多进程构建该选 HappyPack 还是 thread-loader

全量构建一次要两分多钟,改一行代码等热更新的间隙经常够冲好一杯咖啡——这套 webpack 配置里有一段 HappyPack,是接手项目时就在的,没人特别去动过。真正要纠结的问题不是"要不要上多进程",而是"这段已经在跑的 HappyPack 配置,还要不要留着":翻 webpack 4 的文档和 issue 会发现,HappyPack 这个项目实际上已经很久没更新了,官方现在推荐的是 thread-loadercache-loader 这一组更新的方案。继续用旧的、还是换成新的,是这次要想清楚的事。

先搞清楚构建慢在哪

在动配置之前,我先确认了一件事:项目慢,慢在哪个环节。webpack 构建要经过读取文件、解析依赖、调用 loader 转换代码、生成依赖图、输出资源这一整条链路,其中 loader 转换往往是耗时大头,但具体是哪个 loader、慢多少,光靠猜不靠谱。我用 speed-measure-webpack-plugin 把每个 loader 和 plugin 的耗时单独打出来看:

1const SpeedMeasurePlugin = require("speed-measure-webpack-plugin");
2const smp = new SpeedMeasurePlugin();
3
4module.exports = smp.wrap({
5  // 原本的 webpack 配置
6});

结果和我预想的不太一样。我以为是 Vue 单文件组件编译占大头,打出来一看,babel-loader 处理那堆业务 JS 才是真正的瓶颈,模板编译反而占比不高。再往下查,是 babel-loaderexclude: /node_modules/ 写漏了,第三方库也被重新转译了一遍,这一个疏忽就吃掉了将近一半的构建时间。这件事说明一个道理:在加任何并行、缓存工具之前,先用数据确认瓶颈,凭感觉优化十有八九会用错力气。

speed-measure-webpack-plugin 打出来的报告分两层,一层是每个 plugin 的耗时,一层是每个 loader 的耗时,而且 loader 那层还会按 test 规则分别列出来,同一个 babel-loader 命中了多少次文件、总共花了多久,一目了然。我把这份报告存了下来,作为后面每次调整配置的基准线——改完 exclude、加上 HappyPack、加上缓存之后,都重新跑一次这份报告,拿数字对比,而不是凭感觉说"好像快了"。这份习惯后来在别的项目上也一直在用,尤其是团队里有人提议要引入某个新工具"提速"的时候,我都会先问一句:跑过 speed-measure-webpack-plugin 吗,瓶颈真的在这个环节吗。

顺带说一句,speed-measure-webpack-plugin 本身也有些限制,它是通过包一层 wrapper 去测量各个插件和 loader 的执行时间,对某些内部做了特殊优化、没有暴露标准生命周期钩子的插件(比如某些版本的 terser-webpack-plugin)测出来的数字会不准,权当一个参考数量级,不必较真到个位数的毫秒差异。

HappyPack 解决的是什么问题

HappyPack 的思路是把 loader 的处理任务分发到多个子进程并行执行:主进程负责调度,HappyPack 创建 worker,文件转换交给 worker 并发处理,处理完再把结果交回主进程。这样能更充分利用多核 CPU,前提是某些 loader 任务可以相对独立地处理——比如大量 JavaScript 文件走 Babel 转译,就比较容易从并行里获益。

容易被忽略的一点是,HappyPack 用的是子进程,进程之间不共享内存,主进程要把文件内容序列化后通过 IPC 发给 worker,worker 处理完再序列化传回来。这一来一回是有成本的:文件越大、数量越多、单个文件转换越耗时,通信成本相对收益就越划算;反过来如果都是几行的小文件,通信开销可能比转换本身还高,这也是为什么项目规模小的时候,引入多进程编译很可能是负收益——进程调度和序列化的开销比省下来的转换时间还大。

这个道理听起来抽象,落到具体数字上会更直观。子进程启动本身有固定开销,Node.js 拉起一个子进程、加载完整的运行时环境,怎么也要几十到上百毫秒,四个 worker 全部拉起来,冷启动阶段这部分固定成本就有小几百毫秒,这还没算真正开始处理文件之前的调度和分发过程。如果项目总共只有几十个文件、每个文件转换只要一两毫秒,全部转换加起来可能也就几十毫秒,比拉起子进程本身还快,这种规模下上 HappyPack 纯粹是负收益。反过来,项目里有几百上千个文件、每个文件转换要几毫秒到几十毫秒不等,几百毫秒的固定成本摊到全局收益里就微不足道了,并行带来的总耗时下降会很明显。这也是为什么网上一些"HappyPack 提速几倍"的分享,换到小项目里复现时经常发现效果平平甚至变慢——脱离了项目规模谈多进程编译的收益,本身就是不完整的。

判断自己的项目属于哪种规模,不需要精确计算,跑一次前面提到的 speed-measure-webpack-plugin,看 loader 转换总耗时是几十毫秒量级还是几秒甚至几十秒量级,心里大致就有数了。

