☰
Vite构建提速实战:用Worker Threads实现多线程并行压缩
2026/10/16 16:59:25 网站建设 项目流程

从Vite 2开始,我就把它当成主力构建工具,前后折腾了几个中大型项目,单独看构建速度还算能忍,但一旦业务模块到了几十个、路由文件成百上千、组件库按需引入之后,vite build的时间就肉眼可见地往上涨。热更新再快也架不住最后打包那几分钟的煎熬,尤其是在发布前频繁改bug、反复验证构建结果的时候,这几分钟会被无限放大。后来我把构建过程跑了一遍 CPU 监控,发现一个很尴尬的事实:电脑是 8 核 16 线程,但 Vite 的很多构建环节只吃了一两个核。多核潜能被白白浪费,这才是慢的真正原因。

搞懂了这个瓶颈之后,我决定把Worker Threads引入到 Vite 的构建链路里,专门处理那些可并行的 CPU 密集任务。这篇文章就记录一下我在这套方案里的整体设计、代码封装、坑点排查和最终效果,给同样被构建速度卡住的朋友一个可以直接参考的实战样本。内容主要面向有一定 Vite 使用经验、想进一步压榨构建性能的前端工程师,也适合对 Node.js 多线程编程感兴趣、想把 Worker Threads 落地到真实场景的人。

1. 为什么 Vite 构建会卡在单核上

1.1 构建链路里的串行瓶颈到底在哪

很多人对 Vite 的认知还停留在"开发时快、打包时慢"这个层面上,但很少人去细究打包时到底慢在哪些环节。我实际用--debug模式和vite build --profile跑过几次性能分析,也用过0x这类 CPU profiler 去采样,发现时间主要消耗在下面这几块。

首先是依赖预构建。开发环境下 Vite 用 esbuild 做预打包,把 node_modules 里的 ESM 依赖统一转成 optimize 后的格式,速度不算慢。生产构建时这些依赖会被 Rollup 重新处理一遍,涉及大量的模块解析、transform 和 tree-shaking 判断,这部分虽然是 Rollup 内部的逻辑,但它在单线程里跑,遇到那种"一个入口依赖几百个模块"的库时,耗时是线性上升的。

其次是代码压缩。Vite 默认生产构建会用 esbuild 来 minify JS,这玩意儿已经很快了,但如果你为了兼容性切到terserOptions,或者用了需要完整 AST 处理的插件,比如@vue/compiler-sfc的某些定制逻辑,压缩过程就会变成纯 CPU 密集型任务,一个 chunk 几 MB 的代码,压缩下来要好几秒。

再者是组件级代码转换。像 Vue 的 SFC 编译、JSX 转换、TS 类型擦除,这些 transform 操作在 Vite 里默认走的是Promise.all并行,但它并行的是 IO 和微任务,真正的 CPU 计算还是在主线程内排队。换句话说,一个项目里几千个.vue文件,每个文件的编译过程本身没有办法天然地分散到多个物理核心上。

最后还有一类很容易忽略的任务,就是构建结束后的一些自定义逻辑。比如自动化生成路由表、扫描目录生成导航配置、根据模块关系生成 sitemap、批量压缩静态资源。这些逻辑通常是写在一个自定义 Vite 插件里的,很多人直接写在closeBundle钩子里,用一个for循环把几百个文件处理一遍,如果你在里面做了字符串模板拼接、正则匹配、文件内容分析,那它就是纯纯的串行 CPU 任务。

1.2 单线程模型与多核 CPU 之间的错配

Node.js 主线程是单线程事件循环,这个特性决定了它在处理 IO 密集型任务时非常高效,因为 IO 等待期间可以继续处理其他事件。但 CPU 密集型任务不一样,它需要独占一段连续的计算时间,主线程一旦进入密集计算,事件循环就被卡住,其他所有任务都得排队。

