iT邦幫忙

第 12 屆 iT 邦幫忙鐵人賽

DAY 27
1
Modern Web

從 0 開始建一個 Static Site Generator系列 第 27

Day 27: Incremental build

這系列的程式碼在 https://github.com/DanSnow/ironman-2020/tree/master/static-site-generator

Incremental Build 是指只重建必要的部份,而不去動不需要重建的檔案,這在編譯器的領域也有在使用,當檔案多的時候,重建不需要的部份就顯得很浪費時間跟資源,所以這篇就試著來找出資料跟頁面的關係,然後嘗試不要重建那些輸入資料並沒有改變的頁面 (這麼做的一個大前題是,頁面只有用我們掌握的到的方式取得資料,比如像直接讀檔這種方法我們就管不到了)

做這種事情其實並不容易,這邊只是一個簡單的概念,不知道你有沒有聽過 Cache Invalidation 的問題,這其實是一種 cache ,而做 cache 最麻煩的是 cache 要在什麼時候失效的問題, cache 不失效,那使用者就會拿到舊的資料,看場景或許是可以接受的,但像 SSG ,既然重 build 了,那就應該要是新的資料,有時候錯誤的 cache 會比不 cache 還要可怕

假設我們都是從 GraphQL 取得資料的

追蹤頁面與資料的關係

這是很重要的第一步,類似的東西也在很多地方都有應用,比如 Vue 2 就是用 getter ( Vue 3 換成了 Proxy ) 在 render 的過程中追蹤這個變數的改變會不會需要重新 render (有存取某個變數那就是當成是需要) ,我們的 SSG 使用了 GraphQL ,所以算是有個統一的資料來源,只要從資料來源下手,要知道使用了哪些資料並不是難事,我們在 schema.js 中的 resolver 加入追蹤使用的程式碼:

// dependencies 是個全域變數
let dependencies = null

// 省略

const hash = objectHash(node)
// 加入資料節點時幫每個節點都建一個 hash ,用來判斷是不是有改變
// 加入 hash 的想法是參考 Gatsby 的
nodes.push({
  ...node,
  _hash: hash,
})

// 省略

schemaComposer.Query.addFields({
  [`all${typename}s`]: {
    type: schema.getTypePlural(),
    resolve: () => {
      // 用到了全部的節點,如果有增減也要算是有改變,所以要把目前有的節點都存下來
      dependencies?.push({
        type: typename,
        id: typename,
        all: true,
        nodes: nodes.map((node) => ({ id: generateHashKey(typename, node), hash: node._hash })),
      })
      return nodes
    },
  },
  [typename.toLowerCase()]: {
    type: schema,
    args: {
      id: 'ID!',
    },
    resolve: (_, { id }) => {
      const node = nodes.find((x) => x.id === id)
      if (node) {
        // 如果是單一個節點就只存一個節點
        dependencies?.push({
          type: typename,
          id: generateHashKey(typename, node),
          hash: node._hash,
        })
      }
      return node
    },
  },
})

// 用 typename 跟 id 當成 cache 用的 id ,用來判斷是不是同一筆資料
function generateHashKey(typename, node) {
  return `${typename}:${node.id}`
}

所以我們只要給 dependencies 一個陣列, resolver 就會把用到的資料存進去,那真是太棒了

// 另一個全域變數,存的是頁面跟資料的相依性
export const pageDependencies = new Map()

// url 就是頁面的網址
export async function trackDependencies(url, cb) {
  dependencies = []
  // 執行 callback
  await cb()
  // 保存目前紀錄下來的相依性
  pageDependencies.set(url, dependencies)
  dependencies = null
}

太好了,問題解決了,但是上面的 trackDependencies 要放在哪?

收集頁面的相依性

最簡單的方式就是在 index.js 中,收集可能的 url 時也把 query 執行一次,這樣就能取得相依性了

import pMapSeries from 'p-map-series'

// 省略

let possibleRoute = []
for (const route of data.routes) {
  if (route.dynamic) {
    const store = createStore(reducer)
    const paths = await route.getStaticPaths({ store, query })
    possibleRoute = possibleRoute.concat(
      // 這邊要照順序執行,不然會有 race condition 的問題
      await pMapSeries(paths, async (path) => {
        const route = findMatchRoute(path)
        if (route.query) {
          // 用 trackDependencies 包起來就會追蹤相依性了
          await trackDependencies(path, async () => {
            await client.query({ query: route.query, variables: route.params })
          })
        }
        return path
      })
    )
  } else {
    await trackDependencies(route.url, async () => {
      if (route.props.query) {
        await client.query({ query: route.props.query })
      }
    })
    possibleRoute.push(route.url)
  }
}

// 省略

這樣其實會造成 query 執行兩次,不過目前我沒有打算解決這個問題,如果能把執行完的 cache 傳給 server 做 render 的話,應該就不會多 request 了,反到是別的地方出問題了,猜看看是哪?這晚點再說

檢查是否需要重 build

我們寫了一個簡單的函式透過收集來的相依性的資料來判斷是不是需要重新 build:

function needRebuild(previous, current) {
  if (!previous) {
    return true
  }
  // 長度不同
  if (previous.length !== current.length) {
    return true
  }

  // 確保兩邊的順序一致
  previous.sort((a, b) => a.id.localCompare(b.id))
  current.sort((a, b) => a.id.localCompare(b.id))

  for (let i = 0; i < previous.length; ++i) {
    const prev = previous[i]
    const cur = current[i]
    // 有一邊的資料不是取得全部了
    if (prev.all !== cur.all) {
      return true
    }
    if (prev.all) {
      // 取得全部的話就比較所有節點
      const res = compareNodes(prev.nodes, cur.nodes)
      if (res) {
        return true
      }
    }
    // 不然就比較單一節點
    if (prev.id !== cur.id || prev.hash !== cur.hash) {
      return true
    }
  }
  return false
}

function compareNodes(a, b) {
  if (a.length !== b.length) {
    return true
  }

  sortById(a)
  sortById(b)

  for (let i = 0; i < a.length; ++i) {
    const prev = a[i]
    const cur = b[i]
    if (prev.id !== cur.id || prev.hash !== cur.hash) {
      return true
    }
  }
  return false
}

function sortById(nodes) {
  nodes.sort((x, y) => x.id.localeCompare(y.id))
}

用這樣的方式我們就可以知道頁面的資料有沒有改變,再來決定要不要 build 了:

for (const url of possibleRoute) {
  if (!needRebuild(previousDependencies.get(url), pageDependencies.get(url))) {
    console.log(`Skip ${url}`)
    continue
  }
  console.log(`Build ${url}`)
  // 省略
}

知道問題出在哪了嗎?跑一次看看,改個一篇文章,再跑一次看看?有注意到了嗎?之前我們用 Static Query 改寫了首頁,不過 Static Query 並沒有被追蹤,但照理來說文章的資料改變,首頁應該也要重 build 才對,如果把首頁改回用 page query ,是不是又正常了呢?

順帶一提,目前的 SSG 中比較花時間的大概是 webpack ,產頁面的速度還挺快的, webpack 也有加速的方法,這在 Nuxt.js 中有做

下一篇開始就進入 Vue 的部份了


上一篇
Day 26: 載入圖片
下一篇
Day 28: 介紹 Vue 的 Server Side Render
系列文
從 0 開始建一個 Static Site Generator30

尚未有邦友留言

立即登入留言