团队里另一个小项目正好是个反例,拿来对照着看更清楚。那是一个内部用的活动配置后台,总共不到五十个文件,之前有人图省事直接把主项目的 webpack 配置整份复制过去,HappyPack 也一起带了过来。跑起来之后开发环境启动时间反而比同事另一个没配 HappyPack 的类似小项目慢了一截,一开始以为是机器问题,换了台机器结果一样,最后翻配置发现是 HappyPack 在这种文件量级下纯属负担——worker 启动和调度的固定成本,比这几十个文件全部转换一遍所需的时间还长。把 HappyPack 摘掉之后,启动时间反而更快了。这件事也提醒自己,构建配置这种东西不太适合"复制大项目那份直接用",规模不匹配的时候,同样的配置在不同项目里效果可能是相反的。

HappyPack 对 loader 也是挑剔的。像 style-loader 这种需要往 DOM 注入、依赖运行时上下文的 loader,放进 HappyPack 就容易出问题。之前把整条 CSS 链路塞进去,样式热更新失灵,sourcemap 也错位了,排查了好一会儿才确认是 HappyPack 的锅。后来带副作用、强依赖顺序的 loader 我都老老实实留在主进程,只把纯转换型的 babel-loaderts-loader 交给它。

那次排查印象比较深,因为一开始完全没往 HappyPack 身上想。现象是改一处 .vue 文件里的 <style> 部分,浏览器页面确实刷新了,但样式没变,要手动强制刷新页面才能看到新样式生效;再往下翻控制台,sourcemap 指向的行号和实际代码对不上,点进去经常是空白或者跳到不相关的位置。一开始怀疑是 vue-loader 版本问题,翻了一遍 CHANGELOG 没找到线索;又怀疑是浏览器缓存,清了缓存问题依旧。最后是把 CSS 那条规则单独从 HappyPack 里摘出来、改回普通 loader 链路,问题立刻消失,才反向确认是 HappyPack 的锅。

这背后的原因值得多说一句:worker 子进程之间是彻底隔离的,一个 worker 完全不知道另一个 worker 处理到了哪个文件、生成了什么中间结果。而 style-loader 这类 loader 的部分实现依赖同一次编译过程里的共享状态(比如收集所有样式模块、统一生成注入代码),一旦文件被分散到不同 worker 里各自处理,这种"全局视角"就没有了,处理结果自然对不上。判断一个 loader 能不能放进 HappyPack,我后来总结了一条简单的准则:这个 loader 的输出只依赖输入文件本身的内容,不依赖其他文件的处理结果、不依赖跨文件的状态收集,才适合丢进子进程并行;反过来,凡是要"看全局"的 loader,都得留在主进程。

source map 在多进程编译下的额外开销

这次测试还顺带发现一个此前没注意的现象:开着完整 source-map 的时候,HappyPack 的收益比预期小很多。原因是 source map 的生成本身就是一个消耗 CPU 和内存的步骤,子进程处理完文件转换后还要把 source map 一并通过 IPC 序列化传回主进程,文件越大,这份 map 数据也越大,通信成本跟着水涨船高。也就是说,多进程编译省下来的时间,有一部分会被"传更大的数据回主进程"这件事重新吃掉。

对比测试的结果比较直观:同一份代码,devtool: 'source-map' 时 HappyPack 带来的构建时间缩短大概在两成左右;换成开发环境常用的 cheap-module-eval-source-map 之后,缩短幅度能到四成以上。这也印证了前面那次单独调整 devtool 就让构建明显变快的经验——source map 的开销和多进程编译的开销是叠加的,两边都要抠一遍才划算。后来我给自己定了个顺序:先确认 devtool 选的是不是当前阶段真正需要的精度,再去考虑要不要上多进程,颠倒过来会高估多进程带来的收益。

一份完整的 HappyPack 配置,连带它的隐藏成本

为了搞清楚这套方案到底值不值,我把项目里的 HappyPack 配置整份摊开重新过了一遍,顺便把之前没细看的选项都补上了。完整版大概是这样:

1const os = require("os");
2const HappyPack = require("happypack");
3
4const happyThreadPool = HappyPack.ThreadPool({
5  size: Math.max(os.cpus().length - 1, 1),
6});
7
8module.exports = {
9  module: {
10    rules: [
11      {
12        test: /\.js$/,
13        exclude: /node_modules/,
14        use: "happypack/loader?id=js",
15      },
16      {
17        test: /\.css$/,
18        use: "happypack/loader?id=css",
19      },
20      {
21        test: /\.ts$/,
22        exclude: /node_modules/,
23        use: "happypack/loader?id=ts",
24      },
25    ],
26  },
27  plugins: [
28    new HappyPack({
29      id: "js",
30      threadPool: happyThreadPool,
31      verbose: true,
32      threads: 4,
33      loaders: [
34        {
35          loader: "babel-loader",
36          options: { cacheDirectory: true },
37        },
38      ],
39    }),
40    new HappyPack({
41      id: "css",
42      threadPool: happyThreadPool,
43      loaders: ["style-loader", "css-loader"],
44    }),
45    new HappyPack({
46      id: "ts",
47      threadPool: happyThreadPool,
48      threads: 2,
49      loaders: [
50        {
51          loader: "ts-loader",
52          options: { happyPackMode: true },
53        },
54      ],
55    }),
56  ],
57};