Rollup 的打包流程本质上就是"读取模块 -> 解析 -> transform -> 生成 chunk"这样一个有依赖关系的流水线。模块之间存在引用关系,所以整个模块图不可能完全独立。但如果你把任务拆细一点,你会发现很多环节是独立的,比如不同 chunk 的 minify、不同目录的静态资源压缩、不同路由模块的代码生成,这些任务之间没有任何依赖关系,完全可以并行。

我做的第一版优化思路很简单:把构建流程里那些"没有相互依赖关系的 CPU 密集型工作"提取出来,交给多个 worker 进程去处理,主线程只负责把任务分发下去、收集结果。这样不仅解决了 CPU 利用率低的问题,还顺带解决了极端情况下主进程长时间无响应的问题。

这里我选择的是worker_threads而不是child_process,原因有两个。第一,worker_threads 和主线程共享同一个进程,通信开销更小,传递数据只需要序列化到 SharedArrayBuffer 或者用 structured clone 传输,不需要走完整的进程间通信。第二,worker_threads 更轻量,创建速度快,适合按任务量动态扩容。后面实际使用中,如果你需要处理的内存数据非常大,child_process也有它的优势,但对我们这种构建场景来说,还是 worker_threads 的手感更顺。

2. 多线程改造的适用边界与整体设计

2.1 不是所有构建环节都适合并行化

我见过有的人一上来就把整个 Vite 构建塞进 worker 里,结果跑出来的构建速度比原来还慢,进程还经常崩。原因很简单,Rollup 的打包流程本身是高度状态化的,模块图在内存中被反复修改、遍历,你把它拆到多线程里,光是同步这些状态的开销就比你省下的时间还多。

所以做多线程改造之前,先要判断一个任务是否适合并行化。我总结了三条经验。

一是任务之间必须无依赖或者弱依赖。比如你要压缩 5 个 JS chunk,它们之间没有任何引用关系,压缩完各自输出到对应路径,这就非常适合并行。再比如你要把 100 个.vue文件编译成 render 函数,每个文件的编译结果只跟它自身代码有关,这也适合并行。

二是任务粒度不能太小。如果一个任务本身只要跑 1 毫秒,你把它分发到 worker 的通信开销可能就要 2 毫秒,得不偿失。我习惯是把任务按"文件"或"chunk"来拆分,而不是按"函数调用"来拆。比如一次处理一个目录下的所有文件,而不是一次处理一个文件的某一行。

三是任务结果要容易合并。如果每个 worker 算完的结果都需要跟其他 worker 的结果做复杂的跨线程合并,那这个并行收益也会被吃掉。尽量设计成"各算各的,最后主线程简单汇总"的模式。

2.2 构建模型的转变:从串行流水线到任务分发池

传统的构建模型可以理解成一条流水线:依赖解析 -> transform -> chunk 生成 -> 压缩 -> 输出。每一步都必须等上一步完成,中间不能并行。这个模型的好处是简单可靠,坏处就是整条链路的耗时等于所有步骤串行之和。

我改造后的构建模型变成了这样:流水线的主干仍然是串行的,这没有办法,因为模块图的构建是强依赖的。但我在主干上的几个可并行节点位置插入了"任务分发池"。每个节点收到一批任务后,把任务拆成 N 份,分发给 N 个 worker,等所有 worker 汇报结果后,主干再继续往下走。

这个模型有点像一个流水线工厂,每个工位后面接了一个"外包团队"。工位本身还是按顺序走,但每个工位上的活可以拆给一群人同时干。整体耗时取决于最慢的那个工位,而不是所有工位的累加。

具体到 Vite 的生产构建,我在下面几个位置加了并行处理:

  • CSS 压缩:项目里上百个 CSS chunk,每个 chunk 的压缩互不影响,非常适合并行。
  • JS chunk 的 terser 压缩:把build.minify设为false,然后自己在插件里用 worker 池调用 terser 压缩每个 chunk。
  • 静态资源处理:图标、图片、JSON 文件的批量处理,比如生成 webp 格式、压缩 PNG、给 SVG 做 optimize。
  • 路由模块生成:扫描src/views目录,为每个目录生成对应的路由模块代码,这个任务天然按目录拆分。

