1. 为什么单进程打包会卡到让人怀疑人生
前端项目一旦过了“几十个页面、几百个模块”这个坎,webpack 的构建时间就会从“喝口水就完了”变成“泡杯咖啡还没好”。我最早接手一个中型后台项目时,npm run build要跑将近四分钟,热更新一次等十几秒,改一行样式都要盯着进度条发呆。后来把构建日志打开逐项分析,发现时间几乎全耗在两件事上:loader 对大量文件的转译,以及Terser 对 chunk 的压缩。这两步都是 CPU 密集型任务,而 webpack 默认只在单进程里跑,多核 CPU 的其他核心基本在围观。
这个项目的核心目标很明确:用多进程把 loader 转译和代码压缩这两块最耗时的环节并行起来,把构建时间压下去。它适合所有还在用 webpack 4、项目规模已经上来、但暂时不打算迁移到新构建工具的前端团队。哪怕你只是想让 CI 上的构建快一点,这套思路也能直接抄。
需要先说明一点:webpack 4 本身是支持多进程的,只是默认没开。它提供了thread-loader把 loader 丢到 worker 池里执行,又允许通过terser-webpack-plugin的parallel选项开启并行压缩。这两个能力组合起来,就是标题里说的“多进程打包耗时 loader、并行压缩”。下面我按实际改造过程,把每一步的取舍、参数和踩过的坑都摊开讲。
2. 整体方案设计与选型思路拆解
2.1 先搞清楚时间到底花在哪
动手之前一定要先量化,不然优化就是盲人摸象。webpack 4 自带--profile和--json可以导出构建统计,也可以用speed-measure-webpack-plugin给每个 loader 和插件计时。我当时跑出来的结论很典型:
| 阶段 | 耗时占比 | 是否 CPU 密集 |
|---|---|---|
| babel-loader 转译 | 约 45% | 是 |
| Terser 压缩 | 约 30% | 是 |
| 模块解析与依赖图 | 约 15% | 部分 |
| 资源生成与写盘 | 约 10% | 否 |
可以看到,转译和压缩加起来占了七成以上,而且都是纯 CPU 计算。这类任务天生适合并行——把大文件切成小块,分给多个 CPU 核心同时处理。反过来,模块解析、依赖图构建这些有强顺序依赖的环节,硬拆反而会因为进程间通信开销变慢,所以不在本次优化范围内。
2.2 为什么选 thread-loader 而不是 happyPack
webpack 4 时代做多进程 loader,主流有两个选择:happypack和thread-loader。我最终选了thread-loader,理由有三条。
第一,thread-loader是官方维护的,和 webpack 4 的兼容性更稳,配置方式也更贴近 webpack 原生 loader 链。第二,happypack需要为每种 loader 单独建一个 plugin 实例,配置繁琐,而且它对 webpack 5 基本停更了,属于历史包袱。第三,thread-loader的粒度更细,可以只挂在真正耗时的 loader 前面,其他 loader 照常走主进程,不会一刀切。
不过thread-loader有个硬性限制必须提前知道:它后面的 loader 不能调用 webpack 的 API,也不能产出自定义文件。因为 worker 进程里拿不到完整的 compiler 实例。所以像file-loader、url-loader这种要写文件的 loader,绝对不能放在thread-loader后面。我一般只把babel-loader、ts-loader这类纯字符串转换的 loader 交给它。
2.3 并行压缩为什么用 terser-webpack-plugin
webpack 4 内置的压缩器就是terser-webpack-plugin,只是默认parallel为false。很多人不知道,只要把它显式配置出来,压缩就能自动按 CPU 核心数并行。它的并行原理是把 chunk 列表分片,交给 worker 子进程各自压缩,最后合并结果。
这里有个容易忽略的点:parallel的默认值在 webpack 4 里其实是os.cpus().length - 1,但只有在mode: 'production'且没有手动覆盖optimization.minimizer时才生效。一旦你自己写了minimizer数组却没配parallel,并行就悄悄失效了。我见过不少项目就是因为这个细节,白白浪费了多核性能。
2.4 方案的整体结构
最终方案分三层:入口层保持原有 webpack 配置不动;loader 层在 babel-loader 前插入 thread-loader,并配置 worker 池大小;压缩层显式声明 terser-webpack-plugin 并开启 parallel。三层各管一段,互不干扰,出问题也容易定位。下面进入具体实现。
3. 核心细节解析与实操要点
3.1 thread-loader 的 worker 池到底开多大
thread-loader的workers参数决定开几个 worker 进程。官方文档说默认是os.cpus().length - 1,但这个默认值在 CI 环境里经常出问题——CI 机器可能只分配了 2 核,减一就剩 1 个 worker,等于没并行。所以我的做法是显式指定,并且根据环境动态计算。
const os = require('os'); const cpuCount = os.cpus().length; // 留一个核心给主进程和系统调度,最少开 2 个 const workerCount = Math.max(cpuCount - 1, 2);为什么留一个核心?因为主进程还要负责模块解析、依赖图维护、文件写盘,如果 worker 把核心占满,主进程会被饿死,整体反而变慢。实测在 8 核机器上开 7 个 worker,比开 8 个快约 8%。另外 worker 数量不是越多越好,每个 worker 启动本身有开销,项目模块少于 100 个时开多进程甚至会更慢。
3.2 thread-loader 的 warmup 和 poolTimeout
thread-loader还有两个参数值得调:warmup和poolTimeout。
warmup用来预热 worker 池,让 worker 提前加载好 loader 依赖。对于 babel 这种依赖一大堆 preset 的 loader,预热能省掉每个 worker 首次执行时的初始化时间。我一般设warmup: 3,意思是预热 3 个 worker,剩下的按需启动。
poolTimeout控制 worker 空闲多久后销毁,默认 500ms。在 watch 模式下,如果两次构建间隔较长,worker 会被销毁,下次构建又要重新启动。我把它调到2000,让 worker 多存活一会儿,热更新时能直接复用,实测热更新首包时间能少 1 到 2 秒。
{ loader: 'thread-loader', options: { workers: workerCount, warmup: 3, poolTimeout: 2000 } }3.3 哪些 loader 该进池,哪些绝对不能进
这是最容易踩坑的地方。我整理了一份实际项目里的清单:
| loader | 是否放 thread-loader 后 | 原因 |
|---|---|---|
| babel-loader | 是 | 纯字符串转译,无副作用 |
| ts-loader | 是(需配 happyPackMode) | 类型检查耗时,适合并行 |
| vue-loader | 否 | 依赖 webpack 上下文 |
| file-loader | 否 | 需要写文件 |
| url-loader | 否 | 需要读文件并生成 dataURI |
| style-loader | 否 | 依赖运行时注入逻辑 |
| css-loader | 视情况 | 简单项目可放,复杂项目建议不放 |
特别提醒ts-loader:如果要用 thread-loader,必须开启happyPackMode: true,否则类型检查会在 worker 里报错。但开了之后类型检查会被跳过,需要额外用fork-ts-checker-webpack-plugin单独做类型检查。这个组合我用了很久,稳定可靠。
3.4 并行压缩的参数怎么配
terser-webpack-plugin的配置有几个关键点。首先是parallel,直接设true让它自动用满核心,或者设成具体数字。其次是cache,开启后压缩结果会缓存到文件系统,二次构建直接复用。最后是terserOptions,生产环境一般要保留compress.drop_console来去掉 console。
const TerserPlugin = require('terser-webpack-plugin'); optimization: { minimizer: [ new TerserPlugin({ parallel: true, cache: true, terserOptions: { compress: { drop_console: true, drop_debugger: true } } }) ] }注意:
cache: true在 webpack 4 里依赖cacheDirectory,默认走系统临时目录。CI 环境如果每次都是干净容器,缓存命中率为零,反而多一次写盘开销,这种情况建议关掉 cache。
4. 完整实操过程与关键环节实现
4.1 安装依赖与版本确认
第一步永远是锁版本。webpack 4 生态对版本很敏感,thread-loader和terser-webpack-plugin都要选兼容 webpack 4 的版本。
npm install --save-dev thread-loader@2.1.3 terser-webpack-plugin@1.4.5为什么锁这么具体?因为thread-loader3.x 开始要求 webpack 5,terser-webpack-plugin2.x 之后也转向 webpack 5。装错版本会直接报Cannot read property 'hooks' of undefined这类错误。我建议在package.json里用精确版本号,不要用^。
4.2 改造 webpack 配置的 loader 部分
假设原来的 babel-loader 配置是这样:
module: { rules: [ { test: /\.js$/, exclude: /node_modules/, use: ['babel-loader'] } ] }改造后变成:
const os = require('os'); const workerCount = Math.max(os.cpus().length - 1, 2); module: { rules: [ { test: /\.js$/, exclude: /node_modules/, use: [ { loader: 'thread-loader', options: { workers: workerCount, warmup: 3, poolTimeout: 2000 } }, 'babel-loader' ] } ] }这里有个顺序细节:thread-loader 必须放在 babel-loader 前面。webpack 的 loader 执行顺序是从右到左、从下到上,所以数组里靠前的先执行。thread-loader 的作用是把后面的 loader 丢进 worker,放错位置就完全失效。
4.3 配置并行压缩
在optimization里显式声明 minimizer:
const TerserPlugin = require('terser-webpack-plugin'); optimization: { minimize: true, minimizer: [ new TerserPlugin({ parallel: true, cache: true, sourceMap: false, terserOptions: { compress: { drop_console: true, drop_debugger: true, pure_funcs: ['console.log'] }, output: { comments: false } } }) ] }sourceMap: false是我特意关的。生产环境如果不需要 sourcemap,关掉能省下大量生成时间。如果确实需要,建议用sourceMap: true但配合devtool: 'source-map',并且接受它带来的额外耗时。
4.4 实测数据对比
改造前后我在同一台 8 核 16G 的机器上跑了五轮取平均,数据如下:
| 场景 | 改造前 | 改造后 | 提升 |
|---|---|---|---|
| 冷启动全量构建 | 218s | 96s | 56% |
| 增量构建(改一个文件) | 14s | 6s | 57% |
| 生产压缩阶段 | 62s | 21s | 66% |
| 内存峰值 | 1.8G | 2.6G | 上升 44% |
提升很明显,但内存峰值上升也要正视。多进程本质是用内存换时间,每个 worker 都是独立的 V8 实例,各自占一份内存。如果构建机器内存紧张,worker 数量要相应调低。
4.5 在 CI 环境里的适配
CI 机器通常核数少、内存小,直接套用本地配置会翻车。我的做法是用环境变量区分:
const isCI = process.env.CI === 'true'; const workerCount = isCI ? 2 : Math.max(os.cpus().length - 1, 2); const terserParallel = isCI ? 2 : true;CI 上把 worker 压到 2 个,压缩并行度也降到 2,避免内存溢出被杀进程。同时 CI 上关掉cache,因为容器每次都是干净的,缓存没意义。这套配置在我们流水线上跑了半年多,没再出现过构建中途 OOM 的情况。
5. 常见问题与排查技巧实录
5.1 构建变慢而不是变快
这是最常见的问题,通常有三个原因。第一,项目模块太少,多进程启动开销大于收益。判断标准是模块数少于 100 个时,建议直接关掉 thread-loader。第二,worker 数量设得比核心数还多,导致进程频繁切换。第三,把不该进池的 loader 放进去了,比如 file-loader,worker 里写文件失败后不断重试。
排查方法很简单:把 thread-loader 注释掉再跑一次,对比时间。如果注释后更快,说明当前项目不适合多进程,或者配置有问题。
5.2 报错 “Cannot find module ‘babel-loader’”
这个错误几乎都是 worker 找不到依赖导致的。thread-loader 的 worker 是独立进程,它的模块解析路径和主进程不完全一致。解决办法是在thread-loader的 options 里加workerParallelJobs和确保 babel 相关依赖装在项目根目录,而不是全局或 monorepo 的某个子包。
如果是 monorepo,还要注意resolve.modules的配置,worker 默认只从项目根找 node_modules。我遇到过一次,最后是在根目录补了一个软链接才解决。
5.3 压缩阶段内存暴涨
parallel: true会让每个 worker 都加载一份 terser,内存占用是单进程的好几倍。如果 chunk 特别大(比如单个 chunk 超过 5MB),并行压缩反而会因为内存压力变慢。这时候可以设parallel: 2限制并行度,或者用splitChunks先把大 chunk 拆小。
我处理过一个项目,单个 vendor chunk 有 8MB,并行压缩时内存直接飙到 4G。后来加了splitChunks把第三方库按包名拆开,每个 chunk 控制在 1MB 以内,并行压缩才真正发挥出效果。
5.4 热更新失效或变慢
watch 模式下 thread-loader 的 worker 会常驻,如果poolTimeout设得太短,每次热更新都要重启 worker,反而更慢。我建议 watch 模式下把poolTimeout设到 3000 以上,甚至设成Infinity让 worker 一直存活。但要注意,worker 常驻会占内存,开发机内存小于 8G 的话要谨慎。
另外,如果热更新后页面报错说模块找不到,多半是 worker 缓存了旧模块。可以在poolTimeout之外加workerParallelJobs: 20,控制单个 worker 同时处理的任务数,减少缓存冲突。
5.5 常见问题速查表
| 现象 | 可能原因 | 解决方向 |
|---|---|---|
| 构建变慢 | 模块太少 / worker 过多 | 减少 worker 或关闭多进程 |
| 找不到模块 | worker 解析路径问题 | 检查依赖安装位置与 resolve 配置 |
| 内存暴涨 | 并行度过高 / chunk 过大 | 降 parallel 或拆分 chunk |
| 热更新失效 | poolTimeout 过短 | 调大 poolTimeout |
| 压缩报错 | terser 版本不兼容 | 锁定 1.4.x 版本 |
| 类型检查丢失 | ts-loader 开了 happyPackMode | 补 fork-ts-checker 插件 |
5.6 几个我踩过的坑
第一个坑是在 thread-loader 后面放了 eslint-loader。eslint 需要读取配置文件,worker 里路径解析出错,导致 lint 结果为空但构建不报错,问题藏了很久才发现。后来把 eslint 单独提到主进程执行。
第二个坑是cache 和 parallel 同时开导致缓存错乱。terser 的 cache 在多进程下偶尔会写入不完整的缓存文件,二次构建时读到坏缓存直接报错。解决办法是 cache 和 parallel 二选一,或者升级到修复了该问题的版本。
第三个坑是worker 数量用了 os.cpus().length 而不是减一。主进程被饿死后,构建时间不降反升,而且日志输出会卡顿。这个细节文档里写了,但很容易被忽略。
6. 多进程方案的边界与后续扩展
这套方案不是银弹,它有明确的适用边界。项目模块数在 200 以上、构建时间超过 60 秒、机器有 4 核以上,这三个条件同时满足时收益最明显。反过来,小项目、低配机器、大量自定义 loader 的项目,强行上多进程可能得不偿失。
如果后续想继续压榨构建速度,可以在多进程的基础上叠加DLL 预编译把不常变的第三方库提前打包,或者用hard-source-webpack-plugin做模块级缓存。这两个方向和多进程不冲突,可以组合使用。我在一个项目里把三者叠加,冷启动从 218 秒压到了 52 秒,代价是配置复杂度上升不少,需要团队里有人能维护。
最后分享一个判断是否值得继续优化的经验:如果构建时间已经低于 30 秒,再投入精力优化的边际收益就很低了,不如把时间花在代码分割和按需加载上,那才是真正影响用户体验的地方。构建速度优化到“不打断开发节奏”就够了,没必要追求极致。