一、从一个让人头疼的构建说起

如果你在一个大公司上班,代码仓库可能大得离谱——几十万个文件,几千个模块,几百个团队同时往里面提交代码。这种仓库我们叫“单体仓库”。好处是大家共享基础设施,坏处是构建起来那叫一个慢。尤其到了下午高峰期,改一行代码想看看效果,好家伙,构建一分钟起步,喝口水回来还没好。

后来我们上了Turbopack。这玩意儿号称比Webpack快十倍,用Rust写的,本身能力很强。但光靠工具还不够,得学会怎么“正确地用”。今天我就想跟你聊聊我们在大型单体仓库里,怎么用Turbopack做增量构建优化,重点讲两个技术:依赖图剪枝和变更范围分析。听起来很专业,其实道理很简单——就像整理衣柜,你只想找今天要穿的那件T恤,没必要把整个衣柜全翻一遍。

二、Turbopack增量构建的核心逻辑

2.1 什么是增量构建

增量构建的意思是:不是每次从零开始打包,而是只重新构建那些“变了的东西”,以及被这些变化影响到的东西。Turbopack天生支持这种模式,因为它把模块编译后的结果缓存下来了。你改了一个文件,它就去比对前后两次的差异,然后只更新相关的部分。

好比你写作文,老师只让你改第二段,你不会把整篇作文重新抄一遍。Turbopack就是那个聪明的老师,知道你改了哪里,也知道哪些段落因为改动需要重写。

2.2 依赖图是什么

要搞清楚谁影响谁,就得有一个地图。这个地图叫“依赖图”。每个文件是一个节点,如果A文件引用了B文件,那么在A和B之间就有一条边,方向是从A指向B。

举个例子,我们有一个入口文件 index.js,它引用了两个模块 a.jsb.js,而 a.js 又引用了 c.js。那么依赖图就像这样:

// 这是一个纯粹用来描述的JavaScript伪代码
// 实际Turbopack内部是用Rust实现的,但思路一样

// 定义模块之间的引用关系
const depGraph = {
  'index.js': ['a.js', 'b.js'],  // index引用了a和b
  'a.js': ['c.js'],              // a引用了c
  'b.js': [],                    // b没有引用别人
  'c.js': []                     // c是叶子节点
};

当你改动了 c.js,那么 a.js 可能受影响,而 index.js 因为间接依赖 c.js,也可能需要重新构建。Turbopack的工作就是沿着依赖图往上找,把这条链路上的模块都更新一遍。

三、依赖图剪枝:把没用的边剪掉

3.1 剪枝思路

依赖图剪枝,说白了就是“去掉那些实际上不需要的依赖关系”。为什么依赖会有多余呢?因为在大型单体仓库里,代码往往有历史包袱。比如一个工具函数原本用了 lodash,后来重构了自己实现,但 import 语句没删干净;再比如有些文件引用了一整个 UI 库,其实只用到其中一个组件。

这些没用的边会让构建范围变大。Turbopack虽然快,但如果你把所有不需要的依赖也拉进来,它也得编译那么多模块。

所以我们要做的是:分析依赖图,把那些“没有实际调用”的边剪掉。怎么做?最直接的办法是看代码内容,看看是否有 importrequire 语句真的用到了导出的变量。

3.2 基于文件内容hash的剪枝

我们可以给每个文件计算一个内容哈希。如果一个文件的内容变了,哈希就会变。利用哈希,可以快速判断一个模块是否真的依赖另一个模块。

举个例子,假设我们有这样的代码:

// 文件: utils.js
export function add(a, b) {
  return a + b;
}

export function multiply(a, b) {
  return a * b;
}

然后有另一个文件:

// 文件: index.js
import { add } from './utils.js';

console.log(add(1, 2)); // 只用了add,没用到multiply

从文本上看,index.js 引用了 utils.js。但如果我们做“摇树优化”,会知道实际上只用到了 add 函数,multiply 可以被剪掉。Turbopack会分析这种 ES Module 的导入导出关系,从而在构建时省略不必要的代码。

但是这里有个重要前提:剪枝一定不能剪错。如果代码里有副作用(比如 import 一个模块只是为了执行它的初始化代码),那剪掉就出bug了。所以Turbopack很谨慎,只有它能明确证明某段代码没有副作用时,才会剪。

3.3 动态导入的处理

动态导入让剪枝变得更复杂。比如下面这个:

// 文件: router.js
const pages = {
  home: () => import('./pages/home.js'),
  about: () => import('./pages/about.js')
};

export function loadPage(name) {
  return pages[name]();
}

这里的 import() 是运行时才决定的,Turbopack没法静态知道 name 到底是什么,所以它不能随便剪掉 homeabout 的依赖。对于这种情况,我们就得靠“变更范围分析”来辅助判断:如果 home.js 没变,那么就没必要重新加载这个动态模块。

剪枝的实质是“缩小依赖图中边的数量”,而变更范围分析则是“缩小需要重新计算的节点集合”。两者配合,才能让构建性能最大化。