2.3 多核数据一致性怎么保证

提到多线程,很多人第一反应是"变量共享会不会乱"、"要不要加锁"。在浏览器前端里,多线程数据一致性问题很常见,但在 Node.js 的 worker_threads 场景下,我们的通信模型默认是"拷贝传递",不是"共享内存"。每个 worker 拿到的数据是主线程数据的 structured clone,worker 内部怎么改都不会影响主线程。

但我在实践中有个例外——遇到了"构建结果收集时顺序错乱"的问题。第一个版本我是这样写的:主线程把任务发给 4 个 worker,每个 worker 处理完就回调,主线程拿到结果直接 push 到一个数组里。后来发现,任务 2 可能比任务 1 先完成,导致最终数组里的顺序和任务分配顺序不一致。文件写入本身没问题,但如果后续有依赖"处理顺序"的逻辑,比如生成一个汇总 JSON 按固定顺序列举文件名,那么这个错乱就会导致生成物不稳定。

解决办法还是靠任务 id 和结果缓冲。每个任务带一个唯一的taskId,worker 返回结果时把taskId带回来,主线程根据taskId把结果放到固定槽位,全部完成后按原顺序输出。这样既保证了并行执行,又保持结果顺序的一致性,实测下来非常重要。

再一个容易踩的坑是SharedArrayBuffer的过度使用。有人觉得传输大数据时用共享内存更快,于是在 worker 里直接写同一个 ArrayBuffer。这个方案要非常小心,因为一旦出现竞态,排查成本极高。我的建议是能用拷贝就用拷贝,真遇到超大数据的场景,优先考虑数据分片而不是共享写入,后面我会详细说这个问题。

3. Worker Threads 实战封装:从任务拆分到结果回收

3.1 封装一个轻量 Worker Pool

我们不做那种大而全的线程池框架,直接自己封装一个,核心就三个能力:动态创建 worker、任务分发、结果回收。先上代码。

// worker-pool.js const { Worker } = require('worker_threads'); const os = require('os'); const path = require('path'); class WorkerPool { constructor(workerScript, options = {}) { this.workerScript = workerScript; this.size = options.size || Math.max(2, os.cpus().length - 1); this.workers = []; this.idleWorkers = []; this.taskQueue = []; this.taskMap = new Map(); this.nextTaskId = 1; this.init(); } init() { for (let i = 0; i < this.size; i++) { const worker = new Worker(path.resolve(__dirname, this.workerScript)); worker.isIdle = true; worker.on('message', (msg) => this.handleMessage(worker, msg)); worker.on('error', (err) => this.handleError(worker, err)); worker.on('exit', () => { this.destroyWorker(worker); }); this.workers.push(worker); this.idleWorkers.push(worker); } } runTask(data) { return new Promise((resolve, reject) => { const taskId = this.nextTaskId++; this.taskMap.set(taskId, { resolve, reject, data }); this.enqueue(taskId); this.dispatch(); }); } enqueue(taskId) { const task = this.taskMap.get(taskId); if (task) { this.taskQueue.push(taskId); } } dispatch() { if (this.taskQueue.length === 0) return; if (this.idleWorkers.length === 0) return; const worker = this.idleWorkers.shift(); const taskId = this.taskQueue.shift(); const task = this.taskMap.get(taskId); if (!task) return; worker.isIdle = false; worker.postMessage({ taskId, data: task.data }); } handleMessage(worker, msg) { const { taskId, result, error } = msg; const task = this.taskMap.get(taskId); if (!task) return; this.taskMap.delete(taskId); worker.isIdle = true; this.idleWorkers.push(worker); if (error) { task.reject(new Error(error)); } else { task.resolve(result); } this.dispatch(); } handleError(worker, err) { console.error(`[WorkerPool] worker error:`, err); this.destroyWorker(worker); } destroyWorker(worker) { const index = this.workers.indexOf(worker); if (index !== -1) this.workers.splice(index, 1); const idleIndex = this.idleWorkers.indexOf(worker); if (idleIndex !== -1) this.idleWorkers.splice(idleIndex, 1); if (this.workers.length === 0) { this.init(); } } async close() { await Promise.all(this.workers.map((worker) => worker.terminate())); this.workers = []; this.idleWorkers = []; this.taskQueue = []; this.taskMap.clear(); } } module.exports = WorkerPool;