这里有两个容易忽略的细节。一是每个 HappyPack 实例除了共享的 threadPool 之外,还能单独设 threads,控制这一类文件最多用几个线程去抢线程池里的资源——项目里 TS 文件数量比 JS 少得多,给它单独设成 2,避免和 JS 那边抢线程抢得太凶。二是 ts-loader 塞进 HappyPack 时必须加上 happyPackMode: true,否则 ts-loader 默认会做完整的类型检查,而类型检查这件事本身就依赖跨文件的类型信息(一个文件里用到另一个文件导出的类型),扔进互相隔离的子进程后类型检查要么直接失败要么给出错误的结果。开了 happyPackMode 之后,ts-loader 只做单文件的转译,不做类型检查,类型检查这件事需要单独交给 fork-ts-checker-webpack-plugin 在另一个进程里做——这也是当时最容易漏掉的一步,漏掉之后的现象是构建正常跑完、但类型错误的代码也放过去了,等于类型检查形同虚设。

verbose: true 这个选项值得留着,它会在终端打印出每个 worker 实际处理了哪些文件、耗时多少,调试线程分配不均的问题时很有用——比如发现某个 worker 一直在处理体积特别大的几个文件,其他 worker 早就闲下来了,这种情况下即使开了多个 worker,实际收益也有限,因为 HappyPack 按文件粒度分发任务,不会预先估算每个文件的处理耗时来做负载均衡,纯粹是谁先来谁先派活。

这一点在项目里体现得比较明显:业务代码里大部分文件是几十行的小组件,但有两三个页面文件因为历史原因写得特别臃肿,单个文件超过两千行。开着 verbose 看日志时能明显看到,构建接近尾声阶段,其他 worker 已经显示"空闲",只剩一个 worker 还在处理这几个大文件,等于并行到最后又退化回了串行。这种"长尾任务"的情况,多加 worker 数量不解决问题,因为文件本身没法再拆更小的处理单元交给不同 worker——HappyPack 是按文件分发任务,不会把一个文件内部再拆开并行处理。真正该做的是把这几个臃肿页面本身拆成更小的组件,这属于代码层面的问题,跟构建工具选型没关系,但如果不看 verbose 输出,是不会想到"构建慢"和"某几个文件写得太大"之间还有这层关系的。

配置本身的坑

项目里现有的配置大致是这样,用共享线程池控制 worker 数量:

1const os = require("os");
2const HappyPack = require("happypack");
3
4const happyThreadPool = HappyPack.ThreadPool({
5  size: Math.max(os.cpus().length - 1, 1),
6});
7
8module.exports = {
9  module: {
10    rules: [
11      {
12        test: /\.js$/,
13        exclude: /node_modules/,
14        use: "happypack/loader?id=js",
15      },
16      {
17        test: /\.css$/,
18        use: "happypack/loader?id=css",
19      },
20    ],
21  },
22  plugins: [
23    new HappyPack({
24      id: "js",
25      threadPool: happyThreadPool,
26      loaders: [
27        {
28          loader: "babel-loader",
29          options: {
30            cacheDirectory: true,
31          },
32        },
33      ],
34    }),
35    new HappyPack({
36      id: "css",
37      threadPool: happyThreadPool,
38      loaders: ["style-loader", "css-loader"],
39    }),
40  ],
41};

cacheDirectory: true 这一项值得单独说一句,它和 HappyPack 是两个层面的优化,配合起来才划算。babel-loader 的缓存会把转译结果写到磁盘,默认在 node_modules/.cache/babel-loader,二次构建时没改过的文件直接读缓存,连 worker 都不用派活。实测下来,真正让二次构建提速最明显的其实是这个缓存,HappyPack 解决的更多是冷启动那一次的全量转译——如果只关心热更新体验,缓存的优先级应该排在并行前面。

这里有个容易被忽略的叠加细节:cacheDirectory: true 只是让 babel-loader 把结果写到默认路径,缓存的失效判断依赖一个内部计算出来的哈希值,这个哈希由文件内容、babel-loader 自身版本号、以及传给它的 options(包括 .babelrcbabel.config.js 里的 preset、plugin 配置)共同决定。也就是说,改动 Babel 配置本身——加一个 plugin、升级一个 preset 版本——都会让全部缓存一次性失效,这也是为什么升级 Babel 相关依赖之后,第一次构建往往感觉不到缓存带来的提速,要等到再跑一次才会明显变快。放在 HappyPack 的 worker 里用这个缓存没有冲突,因为缓存判断是 babel-loader 自己内部做的,跟外层是不是多进程调度没关系,两层优化各管各的事:HappyPack 决定"谁来处理这个文件",babel-loader 的缓存决定"要不要真的处理"。

还有一个坑是关于 id 的:一个 HappyPack 实例只能配一组 loader,多个文件类型必须建多个实例、用不同的 id 区分。早期不注意,把 js 和 css 的规则指向了同一个 id,结果 css 文件被 babel-loader 处理,报了一堆语法错误,而且这类错误信息特别不友好——报错发生在子进程里,栈信息传回主进程时已经不全了,对着报错看了好一会儿才反应过来。子进程出错排查起来天然比单进程麻烦,这也是引入 HappyPack 要承担的隐性成本,不只是配置复杂度。

