1. 从一次构建卡顿说起:为什么我会盯上 Worker Threads
事情要从一个再普通不过的下午说起。我负责的一个 Vue 3 企业级后台项目,不知不觉已经长到了 200 多个页面模块,路由懒加载、按需引入、组件库全量按需都做了,但每次跑 vite build,内存占用还是能轻松顶到 3GB 以上,构建耗时稳定在 6 分钟左右。更难受的是,这 6 分钟里 Node 进程单线程跑,CPU 多核利用率惨不忍睹——我看了一眼任务管理器,16 核的机器,Vite 构建时 CPU 总占用率只有 20% 左右。这不是 Vite 不行,是 Vite 的构建链路里,有些重活天生吃单线程,而 Vite 默认不会把这些活拆给 Worker Threads。
后来我翻了不少资料,也看了 Vite 的源码,确认了一件事:Vite 本身在开发服务器的依赖预构建阶段已经用了 Worker Threads(esbuild 和 terser 的并行压缩也有相关支持),但在生产构建的很多自定义插件场景下,如果你自己写了耗时的转换逻辑,比如大文件解析、复杂的 AST 处理、自定义代码生成,这些活儿默认全挤在主线程上。项目一大,主线程就成了瓶颈。
我当时的优化目标很简单:在不改动业务代码、不更换构建工具的前提下,把生产构建时间砍掉一半以上,同时控制内存峰值不爆掉。最后我选择了一条相对可控的路:用 Node.js 自带的 Worker Threads,把构建流程里最耗时、最可并行的几个环节拆出去。这篇文章不会跟你讲太多 Vite 内部源码的细枝末节,而是完整记录我踩坑、设计、落地、验证的全过程。里面有不少细节,网上资料很少提到,希望对你正在优化的大项目有实际帮助。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 定位构建耗时瓶颈:别凭感觉,用数据说话
2.1 先搞清楚 Vite 构建时到底慢在哪
很多人一上来就想着怎么加 Worker Threads,结果加完之后发现构建时间没降多少,反而因为线程通信用了一堆额外开销。我得提醒一句:Worker Threads 不是银弹,它只对 CPU 密集型任务有明显收益。IO 密集、网络密集、或者本身就很快的小任务,丢进 Worker 反而更慢。
所以第一步一定是定位瓶颈。我用的方法很简单——拆解 Vite 构建阶段:
vite build整体流程大致是:加载配置、解析入口、依赖预构建(esbuild)、Rollup 打包、产物压缩(esbuild/terser)、生成文件。- 我通过
vite build --debug以及手动在插件钩子(transform、renderChunk、generateBundle)里打点计时,把每个阶段耗费的时间打印出来。
实测数据(项目规模:约 210 个页面,1500 多个组件,第三方依赖约 300 个 npm 包):
| 阶段 | 耗时(秒) | 说明 |
|---|---|---|
| 依赖预构建(esbuild) | 8.4 | 开发时已做过大部分,生产构建仍会处理 |
| Rollup 模块解析与加载 | 42.6 | 项目越大,这块越慢 |
| 插件 transform 钩子总耗时 | 96.8 | 我自己的几个自定义插件占了大头 |
| renderChunk(代码生成后处理) | 24.3 | 自定义 banner、代码降级、格式化 |
| 压缩(terser) | 180.5 | 最痛的环节,单线程跑完整包 |
| write 文件输出 | 6.2 | 可忽略 |
注意,上面这些阶段不是纯串行,有些是交错的。但有一点很明显:压缩耗时占比最大,其次是我自己的 transform 钩子。也就是说,要优化,两个方向:
- 把 terser 压缩换成 esbuild 压缩,或者用 Vite 自带的
minify: 'esbuild',这个能立刻省掉一大块时间。 - 把自定义插件里耗时的 transform 逻辑并行化。
2.2 为什么 terser 那么慢,以及能不能换
Vite 默认生产构建用 esbuild 进行压缩,但很多老项目因为需要兼容低版本浏览器、保留注释、特定格式要求,会手动把 build.minify 改成 'terser',或者干脆在 renderChunk 里过一遍 terser。terser 是纯 JavaScript 实现的压缩器,性能比 esbuild 差一个数量级,而且默认单线程。
我当时遇到的情况是:项目需要兼容到 Chrome 60 左右的 WebView,部分代码依赖 terser 的特定压缩行为(比如 compress 选项里的 drop_console 和 pure_funcs),所以不能盲目换成 esbuild。但 terser 本身是支持多线程的——terser 包的 API 里有一个 maxWorkers 选项,可以配合 Worker Threads 并行压缩多个文件。只是 Vite 内部没有暴露这个配置,需要自己写插件。
于是我的优化策略非常清晰:
- 压缩阶段:写一个自定义
renderChunk插件,收集所有需要压缩的 chunk,然后用 Worker Threads 并行调用 terser。 - 自定义 transform 阶段:把一个耗时的代码检查/转换插件拆分成 Worker 任务池执行。
2.3 一个反直觉的发现:并行不是越多越好
在这之前我犯过一个错误:以为 Worker 开得越多,速度越快。实际上我用 16 核机器测试,开 16 个 Worker 跑 terser,结果比开 8 个还慢了 10% 左右。原因有三:
- Worker 创建和销毁本身有开销,每个 Worker 有自己的 V8 实例,内存占用高,线程切换频繁。
- terser 压缩时如果多个 Worker 同时处理同一份源码的依赖图,会有重复解析。
- 机器 CPU 并不是只有 Vite 在跑,还有 IDE、浏览器、其他服务。
后来我通过试错总结出一个经验公式:Worker 数量 = Math.max(2, Math.min(cpus().length - 2, 8))。也就是说,一般最多 8 个 Worker,留出 2 个核心给系统和其他进程。这个配置在大多数机器上都是接近最优的。
3. 给 Vite 的 transform 阶段插上并行的翅膀
3.1 第一步:把慢插件单独摘出来
先看我最开始写的一个自定义插件,作用是给每个组件的源码里注入一段统计代码。代码很简单,但用正则+AST 做了一些匹配,项目大起来之后,单次 transform 要 50ms,在几千个模块上累积起来就非常可观。
优化前代码大概是这样的:
javascript复制// vite-plugin-inject-stat.js
export function injectStatPlugin() {
return {
name: 'inject-stat',
transform(code, id) {
if (!id.endsWith('.vue') && !id.endsWith('.js')) return null;
// 模拟耗时操作:AST 解析 + 节点遍历 + 注入代码
const ast = parse(code);
traverse(ast, {
FunctionDeclaration(path) {
// 做一些无意义的耗时操作,真实场景里可能是复杂的依赖分析
for (let i = 0; i < 10000; i++) { /* 模拟计算 */ }
}
});
return generate(ast);
}
};
}
这个插件的问题在于:Vite 的 transform 钩子默认按模块逐个调用,所有模块都挤在主线程上执行。因为模块之间没有严格的顺序依赖,非常适合并行化。
3.2 第二步:设计 Worker 池,而不是每次 new 一个 Worker
一开始我偷懒,在 transform 里直接 new Worker 去处理每个模块。结果发现比原来的单线程还慢。为什么?因为创建一个 Worker 的开销是几十毫秒级别,而单个模块的 transform 耗时也就 20ms,一进一出,开销比收益还要大。
正确做法是维护一个常驻 Worker 池。Worker 启动后不销毁,等待任务队列派发。我用的是 Node.js 自带的 worker_threads,没有安装额外的进程池库(比如 workerpool 或 piscina)。原因很简单:项目中已经引用的 Node 版本是 16+,原生 Worker 已经够用,而且少一个依赖就少一个维护成本。
Worker 池的架构如下:
javascript复制// worker-pool.js
import { Worker } from 'worker_threads';
import { cpus } from 'os';
class WorkerPool {
constructor(workerPath, numThreads = Math.max(2, Math.min(cpus().length - 2, 8))) {
this.workerPath = workerPath;
this.numThreads = numThreads;
this.workers = [];
this.idleWorkers = [];
this.taskQueue = [];
this.taskId = 0;
this.pending = new Map();
this._init();
}
_init() {
for (let i = 0; i < this.numThreads; i++) {
const worker = new Worker(this.workerPath);
worker.on('message', (msg) => {
const { taskId, result, error } = msg;
const task = this.pending.get(taskId);
if (task) {
this.pending.delete(taskId);
this.idleWorkers.push(worker);
if (error) task.reject(new Error(error));
else task.resolve(result);
this._processQueue();
}
});
worker.on('error', (err) => {
// 如果 worker 崩溃,直接移除并从队列里重新处理任务(简单处理为 reject)
const index = this.workers.indexOf(worker);
if (index > -1) this.workers.splice(index, 1);
});
this.workers.push(worker);
this.idleWorkers.push(worker);
}
}
runTask(data) {
return new Promise((resolve, reject) => {
const taskId = ++this.taskId;
this.pending.set(taskId, { resolve, reject });
this.taskQueue.push({ taskId, data });
this._processQueue();
});
}
_processQueue() {
while (this.idleWorkers.length > 0 && this.taskQueue.length > 0) {
const worker = this.idleWorkers.shift();
const task = this.taskQueue.shift();
worker.postMessage({ taskId: task.taskId, data: task.data });
}
}
}
export const pool = new WorkerPool(new URL('./transform-worker.js', import.meta.url).pathname);
这个池子的核心逻辑就三点:
- 预创建固定数量的 Worker,空闲 Worker 放到
idleWorkers数组。 - 任务进来时,有空闲 Worker 就立刻派发,否则排队。
- Worker 返回消息后,从
pending里取出对应的 Promise,把结果 resolve 掉,再把 Worker 放回空闲池。
3.3 第三步:Worker 内部只做纯计算,不做 IO 和访问外部变量
Worker 文件长这样:
javascript复制// transform-worker.js
import { parentPort } from 'worker_threads';
import { parse, traverse, generate } from './ast-utils';
parentPort.on('message', async (msg) => {
const { taskId, data } = msg;
const { code, id } = data;
try {
// 纯 CPU 计算,不要在这里读取文件、请求网络
const ast = parse(code);
traverse(ast, { /* 业务逻辑 */ });
const output = generate(ast);
parentPort.postMessage({ taskId, result: output });
} catch (e) {
parentPort.postMessage({ taskId, error: e.message });
}
});
这里有个非常关键的注意点:Worker 里不能访问 Vite 插件的 this 上下文,也不能调用任何依赖 Node 全局状态的方法。否则你会遇到各种奇怪的问题。比如有些插件里用了 this.getModuleInfo(),这个在 Worker 里是没有的。我的解决办法是:把这些需要上下文的方法提前在插件主线程里查好,然后作为任务数据传给 Worker。如果确实需要动态获取模块信息,就在主线程里先做一次预扫描,生成一个映射表,再把这个映射表传给 Worker。
还有一点,transform 钩子里的 code 是模块的源码文本,可能非常大。传给 Worker 时需要序列化,这部分有开销。实测一个 100KB 的源码文件,序列化+反序列化大概 2ms,相比 transform 本身的 50ms,完全可以接受。
3.4 第四步:把插件里的同步 transform 改成异步
Vite 的 transform 钩子支持返回 Promise,所以并行化不需要改 Vite 配置,只需要把原来的同步逻辑改成 async:
javascript复制// vite-plugin-inject-stat.parallel.js
import { pool } from './worker-pool';
export function injectStatPlugin() {
return {
name: 'inject-stat-parallel',
async transform(code, id) {
if (!id.endsWith('.vue') && !id.endsWith('.js')) return null;
const result = await pool.runTask({ code, id });
return result;
}
};
}
就这么简单。实际跑下来,这个插件单次 transform 耗时从 50ms 降到了 8ms 左右(包含调度开销),整个插件总耗时从 96 秒降到了 18 秒,速度提升超过 5 倍。
这里要提醒一下:如果你使用了多个并行插件,最好共用一个 Worker 池,不要每个插件都 new 一个池子,否则内存会爆炸。我一开始是两个插件各建了 8 个 Worker,构建时内存直接超了 5GB,差点 OOM。
4. terser 压缩阶段的并行化实操
4.1 默认压缩流程分析:为什么它天生单线程
Vite 使用 Rollup 打包后,会把生成的 chunk 交给 renderChunk 钩子处理。renderChunk 里,如果我们设置了 build.minify: 'terser',Vite 内部会遍历所有 chunk,依次调用 terser 压缩。这个“依次”就是单线程的。
我的项目里有大约 300 多个 chunk(路由懒加载拆分的产物),每个 chunk 大小从几 KB 到几百 KB 不等。terser 压缩最大的 chunk 需要 3 秒,最小的也要 20ms。单线程跑完所有 chunk,总耗时 180 秒。
4.2 写一个并行压缩插件
我先关闭 Vite 自带的压缩,即 build.minify: false,然后在 renderChunk 钩子里自己压缩。具体流程:
- 收集所有需要压缩的 chunk 及代码。
- 把这些 chunk 分发给 Worker 池。
- 每个 Worker 内部调用 terser 压缩。
- 收集压缩后的结果,返回给 Rollup。
这里有一个坑:renderChunk 是逐个 chunk 调用的,如果我在每个 chunk 的钩子里直接 await 压缩,那还是串行的。所以必须把多个 chunk 的压缩任务先收集起来,然后用一个“聚合者”插件,在 generateBundle 之前统一做并行压缩。
我踩过的另一个坑是:renderChunk 钩子返回的代码必须同步或者异步返回当前 chunk 的结果。如果我为了并行把任务攒到后面,需要先返回原始代码,然后在 generateBundle 阶段再替换产物。但是 generateBundle 阶段替换 chunk 内容比较麻烦,而且会导致 sourcemap 失效。
最终我采用的是自定义一个阶段更早的插件,在 renderChunk 之前,通过 this.emitFile 或者修改 Rollup 的 output 流程来捕获所有 chunk。但这样做太复杂了,后来我换了个思路——直接改写 renderChunk 钩子,利用一个批次计数器:
javascript复制// vite-plugin-parallel-terser.js
import { pool } from './worker-pool';
let pendingChunks = [];
let resolveBatch = null;
let batchPromise = null;
export function parallelTerserPlugin() {
return {
name: 'parallel-terser',
async renderChunk(code, chunk) {
// 先把当前 chunk 加入批处理列表
pendingChunks.push({ code, chunk });
if (!batchPromise) {
batchPromise = new Promise((resolve) => { resolveBatch = resolve; });
// 用 setTimeout 0 把本轮的多个 renderChunk 调用合并到一个批次
setTimeout(() => {
const tasks = pendingChunks;
pendingChunks = [];
const p = batchPromise;
batchPromise = null;
// 并行压缩
Promise.all(tasks.map(task => pool.runTask({
code: task.code,
fileName: task.chunk.fileName,
type: 'terser',
terserOptions: { /* 项目需要的配置 */ }
}))).then(results => {
results.forEach((result, i) => {
tasks[i].chunk.code = result.code;
if (result.map) tasks[i].chunk.map = result.map;
});
resolveBatch();
});
}, 0);
}
await batchPromise;
// 返回未压缩代码,因为真正的压缩结果已经在上面通过修改 chunk.code 生效了
return null;
}
};
}
这里 setTimeout 0 的妙处在于:Rollup 在一个事件循环 tick 内会连续调用多个 chunk 的 renderChunk,我们把这些调用全部合并到一个批次,然后用 Worker 池并行处理,处理完成后再一起返回。因为每个 chunk 的 renderChunk 都在等待同一个 batchPromise,所以最终所有 chunk 都会等待压缩完成后才进入下一个阶段。
当然,上面的代码假设 pool.runTask 返回的是 terser 压缩后的代码。Worker 内部的实现如下:
javascript复制// terser-worker.js
import { parentPort } from 'worker_threads';
import { minify } from 'terser';
parentPort.on('message', async (msg) => {
const { taskId, data } = msg;
const { code, terserOptions } = data;
try {
const result = await minify(code, terserOptions);
parentPort.postMessage({ taskId, result: { code: result.code, map: result.map } });
} catch (e) {
parentPort.postMessage({ taskId, error: e.message });
}
});
4.3 压缩并行化的实测数据
改完之后,我测试了三种模式:
| 模式 | 压缩阶段耗时 | 总构建耗时 | 内存峰值 |
|---|---|---|---|
| 默认 terser(Vite 内置) | 180 秒 | 360 秒 | 3.2GB |
| Vite minify 设为 esbuild | 42 秒 | 220 秒 | 2.4GB |
| 自定义并行 terser(8 Worker) | 31 秒 | 210 秒 | 3.8GB |
可以看到,并行 terser 比默认 terser 快了接近 6 倍。不过相比 esbuild 的压缩,并行 terser 还是慢一些,而且内存更高。如果你的项目没有兼容性硬需求,建议直接用 minify: 'esbuild',这是最省事的方案。如果确实需要 terser,我的并行方案能让你在保留 terser 行为的同时,把耗时拉近到 esbuild 的水平。
4.4 一个必须注意的陷阱:sourcemap 的合并
开启 sourcemap 时,并行压缩的 sourcemap 合并是个很麻烦的事。terser 的 minify 会返回新的 map,但你直接把它赋值给 chunk.map,Rollup 可能不认。因为我当时不需要 sourcemap,所以没深入处理。但如果你要开 sourcemap,建议自己验证一下生成的 map 是否正确,必要时需要手动合并 map 的 sources 和 mappings。
5. 开发服务器的热更新优化:Worker Threads 也能派上用场
5.1 Vite 热更新为什么在大型项目里会越来越慢
大部分人的困惑在于:生产构建优化完了,开发环境启动是快了,但改一个文件,页面要等好几秒才更新。原因主要出在:修改文件后,Vite 需要重新转换这个模块以及所有依赖它的模块(我们常说的“热更新链”)。如果项目里模块依赖关系复杂,这个递归转换过程就会特别耗时。
另外,Vite 开发服务器的依赖预构建(optimizeDeps)在项目首次启动时,需要对 node_modules 里的依赖做 esbuild 打包。虽然 esbuild 已经很快了,但当你引用了大量第三方库时,这个阶段依然可能要 10 秒以上。这里 Vite 其实内部已经用到了 Worker Threads(通过 esbuild 的 build API 的 watch 模式,不过 esbuild 本身是多进程的),所以我们能操作的空间主要在于自定义插件的 transform 钩子。
5.2 把耗时的 transform 也搬到 Worker 池,但要注意缓存
我在开发环境试过直接复用上面写的 Worker 池。结论是:有效,但必须配合缓存。因为热更新时,同一个模块可能被反复 transform(比如你改了一个组件,它的父组件、子组件、路由懒加载 chunk 都会重新 transform)。
我的做法是加了一层 Map 缓存,key 是 模块路径 + 文件修改时间 + 文件大小,value 是 transform 结果。具体代码不贴了,思路很简单:在插件主线程里维护这个 Map,任务派发前先查缓存,命中就直接返回,不派发给 Worker。这样热更新时,只有真正修改的模块才会重新走 Worker,其他依赖模块直接走缓存,速度提升非常明显。
5.3 实测:开发体验的改善数据
在我的项目里,改一个基础组件(比如一个 Button),之前热更新需要 3.8 秒左右,加了并行 transform + 缓存后,热更新稳定在 1.2 秒以内。而且这个过程不影响 Vite 自身的 HMR 边界更新,只会让 transform 阶段更快。
如果你也在做类似优化,建议先用 vite --debug 查看热更新日志里每个模块的 transform 耗时,找出你的 top 10 慢模块,看看它们是否在钩子里做了重复计算。大部分情况下,把重复的 AST 解析结果缓存起来比并行化更有效。
6. 内存优化与长期维护经验:别把构建工具搞成定时炸弹
6.1 内存峰值控制的几个土办法
并行 Worker 最让人担心的就是内存。每个 Worker 都是一个独立的 V8 堆,如果项目本身很大,源文件字符串、AST、代码压缩中间产物都会占用大量内存。我在优化过程中遇到过几次 OOM,总结下来控制内存的方式有这些:
- 控制 Worker 数量:不要超过 CPU 核心数的一半。我实测 16 核机器上开 8 个 Worker 是最优平衡点,再往上内存飙升,速度收益却不明显。
- 及时回收大对象:在处理完一个批次任务后,手动将大字符串变量置 null,帮助 V8 垃圾回收。虽然现代 V8 很智能,但在高频场景下还是能看到效果。
- 限制并发批次:如果你的任务队列太大,不要一次性全派发。比如压缩 300 个 chunk,可以分成每批 50 个,处理完再派下一批。我的 WorkerPool 实现里可以加一个
maxConcurrentTasks参数,控制同时进行的任务总量。 - 给 Worker 设置独立内存限制:使用
resourceLimits选项可以在创建 Worker 时指定最大堆内存大小:
javascript复制new Worker(workerPath, {
resourceLimits: {
maxOldGenerationSizeMb: 1024,
maxYoungGenerationSizeMb: 256,
}
});
这样即使某个 Worker 里发生了内存泄漏,也不会瞬间拖垮整个构建进程。
6.2 如何避免 Worker 里的代码与主线程不同步
Worker Threads 最让人头疼的维护问题,就是 Worker 里的业务逻辑和主线程版本不一致。因为你把 transform 逻辑搬到了独立文件里,很可能改主线程代码时忘了同步改 Worker 里的副本。
我的做法是把业务逻辑抽成纯函数模块,主线程和 Worker 共用同一个模块。也就是:
javascript复制// shared-transform.js
export function processCode(code, id, options) {
// 这里是核心计算逻辑,不依赖任何外部环境
return result;
}
然后在主线程插件的 transform 钩子里这样写:
javascript复制import { processCode } from './shared-transform';
// 兜底:如果 Worker 不可用,直接在主线程调用 processCode
在 Worker 里:
javascript复制import { processCode } from './shared-transform';
这样两边调用同一个函数,逻辑永远一致。如果某个函数依赖 this 上下文,就在主线程提前把它转换成参数传入。这套设计试运行到现在,几乎没有出现过逻辑不同步的问题。
6.3 环境变量与 Node 版本兼容性
worker_threads 在 Node 12.17.0 之后就已经稳定了,Vite 官方要求 Node 14.18+ / 16+,所以只要是能跑 Vite 的环境,基本不用担心 Worker 兼容性。但是要小心:
- Vite 的配置文件如果用了
import { Worker } from 'worker_threads',在vite.config.ts里需要确保是 ESM 或 CJS 正确解析。如果用 TS 配置,需要@types/node才能有类型提示。 - 如果你要打包 Vite 插件发布给其他人用,建议不要依赖
import.meta.url来定位 Worker 路径,而是使用path.resolve(__dirname, './worker.js'),避免 ESM 和 CJS 混用时的路径解析问题。
我在最开始用 new URL('./worker.js', import.meta.url) 在 Windows 上跑,路径处理有坑,后来统一改成 path.resolve(__dirname, './worker.js') 后,跨平台就正常了。
6.4 与 Vite 插件生态共存:如何避免与其他插件冲突
自定义插件并行化之后,最大的风险是打断其他插件的顺序。Vite 插件是有顺序的:pre 插件先执行,normal 插件默认,post 插件最后执行。如果你的并行插件是 normal,可能会和业务方其他插件产生竞态。
我的建议是:把你的并行插件放到 post 阶段执行,或者在插件对象里明确设置 enforce: 'post'。这样能最大程度减少对上游插件输出的影响。
另外,如果同一份代码被多个插件 transform,你的 Worker 池处理时,接到的是前面所有插件处理完的最终代码。因此,不要在 Worker 里假设输入代码是原始源码。这个隐蔽的 Bug 我排查了很久才定位到——一开始我在 Worker 里做的是“根据源码路径判断是否注入”,但经过别的插件处理后的代码可能已经变了。
7. 最终效果与后续扩展方向
7.1 整个优化方案的总收益
把上面的优化全部应用后,我的项目从最初的生产构建 360 秒,降到了 210 秒左右(并行 terser + 并行自定义 transform)。如果直接把压缩换成 esbuild,还能进一步降到 180 秒以内。内存峰值稳定在 3.8GB 以内,没有 OOM。
开发服务器的热更新从 3.8 秒降到了 1.2 秒,项目启动时间(冷启动)也因依赖预构建的优化策略,从 15 秒降到了 9 秒。这些数据在团队内部推广后,大家明显感觉到 CI 构建速度快了很多,开发时也不需要频繁等待热更新。
7.2 还可以怎么继续深挖
Worker Threads 只是并行化的一个方向。后续还有几个思路,我在评估中:
- 使用
piscina这样的成熟 Worker 池库,它提供了更完善的调度策略、优先级、任务取消、返回结果缓存,比自己写的池子更健壮。 - 用
jest-worker替代原生 Worker,这个库是 Facebook 的 Jest 团队维护的,在 Node 生态里用得很广,API 更友好。 - 如果业务中还有代码检查、类型生成、图标集生成等独立任务,也可以全部扔给 Worker 池做。只要任务之间没有依赖,就值得并行化。
- 结合
vite-plugin-checker的并行类型检查,把vue-tsc或tsc放到单独的 Worker 进程里,避免阻塞主线程。
不过我得泼一盆冷水:并行化不是万能的。如果项目的瓶颈是 IO(比如大量小文件读取、网络请求),Worker Threads 帮不了你,你需要考虑的是缓存、预构建或者换用更快的文件系统。如果瓶颈是 Rollup 本身的模块解析,你可能需要看看是否能减少模块总量、使用 @rollup/plugin-node-resolve 的 exportConditions 精简解析路径。
7.3 维护这套方案半年后的个人体会
说实话,刚开始搞这套方案时,我每天都在跟各种奇怪的问题搏斗:Worker 里的模块找不到、路径解析错乱、内存上涨、任务顺序错乱……但稳定运行半年之后,我现在觉得非常值。尤其是当团队从十几人扩大到几十人,项目体量还在涨,构建速度却始终维持在可接受的范围内,这是最让人欣慰的。
如果你也要做类似优化,我给你三个总结性的建议:
- 先用性能分析工具找出真正的热点,不要一开始就上并行化。我用的是
0x和node --cpu-prof生成 CPU Profile,可以看到每个函数的具体耗时,很多问题比你想的更简单(比如一个正则没有缓存)。 - Worker 池的代码要尽量精简,只做纯计算。所有与 Vite 上下文相关的操作等处理完再在主线程里补,这样你的 Worker 测试起来也容易(直接可以在 Node 下跑纯函数)。
- 记得在 CI 环境里验证稳定性。本地 16 核机器和 CI 的 2 核机器行为完全不同,Worker 数量必须根据运行时 CPU 核心数动态设置,不要硬编码。
最后再分享一个小技巧:我在 package.json 的 build 脚本里,加了一个环境变量控制并行度(BUILD_WORKERS=4 npm run build),这样在 CI 需要限制资源时可以随时调整,不用改代码。这个小设计让运维和 CI 配置都省了不少心。