这个 pool 的实现细节我就不逐行解释了,说几个关键点。

os.cpus().length - 1是一个比较稳妥的默认 worker 数量,留一个核给主线程做任务调度和 IO,避免操作系统过度调度导致整体变慢。你也可以根据任务类型手动指定,比如纯 CPU 压缩任务,我建议 worker 数不要超过物理核心数,设置为os.cpus().length / 2在某些超线程环境下反而更稳定。

任务队列我用的是最简单的先进先出策略,实际场景里基本上够用了。如果你希望某些大任务优先处理,可以把taskQueue换成优先队列,但这不是必须的。

worker.on('exit')里的destroyWorker逻辑很重要。worker 崩溃后,如果池子里没有可用 worker,需要自动补一个,否则后续任务会全部卡死。我在destroyWorker里做了一个简单判断,如果 worker 数组空了就重新 init,保证池子的自愈能力。

3.2 编写一个可复用的 Worker 执行体

光有池子还不够,还需要 worker 那边的执行脚本。我的 worker 脚本设计成一个"注册表"模式,主线程发过来的任务里带有handler字段,worker 根据这个字段找到对应的处理函数,然后执行。

// worker-script.js const { parentPort } = require('worker_threads'); const handlers = { minifyJs: async ({ code, options }) => { const { minify } = require('terser'); const result = await minify(code, options); return { code: result.code, map: result.map }; }, compressCss: async ({ code, options }) => { const postcss = require('postcss'); const cssnano = require('cssnano'); const result = await postcss([cssnano({ preset: 'default' })]).process(code, { from: undefined, ...options, }); return { code: result.css }; }, generateRouteModule: async ({ filePaths, template }) => { // 根据文件路径数组生成路由模块代码 const lines = filePaths.map((p) => { const name = p.replace(/\.vue$/, '').replace(/\//g, '-'); return `const ${name} = () => import('${p}')`; }); return { code: template.replace('__IMPORTS__', lines.join('\n')) }; }, analyzeSource: async ({ content }) => { // 模拟一个耗时分析过程 const start = Date.now(); let count = 0; for (let i = 0; i < content.length; i++) { if (content[i] === '\n') count++; } return { totalLines: count, elapsed: Date.now() - start }; }, }; parentPort.on('message', async (msg) => { const { taskId, data } = msg; const handler = handlers[data.handler]; if (!handler) { parentPort.postMessage({ taskId, error: `Unknown handler: ${data.handler}`, }); return; } try { const result = await handler(data.payload); parentPort.postMessage({ taskId, result }); } catch (err) { parentPort.postMessage({ taskId, error: err.message }); } });

有两点值得注意。

第一,terser和postcss这些库要在 worker 脚本里动态引入,不要在 pool 初始化时一次性都加载。虽然每个 worker 启动时会执行一次这个脚本,但require本身是有缓存的,实际开销比想象中小。如果 handler 很多,你可以在 worker 脚本顶部做一个懒加载映射,按需引入。

第二,worker 里处理异常时,务必将错误信息序列化成字符串再传回主线程。直接传Error对象在某些 Node 版本下会被序列化成{},主线程拿到的就是一个空对象,排查问题特别痛苦。我在代码里主动取了err.message,就是为这个。

3.3 任务拆分粒度与调度优化

任务拆分粒度是这个方案里最需要经验的地方。我以一个实际项目为例:有一个 300 个 chunk 的构建产物,需要做terser压缩,每个 chunk 约 80KB 到 2MB 不等。如果我把 300 个 chunk 全部当作单独任务分发,任务队列会非常长,频繁的 postMessage 本身也有开销。如果只分成 4 个大任务,每个任务里有 75 个 chunk,又可能出现某个任务里都是大文件,其他任务都是小文件,最终负载不均。