这个 id 写重复的错误还有个更隐蔽的变体:不是完全写成同一个字符串,而是两个 HappyPack 实例的 id 因为复制粘贴时漏改,恰好只有大小写不同(比如一个是 js、另一个手误写成 Js)。webpack 对 id 是大小写敏感的,理论上不会冲突,但排查过程中我一度以为这就是同一个 id,反而把注意力放错了方向,绕了几圈才发现问题其实出在别处——是那个手误的 HappyPack 实例里 loaders 数组顺序写反了,css-loaderstyle-loader 位置调换,导致解析出来的 CSS 内容不完整。这件事的教训是,id 重复只是这一类问题里最显眼的一种,凡是涉及多个 HappyPack 实例的项目,配置改动之后最好养成通读一遍所有实例定义的习惯,而不是只盯着刚改的那一处看。

HappyPack 和一部分新版本 loader 的兼容性也不算稳。像 vue-loader 15.x 之后的版本,官方文档明确不建议塞进 HappyPack,因为它内部对文件的处理依赖一些跨 loader 的上下文信息,扔进子进程后容易丢失。这条兼容性边界不是配置能解决的,属于选型阶段就该排除的组合。

具体一点说,vue-loader 15.x 把一个 .vue 文件拆成 templatescriptstyle 几个虚拟模块分别处理,这几个虚拟模块之间要共享同一份 descriptor(描述这个单文件组件结构的对象),主进程里维护着这份共享状态,各个虚拟模块的处理结果最后还要合并回同一个文件。放进 HappyPack 之后,.vue 文件被派发到某个 worker 子进程里处理,子进程里没有主进程那份 descriptor 缓存,vue-loader 拿不到跨模块的上下文,编译要么直接报错,要么产出结果不完整。这也是为什么这台机器上的旧配置里,.vue 规则一直没有走 HappyPack,只有 .js 和纯 CSS 走,算是前人已经踩过坑之后留下的边界。

HappyPack 完整配置的局限,一次性说清楚

把这台机器上现役的配置摊开看一遍,HappyPack 需要的额外样板代码不少:每一种文件类型建一个 HappyPack 实例、每个实例配一个 id、原本直接写在 module.rules 里的 loader 选项要挪到 HappyPack 的构造参数里、rules 里只留下一句指向 happypack/loader?id=xxx 的引用。这种写法有个明显的副作用:配置被拆成了两处,一处是 rules 里的引用,一处是 plugins 里的实际 loader 定义,读配置的人得来回跳着看才能拼出完整链路,团队里新人接手这份 webpack 配置时基本都要问一句"这个 id=js 到底对应哪些 loader"。

另外,HappyPack 的 ThreadPool 是要手动创建、手动传给每个实例共享的,如果不同实例各自创建了自己的线程池而不是共享同一个,进程数量会比预期多出好几倍,实际测下来这也是不少项目"开了 HappyPack 反而更慢"的一个常见原因——不是并行没用,是线程池管理没做对。这些都是配置层面的复杂度,跟它到底能不能提速是两回事,但确实是这次评估里让我更倾向新规则换用 thread-loader 的一部分理由:同样的效果,样板代码少了一大截,出错的地方也就跟着少了。

不是所有项目都适合

如果项目本身很小,进程调度的成本可能直接抵消并行带来的收益;如果瓶颈不在 loader、而在插件、依赖分析或压缩阶段,HappyPack 也帮不上忙。判断值不值得引入之前,最好先做几件低成本检查,我把这次用到的几项列成了一份清单,后面评估其他项目时也照这个顺序过一遍:

第一项,记录冷启动和二次构建的时间差。如果二次构建已经很快、慢的只是冷启动,那问题往往出在依赖解析或者压缩阶段,跟 loader 转换关系不大,上 HappyPack 用处有限;如果冷启动和二次构建都慢,且二次构建慢到跟改动文件数量不成比例(改一个文件却要等很久),大概率是缓存没生效,先查缓存问题比上多进程更划算。

第二项,关闭 source map 看耗时变化。方法很简单,临时把 devtool 设成 false,跑一次构建对比耗时,如果差距明显,说明 source map 精度选得过高,这一步调整成本几乎为零,应该排在任何多进程方案之前。

第三项,观察是否误编译了 node_modules。这个可以直接在 speed-measure-webpack-plugin 的报告里看,某个 loader 处理的文件数量如果远超预期(比如项目自己的源码文件明明只有几百个,报告里却显示处理了几千个文件),基本可以断定 excludeinclude 写漏了。

第四项,确认 Babel 缓存有没有真的生效。可以直接去看 node_modules/.cache/babel-loader 目录下有没有生成文件,或者用 rm -rf 清空这个目录后对比两次构建耗时的差异,差异明显就说明缓存平时确实在起作用。

第五项,区分清楚是开发构建慢还是生产打包慢。这两种场景关注的东西不一样:开发环境更在意增量构建和热更新的响应速度,生产打包更在意压缩、Tree Shaking、代码分割这些跟输出体积相关的步骤,针对性不同,优化手段也不该混为一谈——生产打包慢,去折腾开发环境的 thread-loader 配置基本是无的放矢。