四、变更范围分析:只构建改到的东西

4.1 确定变更模块

变更范围分析的第一步:找出本次改动了哪些文件。这通常用 git diff 来做。比如我们能拿到一个变更文件列表。

假设我们改了两个文件:src/utils.jssrc/pages/about.js。那么Turbopack会先标记这两个节点为“脏的”,意思是它们需要重新编译。

然后它要解决:哪些其他节点也受影响?比如 utils.js 被好几十个文件引用,那这几十个文件是否都要重新构建?不一定。因为 utils.js 虽然变了,但如果只是改了一个注释,或者改了一个函数名但调用方没变?这里需要更细粒度地分析。

4.2 从变更模块向上找受影响范围

我们用一个简化模型来说明。假设依赖图是这样一个字典:

// 描述依赖关系的邻接表
// key是模块,value是该模块依赖的其他模块
const dependents = {
  'index.js': ['utils.js', 'pages/home.js'],
  'pages/home.js': ['utils.js', 'components/button.js'],
  'pages/about.js': ['utils.js'],
  'components/button.js': ['style.css'],
  'utils.js': []
};

现在 utils.js 变了。我们要找出所有直接或间接依赖 utils.js 的模块。写个简单函数:

function findAffected(graph, changedFiles) {
  // graph: 反向依赖图,key是模块,value是谁依赖了这个模块
  // 这样更方便向上找
  const reverseGraph = {};
  for (const [module, deps] of Object.entries(graph)) {
    for (const dep of deps) {
      if (!reverseGraph[dep]) reverseGraph[dep] = [];
      reverseGraph[dep].push(module);
    }
  }

  const affected = new Set(changedFiles);
  const queue = [...changedFiles];

  while (queue.length > 0) {
    const current = queue.shift();
    const parents = reverseGraph[current] || [];
    for (const parent of parents) {
      if (!affected.has(parent)) {
        affected.add(parent);
        queue.push(parent);
      }
    }
  }

  return Array.from(affected);
}

// 调用示例
const graph = {
  'index.js': ['utils.js', 'pages/home.js'],
  'pages/home.js': ['utils.js', 'components/button.js'],
  'pages/about.js': ['utils.js'],
  'components/button.js': ['style.css'],
  'utils.js': []
};

const changed = ['utils.js'];
console.log(findAffected(graph, changed));
// 输出: ['utils.js', 'index.js', 'pages/home.js', 'pages/about.js']
// 注意components/button.js不受影响,因为它不依赖utils

这个函数很简单,但真实场景要复杂得多。比如有循环依赖,有动态导入,还有通过 import.meta.glob 之类的批量导入。如果处理不好,可能会漏掉模块或者重复构建。

4.3 复杂场景:重命名、全局样式、类型文件

在大型单体仓库里,有几个容易坑的地方:

第一,重命名文件。你把 old.js 重命名为 new.js,那么所有 import './old.js' 都会变成 import './new.js'。这实际上是一次“删除+新增”操作。Turbopack需要感知这种变化,否则旧缓存可能不能用。

第二,全局样式。很多人喜欢在入口文件引入一个 global.css,然后里面写一堆全局选择器。这个文件一变,理论上所有页面样式都可能受影响。但Turbopack不能简单地把整个页面树都重新构建,那样太慢了。它需要分析样式选择器的具体影响范围。如果做不到,就只能保守地全量刷新。

第三,类型文件(.d.ts)。在 TypeScript 项目中,类型声明文件往往不参与运行时构建,但会影响类型检查。Turbopack默认会忽略类型文件,只做转译。但如果你配置了 tsc 做类型检查,那么类型文件的变更也应该触发相应模块的重建。这需要我们在优化时做出取舍:是跑一遍快速构建然后跳过类型检查,还是为了保险起见,把类型检查也纳入增量范围。

五、取舍策略:不是所有剪枝都值得

5.1 安全优先,性能其次

我们在做优化的时候,最重要的一条原则:宁可构建慢一点,不能构建错。如果剪枝剪出了问题,导致线上bug,那省下的几秒钟毫无意义。

所以Turbopack的设计也是保守的。它只有在“确定安全”的情况下才会剪枝。比如 import { add } from './utils.js',它能确定你只用了 add,那就可以把 multiply 的代码剔除。但如果遇到 import * as utils from './utils.js',它就无法确定你用到了哪些,只能全部保留。

我们自己的实践也是这个原则。比如前面写的 findAffected 函数,我们不会只依赖这个,还会额外检查一些“特殊文件”,比如 package.jsontsconfig.jsonwebpack.config.js 等等。这些配置文件一旦变化,影响的是整个构建,不能走细粒度增量。

5.2 粒度太细的坑

你可能觉得,既然要快,那就把粒度搞得越细越好。比如一个文件里每个函数都单独缓存,哪个变了就只编译哪个。听起来很美好,但实际上有个问题:函数的依赖边界不好确定。一个函数可能引用了外部变量,另一个函数可能修改了全局状态。如果这种“隐式依赖”存在,你很难做到函数级增量。