我的做法是做一个简单的"加权任务分配":先统计每个 chunk 的大小,然后把 chunk 按大小降序排列,用贪心算法把任务分配给当前累计体积最小的 worker。这样可以做到大致均衡,避免出现一个 worker 在算大文件、其他 worker 已经空闲的情况。

function splitTasks(items, workerCount) { const sorted = [...items].sort((a, b) => b.size - a.size); const buckets = Array.from({ length: workerCount }, () => ({ totalSize: 0, items: [], })); for (const item of sorted) { let minBucket = buckets[0]; for (const bucket of buckets) { if (bucket.totalSize < minBucket.totalSize) { minBucket = bucket; } } minBucket.totalSize += item.size; minBucket.items.push(item); } return buckets; }

这个贪心算法虽然不是最优解,但实现简单、效果明显。理论上这是一个"多处理器调度"问题,最优解需要动态规划,但在构建场景里没这个必要。实际跑下来,4 个 worker 的负载差距可以控制在 5% 以内。

4. 在 Vite 插件里接入并行压缩

4.1 重写 build.minify 链路

Vite 默认的build.minify用的是 esbuild 或者 terser,如果你想接管压缩逻辑,最干净的方式是关掉 Vite 自带的压缩,然后在generateBundle钩子里自己处理。

这里有一个很重要的细节:generateBundle钩子执行时,所有的 chunk 已经生成完了,你在钩子里修改bundle[fileName].code,Rollup 最终输出时会使用修改后的代码。所以这个钩子特别适合做压缩替换。

// vite-plugin-parallel-minify.js import { WorkerPool } from './worker-pool'; import { createRequire } from 'module'; const require = createRequire(import.meta.url); export function parallelMinify(options = {}) { let pool; return { name: 'parallel-minify', apply: 'build', async buildStart() { pool = new WorkerPool('./worker-script.js', { size: options.workers || Math.max(2, require('os').cpus().length - 1), }); }, async generateBundle(_, bundle) { const tasks = []; for (const [fileName, output] of Object.entries(bundle)) { if (output.type === 'chunk' && output.code) { tasks.push({ fileName, code: output.code, size: output.code.length, }); } } const split = splitTasks(tasks, pool.size); const results = await Promise.all( split.map((bucket) => pool.runTask({ handler: 'minifyJs', payload: { items: bucket.items, options: { compress: true, mangle: true, }, }, }) ) ); // 把压缩结果写回 bundle for (const bucketResult of results) { for (const item of bucketResult.items) { const fileName = item.fileName; if (bundle[fileName]) { bundle[fileName].code = item.code; } } } }, async buildEnd() { if (pool) { await pool.close(); } }, }; }

这里我让一个 worker 任务处理一个 bucket 里的多个文件,而不是一个文件一个任务,就是为了减少通信开销。payload.items是一个数组,worker 脚本里的minifyJshandler 要相应改成循环压缩。

需要注意:在generateBundle里直接改bundle[fileName].code,这个操作是在所有 Rollup 输出钩子执行完之后才生效的,所以如果你还有别的插件依赖压缩后的代码做分析,可能会拿到原始代码。遇到这种情况,要把自己的插件放在其他分析插件之后执行,或者在writeBundle之后重新读取产物再处理。

4.2 CSS 压缩与静态资源处理的并行化

CSS 压缩我在同一个插件里加了一个并行分支。PostCSS 的cssnano是一个 CPU 密集插件,安装了很多 preset 之后,处理一个几十 KB 的文件都要几十毫秒,几百个文件叠加起来就很可观了。

做法是在generateBundle里筛出所有.css文件,同样按体积拆包,分给 worker 池处理。因为 worker 脚本里已经注册了compressCsshandler,主线程这边只需要把任务发过去就能拿到结果。