之前有个项目加了 HappyPack,构建时间几乎没动,一查是 UglifyJsPlugin 在压缩一个巨大的第三方库 bundle 才慢,跟 loader 无关。这种情况该做的是把压缩本身并行化,比如给 UglifyJsPluginparallel: true,HappyPack 在这个环节完全帮不上忙。另一次是 source map,开发环境用了完整的 devtool: 'source-map',生成慢得离谱,换成 cheap-module-eval-source-map 之后构建时间直接砍掉一截,这种调整比折腾 loader 并行划算得多,几乎零成本。现在我的习惯是开发环境用便宜的 source map,生产环境才上完整的,且只在需要排错时才打开。

拿数字说话:这次实测的对比

光讲道理不够,这次评估我把几种组合各跑了三次冷启动构建、取中位数,机器是团队统一配的八核笔记本,项目大概八百多个模块。数字不追求绝对精确,但相对差距足够说明问题:原始配置下,也就是没有 HappyPack、没有缓存、babel-loader 也没排除 node_modules 的状态,冷启动构建接近两分半;光是修正 exclude 这一项,就降到一分二十秒左右,砍掉了将近一半时间,跟前面排查瓶颈时的判断吻合。在这个基础上加 cacheDirectory: true,冷启动本身没有明显变化——毕竟缓存刚开始是空的——但二次构建,也就是改一个文件后重新构建的耗时,从四十多秒降到十几秒。再叠加 HappyPack 开 4 个 worker,冷启动进一步降到大概一分钟出头,二次构建的变化则不明显,因为这时候大部分文件已经能命中 Babel 缓存,HappyPack 插不上手。真正让冷启动继续往下掉的是 devtool 的调整:把它从 source-map 换成 cheap-module-eval-source-map 之后,冷启动降到四十秒左右,是这一整轮单项调整里收益最大的一步。最后把 HappyPack 换成 thread-loadercache-loader 的组合,冷启动耗时和 HappyPack 基本持平,二次构建因为多了 cache-loader 这一层缓存,比只靠 babel-loader 自身缓存还要再快一点,命中缓存的文件甚至不会进入 thread-loader 的调度队列。

这组数字对我最大的提醒是:这次两分半降到四十秒左右,多进程编译只占了其中一小部分贡献,排除误编译 node_modules、调整 source map 精度、开启 Babel 缓存这三件事加起来的收益远超引入 HappyPack 本身。如果只做了"上 HappyPack"这一步、跳过前面几步,收益会被严重低估,容易让人误判"多进程编译没什么用"。

把这几组数字整理成表格贴到内部文档里之后,团队里另一个同事照着这份记录去看自己负责的项目,也是先跑了一遍 speed-measure-webpack-plugin,结果发现他那个项目的瓶颈完全不在 loader 转换,而在一个体积特别大的图片压缩插件上,跟这篇讨论的内容压根不是一回事。这也从侧面印证了前面反复强调的那句话:不同项目的构建瓶颈不能类比,别人的方案能不能照搬,得先看数据说话。

worker 数量不是越多越好

多进程会带来进程创建、通信和结果合并的成本,机器核心数有限时,worker 开太多反而会互相抢资源。更务实的做法是从保守值开始:

1const workerCount = Math.max(require("os").cpus().length - 1, 1);

然后用实际构建时间验证,而不是只看理论值。我在 CI 上栽过一次典型的跟头:本地是八核机器,cpus().length - 1 给了 7 个 worker,跑得飞快;推到 CI 用的是某云的容器,构建反而比不开 HappyPack 还慢。后来查到,容器里 os.cpus() 返回的是宿主机的核数,但容器实际被 cgroup 限制只能用 2 核,于是它兴冲冲开了 7 个 worker 去抢 2 核的资源,进程频繁切换,时间全耗在调度上了。这个坑说明 os.cpus().length 在容器环境里不可信,后来我针对 CI 单独设了一个保守的环境变量:

1const workerCount = process.env.CI
2  ? 2
3  : Math.max(require("os").cpus().length - 1, 1);

宁可保守,也别让 worker 数量超过实际可用核数——多进程优化的前提是真的有多核可用,这个前提在受限环境里经常不成立。

thread-loader 除了 workers 之外,还有几个配置项是我这次顺带查文档时补上的。workerParallelJobs 控制单个 worker 进程里能同时并发处理多少个任务,默认是 20;poolTimeout 控制 worker 闲置多久之后自动回收,watch 模式下开发环境建议设成 Infinity,避免每次热更新都要重新拉起 worker 进程,这个拉起的开销在频繁改代码的时候会很明显;poolRespawn 决定 worker 挂掉之后要不要自动重启,生产构建一般不需要开,出问题直接让构建失败更容易发现问题。这几项默认值大多数场景够用,真正需要手调的场景不多,但排查"为什么 thread-loader 比预期慢"的时候,这几个参数是先看的地方,比重新猜 workers 数量更有效。

1{
2  loader: "thread-loader",
3  options: {
4    workers: workerCount,
5    workerParallelJobs: 20,
6    poolTimeout: process.env.NODE_ENV === "development" ? Infinity : 2000,
7  },
8}

