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, {