静态资源处理稍微特殊一点,有些操作不是纯 CPU,比如生成 webp 格式,它依赖sharp或imagemin这类原生库。使用原生库时要注意:worker_threads 里的原生模块加载和主线程不同,打包工具在打包 worker 脚本时可能不会把原生依赖的binding文件带上。我在实际使用时遇到过sharp在 worker 里报NODE_MODULE_VERSION不匹配的问题,解决方案是直接用fork一个独立的 JS 文件跑资源处理,或者确保 worker 脚本以 CommonJS 方式直接加载,而不是经过 bundler 二次打包。

如果是纯 JS 的压缩库,比如svgo,在 worker 里跑基本没问题。但像imagemin这种带bin和原生插件的库,建议还是留在主线程或者用child_process来做,省得折腾。

4.3 动态路由模块生成的并行实践

这个场景不是所有人都需要,但我觉得很值得分享,因为它在业务项目里非常实用。很多 Vue3 项目里都有这样的需求:启动时扫描src/views下的所有页面文件,生成一份路由配置或者动态导入映射。文件一多,字符串拼接和正则分析就会成为构建耗时的大头。

我写过这样一个插件:在buildStart时扫描src/views目录,拿到所有.vue文件路径,然后根据目录层级生成一个路由树结构。这个生成过程涉及大量路径解析、模板字符串拼接、文件命名处理,用 worker 并行处理后,整体耗时可以从原来的 3 秒降到 0.5 秒左右。

export function autoRoutePlugin(options = {}) { let pool; return { name: 'auto-route', apply: 'build', async buildStart() { pool = new WorkerPool('./worker-script.js', { size: options.workers || 4, }); const viewsDir = options.viewsDir || 'src/views'; const filePaths = await scanVueFiles(viewsDir); const buckets = splitIntoBuckets(filePaths, pool.size); const results = await pool.runTask({ handler: 'generateRoutes', payload: { buckets, basePath: viewsDir, }, }); const finalCode = mergeRouteResults(results); this.emitFile({ type: 'prebuilt-chunk', fileName: 'virtual-routes.js', code: finalCode, }); }, async buildEnd() { if (pool) { await pool.close(); } }, }; }

这个方案相比传统做法,核心改进就是把scanVueFiles拿到的文件列表按目录拆分,每个 worker 只处理自己那一部分目录,最后主线程把结果按固定顺序合并起来。这样既保证了并行性,又避免路线生成结果被无序合并破坏。

要注意一个点:如果你用emitFile生成虚拟模块,其他地方一引用这个模块,Vite/Rollup 会在内部处理依赖关系。如果你用的是一个真实文件名,要确保在resolveId里做了返回,否则运行时会报模块找不到。

5. 常见问题与排查技巧实录

5.1 Worker 启动慢于任务执行

我最初非常乐观地估计 worker 启动时间是微秒级的,实际测下来不是。Node.js 每次 new Worker 都要加载脚本、初始化 V8 实例,这个时间从几十毫秒到几百毫秒不等。如果你只有 5 个任务,却创建了 8 个 worker,可能任务都跑完了,还有几个 worker 刚刚启动成功。

解决办法很简单:worker 池要坚持复用。我一直强调池子设计,就是因为建立连接的开销是要平摊到成千上万个任务上的。你的插件要把buildStart里创建的 pool 保存到实例属性,在buildEnd时统一关闭,不要在generateBundle每次执行时新建。

另外,第一次任务发出去之前,可以先让 pool 提前初始化,比如在configResolved钩子里就创建好 worker,而不是等用到的时候才建。这样构建一开始,worker 已经在后台待命了。

5.2 内存溢出与任务数据过大的处理

并行压缩看起来很爽,但如果你的 chunk 特别大,比如一个 vendor chunk 有 5MB,你把这份代码通过postMessage传给 worker,再传回来,中间多出两次内存拷贝,峰值内存会翻倍。我遇到过vite build时内存占用达到 3GB,直接触发了 Node.js 的默认堆上限。