cache-loader 的缓存失效策略也值得单独确认一下,免得以为缓存生效了、实际上一直在重新计算。它默认根据文件内容的哈希、以及传给它前面所有 loader 的 options 共同生成缓存 key,只要 loader 链路里任何一环的配置变了,缓存就整体失效,这一点和 babel-loader 自己那份缓存的失效逻辑是类似的思路。它有个 cacheContext 选项可以配合团队里多人协作、缓存路径可能跨机器不一致的场景使用,但更常踩的坑是缓存目录被算进了 .gitignore 之外的地方,CI 每次都是全新容器、缓存目录不会保留下来,cache-loader 在 CI 上基本等于没起作用,这也是这次顺带确认清楚的一点——本地开发和 CI 环境不能假设享有同一份缓存收益。

HappyPack 还是 thread-loader

回到最初的问题:这段现役的 HappyPack 配置到底要不要换掉。查了一圈资料,HappyPack 的 GitHub 仓库最后一次实质更新已经是很久之前的事,issue 区堆着不少和新版 loader 的兼容性问题没人处理,社区讨论里普遍的说法是它已经处于事实上的停止维护状态。webpack 官方现在维护的替代方案是 thread-loader,定位和 HappyPack 几乎一样,但用法更直接——不用单独建 plugin 实例、不用管 id,直接当一个普通 loader 放进链路最前面:

1{
2  test: /\.js$/,
3  exclude: /node_modules/,
4  use: [
5    {
6      loader: "thread-loader",
7      options: { workers: workerCount },
8    },
9    "babel-loader",
10  ],
11}

thread-loader 一样有进程通信成本,所以同样不适合小项目,这一点和 HappyPack 没有本质区别。它能跟 cache-loader 配合使用,cache-loader 放在链路里负责把上一次的处理结果缓存到磁盘,命中缓存直接跳过后面所有 loader,包括 thread-loader 的调度开销都省掉了:

1{
2  test: /\.js$/,
3  exclude: /node_modules/,
4  use: [
5    "cache-loader",
6    {
7      loader: "thread-loader",
8      options: { workers: workerCount },
9    },
10    "babel-loader",
11  ],
12}

这条链路的顺序有讲究:cache-loader 要放在最前面,因为 loader 链是从右往左执行、缓存命中判断是从左边开始的,放错位置缓存就不会生效。

这个顺序问题第一次是被我自己搞错过一次才真正记住的。最初随手写成了:

1{
2  test: /\.js$/,
3  exclude: /node_modules/,
4  use: [
5    {
6      loader: "thread-loader",
7      options: { workers: workerCount },
8    },
9    "cache-loader",
10    "babel-loader",
11  ],
12}

跑起来没报错,构建也能出结果,但二次构建速度死活提不上去,跟没加 cache-loader时几乎一样。当时以为是缓存目录权限问题,翻了半天权限设置无果,最后才想起来去查 loader 的执行顺序:use 数组里的 loader 是从右到左依次应用的,最右边的 babel-loader先执行,cache-loader 在它前面执行,thread-loader 又在 cache-loader 前面。缓存命中判断是在链路最先执行的那个 loader 上做的——顺序反了之后,实际变成了先经过 thread-loader 的调度,再判断缓存,等于每次都先把任务派发给 worker,缓存查询这一步形同虚设,压根没起到"跳过后续处理"的作用。把 cache-loader挪到数组最前面(也就是最先执行)之后,二次构建才真正体现出缓存的效果。

这件事让我对 use 数组顺序这件事多留了个心眼:不只是 thread-loader/cache-loader 这一组,sass-loaderpostcss-loadercss-loaderstyle-loader 这类经典链路同样对顺序敏感,写反了不见得会报错,很可能是悄悄跑出一个"能用但效果不对"的结果,比 HappyPack 报错栈不完整这种"看得见的坑"更难发现。

出错时怎么排查

多进程编译带来的另一个真实成本是排错变难了。单进程时,一个 loader 抛出异常,webpack-cli 打出来的错误栈能直接指到出问题的文件和具体代码行;HappyPack 因为错误发生在 worker 子进程里,要先被子进程捕获、序列化、通过 IPC 传回主进程,这个过程里部分堆栈信息会丢失,实际拿到的错误经常只有一句笼统的提示,没有具体到是哪一行代码出的问题。

排查这类问题,我总结了一个笨但管用的办法:怀疑是某个文件在 HappyPack 里出错时,先把这条规则的 use 临时改回不走 HappyPack 的普通写法,单进程里重新跑一次构建,绝大多数情况下同样的错误会在单进程里给出完整得多的堆栈,定位到具体文件之后再切回 HappyPack 验证是否真的解决。thread-loader 在这方面体验好不少,它的错误信息更贴近原生 loader 抛出来的样子,栈信息基本不会丢,这也是这次决定新规则改用它的理由之一,不完全是为了配置简单。

watch 模式下的差异

开发环境大部分时间是在 watch 模式下反复触发增量构建,这一块 HappyPack 和 thread-loader 的表现也不一样。HappyPack 每次文件变化后,仍然要走一遍 worker 派发,即便只改了一个文件;thread-loader 配合 cache-loader 之后,只有真正变化的文件会重新进入处理链路,没变的文件直接命中缓存,worker 甚至都不会被唤醒。项目里改一个 .vue 文件的 <script> 部分,thread-loader 组合下感知到的热更新反应明显更跟手,HappyPack 那一套即便 worker 已经预热过,每次还是有一点点派发和序列化的固定开销,量不大,但改动频繁的时候这个差值是能感觉出来的。

