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 的部份了


<<:  多国语系魔术

>>:  Day27 - GitLab CI 如何让工作流程流水线跑快一点?之一 从 .gitlab-ci.yml 大部分解

Day 14 - Spring Boot & Thymeleaf

Thymeleaf 是Spring Boot 推荐使用的前端模板引擎,它除了可以完全取代JSP 外,...

Image Compression - JPEG

JPEG概述 Joint Photographic Expert Group在1992年被评为国际标...

Day 06 - Design System — 为什麽前端工程师也该知道它?

新章 突入! 终於进入到期待已久的第二章 Design System 啦! 那在讲 Design ...

Day 14:怎麽在 Angular 使用 Bootstrap?

由於在未来的专案有机会使用到 Bootstrap,所以就藉这个机会来介绍一下如何在 Angular ...

Day 11 - Roman to Integer

大家好,我是毛毛。ヾ(´∀ ˋ)ノ 废话不多说开始今天的解题Day~ 13. Roman to In...