解决思路是两条路。第一条路,在 Node.js 启动时调大内存,比如NODE_OPTIONS="--max-old-space-size=4096",这个对 worker 线程也有效,但我更推荐第二条路:分片处理。一个 5MB 的 chunk,你可以把代码按行数拆成几个片段,分别压缩再拼接回来。但这里要非常小心,压缩器本身是有跨片段优化能力的,你拆开再拼,可能丢了部分压缩收益。更稳妥的做法是,如果你的代码是纯 JavaScript,且没有用到需要全局分析的语法,拆片问题不大;但压缩器默认会对顶层作用域做处理,拆片后可能保留多余的变量名,导致压缩率下降。

真实项目里我通常不拆大 chunk,而是通过配置manualChunks把大型 vendor 拆成多个小 chunk,让每个 chunk 的体积控制在 1MB 左右。这样既利于浏览器并行加载,又匹配了 worker 任务粒度的通信开销,一举两得。唯一的代价是可能要重新规划一下manualChunks的拆分策略,避免公共模块被重复打包。

5.3 动态路由生成后热更新失效的处理

有读者可能会问,既然我在构建阶段用 worker 做了动态路由生成,那开发模式怎么办?如果你的插件在apply: 'build'下运行,那开发环境完全不受影响。但如果你希望开发环境也用到这份自动生成的路由,就不能只放在构建插件里。

我建议的做法是,开发环境单独写一个中间件插件,在configureServer里用同样的scanVueFiles逻辑生成路由,并转换成虚拟模块。不要在 worker 池里做这件事,开发环境更看重的是少量重新生成和快速响应,用缓存比用多线程更划算。

如果你真的想开发环境也并行生成,至少要监听文件变化,只增量生成变化目录对应的路由部分,否则首次启动速度反而会变慢。Vite 的开发模式启动时间本来就不算长,把几百毫秒的生成时间压到几十毫秒,其实意义不大,我倾向于不做过度优化。

5.4 跨平台路径兼容问题

Worker 脚本路径在 Windows 和 macOS 下的写法不一样,直接path.resolve(__dirname, './worker-script.js')在 Windows 下没问题,但在打包成二进制或者用 pnpm 管理依赖时,__dirname可能不是预期的位置。

我习惯的做法是把 worker 脚本放在项目根目录下的scripts/目录,然后用new URL('./worker-script.js', import.meta.url)的方式去解析路径。这样在任何平台和模块系统下都能正确找到文件。另外,如果你的项目用了 pnpm 的严格 node_modules 结构,worker 脚本里require('terser')必须在项目自身的package.json里声明依赖,否则会报模块找不到。这一点坑了我整整一下午。

6. 实测效果与最后的建议

在 8 核 CPU 的 Linux 构建机上,我把这个方案用在一个中等规模的 Vue3 后台项目上,场景包含 260 多个页面、40 多个路由模块、300 多个 chunk。改造前的生产构建时间是 107 秒,改造后是 63 秒,提速约 41%。其中 JS 压缩部分从 34 秒降到 8 秒,CSS 压缩从 11 秒降到 3 秒,动态路由生成从 2.8 秒降到 0.6 秒。

开发环境的改动很小,因为 Vite 的 dev server 本身优化得很好,worker_threads 主要是在 CI 打包机上发挥作用。我个人在实操中发现,如果你的项目构建时间本来就在 30 秒以内,这套方案带来的绝对收益可能只有 5 到 10 秒,不值得为了这点收益引入额外的复杂度和维护成本。但如果你的构建时间超过 1 分钟,或者你有大量需要并行处理的定制构建逻辑,用 Worker Threads 做任务分发池绝对值得一试。

最后再分享一个小技巧:如果你要跑的构建任务非常多,而且每次构建之间代码变化不大,可以考虑先跑一次构建,把产物缓存下来,后续构建只处理变化的文件。这个思路和 Worker Threads 并行是两个维度,两者叠加之后的提速效果非常可观。我试过把"并行压缩 + 产物缓存"组合使用,第二次构建从 63 秒直接降到 15 秒左右,当然这是额外的话题了,但值得你花时间研究。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询