和团队里一位资深前端核对这个结论时,他提了一个我没细想过的角度:团队里不止这一个项目在用 HappyPack,如果只在这一个项目里换掉,其他项目的历史配置还是老样子,长期看维护成本没有真正下降,反而多了一种"新项目用 thread-loader、老项目用 HappyPack"的混用状态,新人接手哪个项目都得先弄清楚这个项目具体用的是哪一套。这个顾虑有道理,但眼下没办法一次性解决——把所有历史项目的构建配置都排查一遍、逐个替换,工作量和风险都不小,不是这次评估能顺带做完的事。暂时的做法是把这次的对比结论和示例配置整理成一份内部文档,放在团队的 wiki 里,后面不管是新项目起步、还是老项目恰好要动构建配置的时候,可以照着这份记录直接用新方案,而不是每个项目各自摸索一遍。

这次的取舍结论是:新建的规则直接切到 thread-loadercache-loader,跑起来的表现和 HappyPack 相当,配置反而更简单,出问题时报错信息也更接近原生 loader 的样子,不像 HappyPack 那样经过一层子进程封装后错误栈会缺失。至于项目里原有的那段 HappyPack 配置,暂时没有必须马上换掉的理由——它工作正常,牵一发动全身去替换存在回归风险,但新的规则不会再照抄这段旧配置了。这大概也是过渡期该有的态度:不必因为工具"过时"就立刻推翻还在正常工作的东西,但新代码要往更有生命力的方向走。

真要把旧配置整体迁移过去,工作量比想象中大一些,不是简单的字符串替换。原来的 HappyPack 实例里,ts 那一组配了 happyPackMode: true 配合 fork-ts-checker-webpack-plugin 做类型检查分离,迁移到 thread-loader 之后,这个分离类型检查的思路依然成立,ts-loader 本身有一个 transpileOnly 选项对应同样的效果,但选项名字不一样,直接照抄旧配置的写法会报"未知选项"的错误,得对照 ts-loader 的文档重新过一遍每一项。css 那一组倒是简单,因为它本来就没塞进 HappyPack(前面踩过 style-loader 的坑之后就退出来了),迁移时原样保留普通 loader 链路即可,不涉及 thread-loader。这也是为什么这次没有选择"整体迁移",而是"新规则用新方案,旧规则先放着"——两种方案在同一个项目里短期共存,对读配置的人不算友好,但比起一次性大改带来的回归风险,权衡下来还是分批迁移更稳妥。

另一条路:DllPlugin

评估过程里还顺带对比了一下 DllPlugin,这是 webpack 自带的另一种提速手段,思路和多进程编译完全不同,不是在"并行处理"上做文章,而是"减少重复处理"。第三方依赖(Vue、Vue Router、Vuex、Element UI 这些)版本基本不会频繁变,没必要每次构建都重新走一遍解析、转换的完整流程。DllPlugin 把这些依赖提前单独打包成一份 dll 文件,正常构建时用 DllReferencePlugin 直接引用这份已经编译好的产物,跳过对这部分代码的重复处理:

1// webpack.dll.config.js
2const path = require("path");
3const webpack = require("webpack");
4
5module.exports = {
6  mode: "development",
7  entry: {
8    vendor: ["vue", "vue-router", "vuex", "axios"],
9  },
10  output: {
11    path: path.resolve(__dirname, "dll"),
12    filename: "[name].dll.js",
13    library: "[name]_[hash]",
14  },
15  plugins: [
16    new webpack.DllPlugin({
17      path: path.resolve(__dirname, "dll", "[name]-manifest.json"),
18      name: "[name]_[hash]",
19    }),
20  ],
21};
1// 正常构建的配置里引用
2new webpack.DllReferencePlugin({
3  manifest: require("./dll/vendor-manifest.json"),
4});

DllPlugin 和 HappyPack、thread-loader 解决的不是同一层问题,两者可以叠加使用:DllPlugin 减少了需要处理的文件总量(第三方依赖不用再走一遍 loader 链路),thread-loader 让剩下那些真正要处理的业务代码并行转换。代价也很直观:多了一份需要单独维护的构建脚本,第三方依赖版本一变就要重新跑一次 dll 构建,否则 manifest 和实际代码对不上会直接报错;而且这份 dll 产物要走一条独立的构建流程,团队里但凡有人忘了升级依赖后重新生成 dll,排查起来会摸不着头绪,容易变成新的隐性维护成本。这次没有立刻在项目里引入,先记下来作为下一步如果还嫌启动慢时的备选项。

顺手拿 speed-measure-webpack-plugin 测了一下只加 DllPlugin、不动 loader 层面配置的效果:第三方依赖打包这部分从原本占整体构建时间的三成左右,降到几乎可以忽略——因为正常构建时根本不再解析这些依赖的源码,直接引用编译好的 dll 文件。这个收益和 HappyPack/thread-loader 的收益是两条不同的曲线,一个减少工作量,一个提升处理工作量的速度,叠加起来理论上比单独用任何一个都快,但团队最终还是决定先不引入,主要顾虑是维护成本要摊给谁——这个项目里没有专门维护构建配置的角色,多一份需要手动同步的产物,长期看比短期的构建速度更值得权衡。