另外,粒度过细会导致缓存元数据变得很大,查找、比对的开销反而上升。就好比你把衣柜分成几千个小格,每个格子里放一双袜子,找起来反而麻烦。

我们最终在一个 tradeoff 中选择了“文件级 + 少量函数级优化”的混合粒度。对于纯函数工具库,允许做更细的剪枝;对于有副作用或复杂上下文的模块,老老实实整个文件重新构建。

5.3 缓存失效的权衡

Turbopack有持久化缓存。它会把编译结果存到磁盘上,下次直接用。但缓存一旦失效,就得重新编译。失效条件是什么?通常是文件内容变了,或者依赖图变了。

有时候,一个微小改动会导致大量缓存失效。比如修改了 package.json 里的依赖版本号,虽然代码没变,但所有模块的依赖路径都可能变,缓存就全废了。这种情况下,如果我们能先做一层“依赖版本号校验”,如果版本号没变,就忽略 package.json 的内容变化,就能保留缓存。但这么做有风险:万一有人改了 package.json 里的某项配置,但没改版本号,却影响了构建?这很难说得准。

我们的策略是:将 package.json 的“依赖项字段”和“其他字段”分开计算哈希。只有依赖项字段变了,才触发全量失效;其他如 scriptsdescription 之类的变化,不影响构建缓存。这样可以减少不必要的缓存失效。

六、实际应用中的注意事项

6.1 正确配置monorepo的watchman

Turbopack在增量构建时,需要知道哪些文件变化了。它通常依赖文件监听工具,比如 watchman。在大型单体仓库中,文件数量巨大,监听所有目录可能内存爆炸。我们只监听源码目录和配置文件目录,忽略 node_modules.gitdist 等。配置很简单:

# 在仓库根目录创建一个 watchmanconfig 文件
# 注意事项:忽略不需要监听的目录可以大幅减少内存和CPU开销

或者用 .watchmanconfig

{
  "ignore_dirs": [
    "node_modules",
    ".git",
    "dist",
    "build/temp"
  ]
}

Turbopack 读取这个配置,就能避免无谓的监听。

6.2 环境变量和构建参数的影响

增量构建的缓存还跟环境变量有关。比如你设置了 NODE_ENV=production,那么代码可能被压缩。如果之前缓存是 development 模式下的,就不能复用。我们需要在缓存标识中加入环境变量和构建模式的哈希。

例如,在 turbopack.config.js 中:

module.exports = {
  // 其他配置...
  cache: {
    // 将环境变量序列化后作为缓存key的一部分
    key: process.env.NODE_ENV || 'development'
  }
};

当然真实配置不是这么简单,但思路是这样。还要注意,有些环境变量被构建过程读取,比如 API_BASE_URL,它变化后,使用这个变量的模块也需要重新构建。Turbopack会在编译时把 process.env.API_BASE_URL 替换成具体的值,那么当你改了这个环境变量,它需要让相关模块的缓存失效。所以我们在 CI 里,如果环境变量变了,最好直接清空构建缓存,避免神不知鬼不觉地用到旧值。

6.3 与CI配合

在本地开发时,增量构建是个好东西。但在 CI 上,我们常常做“干净构建”,因为要保证可复现性。但干净构建太慢,所以我们也让CI支持增量缓存——把 .turbo 或者 node_modules/.cache 目录作为 artifact 上传到对象存储,下次拉下来继续用。

这里有个重要的注意事项:缓存目录必须是相对于仓库路径的。如果 CI 的工作目录每次不同,缓存就找不到了。我们统一使用 /build/repo 作为工作目录,这样缓存可以稳定复用。

还有一个容易踩的坑:多个 CI 任务并行构建同一个仓库的不同部分。如果它们共享同一个缓存目录,可能会出现写冲突。我们给每个任务分配独立的缓存目录,比如 /cache/${CI_JOB_ID},任务结束后再合并到公共缓存。这样既安全又快。

七、总结

在大型单体仓库里,Turbopack的增量构建优化不是开箱即用的。依赖图剪枝能减少不必要的模块编译,但需要精准地判断依赖的可靠性。变更范围分析能让你只构建受影响的部分,但在处理动态导入、全局样式和文件重命名时,必须小心翼翼。

我们最后形成的策略是:安全第一,性能第二;用“文件级 + 特定函数级”的混合粒度;把 package.json 等配置文件单独处理;做好环境变量的缓存键;在CI中设计可复用的持久化缓存。这样下来,我们平均构建时间从原来的 80 秒降到了 12 秒左右,而且没有出现因为剪枝导致的线上事故。

优化路上没有银弹。每个仓库都有自己的“性格”,你需要不断分析依赖图,观察哪些边是真实的,哪些边是历史的惯性。多写一些辅助脚本,多跑几次对比测试,慢慢就能找到属于自己的平衡点。希望这篇文章能给你一些启发,让你的单体仓库也能“快人一步”。