评估 DllPlugin 的时候顺带也翻到了 hard-source-webpack-plugin,思路跟 DllPlugin 不完全一样,它是给整个 webpack 构建结果加一层持久化缓存,把模块解析、loader 转换之后的中间结果全部序列化写到磁盘上的一个缓存目录,下一次构建时如果文件内容和依赖关系没变,直接从这份缓存里读,连 loader 都不用重新跑一遍:

1const HardSourceWebpackPlugin = require("hard-source-webpack-plugin");
2
3module.exports = {
4  plugins: [new HardSourceWebpackPlugin()],
5};

装上试了一次,第二次冷启动(也就是缓存已经写过一次之后,模拟重启开发服务器的场景)比单纯依赖 babel-loader 自身的 cacheDirectory 还要快出一截,因为它缓存的不只是 Babel 转译结果,还包括模块解析、依赖图这些通常不会被单个 loader 缓存覆盖到的中间产物。但这个插件当时同样是社区维护、更新频率不算高,跟某些 plugin(尤其是涉及动态生成文件名、hash 的插件)搭配时会出现缓存失效判断不准的问题,表现为改了代码但构建产物没变化,得手动删掉缓存目录才能刷新。团队里最终没有正式引入,倒不是效果不好,是这类构建期缓存一旦判断出错,排查起来比 HappyPack 的报错栈丢失还要隐蔽——它不报错,只是悄悄用了旧结果,如果没有养成"构建完顺手看一眼产物 hash 有没有变"的习惯,这类问题能潜伏很久才被发现。跟 DllPlugin 一样,先记下来作为备选项,暂不作为默认配置的一部分。

给 CI 单独验证一遍

评估这套方案还有一步容易被漏掉:本地验证通过不等于 CI 上也一样。前面提到过 os.cpus().length 在容器里可能拿到宿主机核数、跟实际可用核数对不上的坑,这次顺便把 CI 流水线里的构建耗时也单独测了一遍,确认新规则在 CI 上不会比本地更慢。做法是在 CI 脚本里加一行输出,让每次构建都打印出实际生效的 workerCount 和总耗时:

1console.log(`[build] workerCount = ${workerCount}, NODE_ENV = ${process.env.NODE_ENV}`);
1# CI 流水线里的一步,跑完构建打印耗时
2- name: Build and measure
3  run: |
4    START=$(date +%s)
5    npm run build
6    END=$(date +%s)
7    echo "build cost: $((END - START))s"

跑了几次之后确认,CI 容器给 workerCount 设了固定值 2 之后,构建耗时比本地八核机器慢,但比不开 thread-loader 时依然要快一截,没有出现"CI 上反而更慢"的情况。这一步验证花的时间不多,但如果跳过,很可能只在本地感觉良好,一推到 CI 就发现流水线变慢了,回头再排查会比现在多花几倍时间。

疫情远程期间顺带确认的一件事

年初那段时间团队一直在家远程办公,每个人机器配置、网络环境都不太一样,这次顺带也把"构建速度是不是跟机器差异关系更大"这个疑问确认了一下。几个同事分别在自己机器上跑了同一份新配置,结果性能差距确实存在,但不算悬殊——用的都是公司统一配的开发机型号,差异更多来自各自后台开着的其他程序占用了多少内存和 CPU,不是配置本身的问题。多进程编译方案对机器配置没有那种"必须高配才能用"的硬性门槛,只要核数够、内存不是特别紧张,效果都比较稳定,这也是为什么给远程办公期间的同事同步这份配置时,没人反馈说自己机器上完全跑不动。

什么时候值得为这件事花时间

评估完这一圈之后,我给自己整理了一条判断顺序,不只适用于这个项目,后面遇到类似的构建提速需求时都可以照这个顺序走一遍:先确认瓶颈到底在哪个阶段(loader 转换、插件处理、压缩、还是模块解析本身),再检查是不是有配置疏漏在做无意义的重复工作(exclude 漏写、devtool 精度过高、缓存没生效),这两步做完往往已经能解决大半问题;只有确认剩下的瓶颈确实集中在可以独立处理的 loader 转换上,且项目规模足够大、文件数量足够多,才轮到考虑多进程编译;至于要不要再叠加 DllPlugin 这类减少工作量的手段,取决于第三方依赖的体量和团队是否有余力维护这份额外的构建脚本。这几步顺序不能颠倒,颠倒了容易把后面的手段用在不该用的地方,投入了配置复杂度,却换不回等比例的构建速度提升。

并行处理能缓解构建瓶颈,但前提是先有数据证明瓶颈确实在能并行的环节。HappyPack 曾经解决过实际问题,现在的位置更像是能解释历史配置为什么长这样,而不是新项目的首选。优化的顺序始终是:先用 speed-measure-webpack-plugin 测瓶颈,再排除无意义编译和多余的 source map 精度,然后开启 babel-loadercache-loader 这类缓存,最后才轮到考虑并行、DllPlugin 或更换工具本身——每一步做完都拿数据重新量一次,而不是凭印象判断"这样应该更快了"。

这台机器上那段跑了一年多的 HappyPack 配置还会继续跑下去,直到某次确实需要改动那部分构建规则时,才会顺手换成新方案。这个节奏不算激进,但比起一次性推翻重来,更符合这个项目当下的实际情况。