引言
浏览器主线程不能阻塞——重计算要么分片、要么扔进 Worker,而 WASM 能把这些计算跑得接近原生。Vite 对这两者都有第一方支持:Worker 自动打包、WASM 直接 import。本文讲透组合拳:先讲 Vite 原生 Worker 的三种写法(?worker、new URL、动态 import)与代码分割,再讲模块 Worker(共享 import 与依赖、避免重复打包),接着讲 Worker 池与通信设计(Transferable、共享内存)、WASM 在 Vite 的加载姿势(?url + instantiateStreaming、async)、Worker + WASM 的组合模式(图片处理/加密/编解码),最后给性能权衡与决策(线程 vs 内存 vs 传输)。
前置:/vite-asset-processing/(资源管线与 Worker 概览)、/vite-build-optimization/(代码分割与产物)、/vite-hmr-internals/(模块图)。
目录
- 1. Worker 解决什么:主线程的三大问题
- 2. Vite 原生 Worker:三种加载方式
- 3. 模块 Worker:共享依赖与避免重复打包
- 4. Worker 池与并发控制
- 5. 通信设计:Transferable、结构克隆与共享内存
- 6. WASM 在 Vite:加载姿势与异步初始化
- 7. Worker + WASM 组合模式
- 8. 性能权衡:线程、内存与传输
- 9. 真实场景与工程实践
- 10. 速查表与一句话记忆
- 延伸阅读
1. Worker 解决什么:主线程的三大问题
主线程(UI 线程)承载一切:渲染、事件、脚本。重活占住它 → 页面卡死。
三大问题:
① 同步重计算(解析大 JSON、编解码)→ 阻塞渲染
② 高频任务(每帧处理)→ 掉帧
③ 长任务(图片处理、哈希)→ 用户点不了
Worker 的解法:把任务扔给独立线程,主线程只收发消息:
// 主线程
const worker = new Worker(...);
worker.postMessage({ data: bigArray });
worker.onmessage = (e) => use(e.data);
Worker 的能力边界(关键认知):
- 无 DOM、无 window → 只做纯计算/数据
- 可 fetch、可 import、可加载 WASM → 数据密集型很合适
- 通信靠 postMessage(克隆/转移)→ 大数据要 Transferable
何时值得 Worker:计算 ≥ 16ms(一帧)就有感知价值;先测主线程耗时,超一帧再上 Worker——别为了用而用。
记忆:Worker 是"把重活挪出 UI 线程"的工具——纯计算/数据适合、无 DOM;任务超 16ms 一帧才值得,别盲目上。
2. Vite 原生 Worker:三种加载方式
Vite 自动打包 Worker,三种写法各有用处:
① new URL(..., import.meta.url)(最推荐):
// 相对当前模块解析、自动打包、带 hash
const worker = new Worker(new URL("./worker.js", import.meta.url));
② ?worker 后缀(类模块导入、按需):
// 返回 Worker 构造函数,可搭配动态 import 做代码分割
import MyWorker from "./worker.ts?worker";
const w = new MyWorker();
// 按需:import(".../worker?worker").then(m => new m.default())
③ ?worker&inline(强制内联进主包):小 Worker 内联、省请求(多 Worker 慎用)。
import InlineWorker from "./tiny.js?worker&inline";
关键差异:
new URL(...) → 相对路径、清晰,但每个 Worker 独立 chunk
?worker → 可动态 import(懒加载)、类型提示好
?worker&inline → 打进主包(体积小/数量少时)
构建后的产物:Worker 生成独立 worker-[hash].js,主线程通过 new Worker(url) 加载。
记忆:Vite Worker 三写法——
new URL日常用、?worker配动态 import 懒加载、?worker&inline小 Worker 内联。
3. 模块 Worker:共享依赖与避免重复打包
经典问题:Worker 里 import 的工具库,会被重复打进 Worker chunk——主包一份、Worker 一份。
type: "module" 的 Worker(模块 Worker):
const worker = new Worker(new URL("./worker.ts", import.meta.url), {
type: "module", // Worker 内部也用 ESM import
});
模块 Worker 的好处:
- Worker 内可用 import / export(代码复用)
- Vite 处理其依赖 → 公共模块可能被提取/共享
- 类型系统(TS)在 Worker 里完整生效
避免重复打包:Vite 的模块 Worker 中,与主包共享的依赖会按 Rollup 的公共 chunk 策略处理(build.rollupOptions.output 配置):
export default {
build: {
rollupOptions: {
output: {
// 把共享依赖提取成公共 chunk,Worker 与主包共用
manualChunks: { shared: ["lodash-es", "fflate"] },
},
},
},
};
注意:模块 Worker 的浏览器支持(Chrome 80+ / Firefox 114+ / Safari 15+)——老浏览器要用经典 Worker + 自建打包。
记忆:模块 Worker 让 Worker 内能用 ESM 和共享依赖——公共 chunk 配置避免"主包一份 Worker 一份"的重复打包;老浏览器支持有限。
4. Worker 池与并发控制
别逐任务开 Worker——线程创建有成本、内存有上限(每 Worker 一份 JS 运行环境)。
Worker 池(Pool):预建 N 个 Worker,任务排队分发:
// 简单 Worker 池实现
class Pool {
constructor(size, workerUrl) {
this.workers = Array.from({ length: size }, () => new Worker(workerUrl));
this.idle = this.workers.map((w, i) => i);
}
run(msg) {
// 找空闲 Worker 或排队
const i = this.idle.shift() ?? /* 等待或排队 */;
return new Promise((resolve, reject) => {
this.workers[i].onmessage = (e) => { this.idle.push(i); resolve(e.data); };
this.workers[i].postMessage(msg);
});
}
}
池大小怎么定:
- 与 CPU 核数相关(navigator.hardwareConcurrency)
- 一般核数 - 1(留一个给主线程)
- 内存敏感场景少开(每个 Worker ~几十 MB)
并发控制:任务数 > Worker 数时排队,别同时发一堆 postMessage(消息队列会积压)。
const pool = new Pool(navigator.hardwareConcurrency - 1, "./calc.worker.js");
// 批量任务串行/受限并发提交
for (const item of items) {
await pool.run(item); // 池内排队,天然限流
}
记忆:Worker 池 = 预建 N 线程 + 排队分发;大小约 核数-1、看内存;并发任务排队提交,别逐任务开线程。
5. 通信设计:Transferable、结构克隆与共享内存
postMessage 的两条路:
结构克隆(structured clone):数据拷贝 → 大数组拷贝很贵
转移(transferable):ArrayBuffer 所有权转移 → 零拷贝,但源不可再用
Transferable 的正确用法:
// 主线程:把大 buffer 转给 Worker(零拷贝)
const buf = new ArrayBuffer(100 * 1024 * 1024); // 100MB
worker.postMessage({ buffer: buf }, [buf]); // 第二个参数=转移列表
// 之后主线程的 buf 已被 detach,不可再用
SharedArrayBuffer(共享内存):真正"共享"而非"转移",配 Atomics 同步:
// 需要 COOP/COEP 头(crossOriginIsolated)
const sab = new SharedArrayBuffer(4096);
worker.postMessage(sab);
// 双方直接读写同一块内存(Atomics.wait/notify 同步)
通信设计建议:
- 大数据单向流动 → Transferable(零拷贝)
- 频繁小块数据 → 合并成一次大消息(减少消息往返)
- 需要双向高频同步 → SharedArrayBuffer + Atomics(要跨源隔离头)
- 消息协议:定义清晰 request/response 结构
记忆:postMessage 两路——结构克隆慢、Transferable 零拷贝;SharedArrayBuffer + Atomics 才是真共享(要 COOP/COEP);大数据用转移、高频小消息合并发。
6. WASM 在 Vite:加载姿势与异步初始化
WASM 在 Vite 的加载方式:
// ① 最简单:直接 import(Vite 自动处理 + 同步初始化)
import init, { run } from "./lib.wasm";
// ② 只拿 URL + 手动实例化(可控)
import wasmUrl from "./lib.wasm?url";
const { instance } = await WebAssembly.instantiateStreaming(fetch(wasmUrl), imports);
// ③ 异步初始化包(Rust wasm-bindgen 常见)
import init from "./pkg/pkg.js";
await init(); // 拉取并实例化 wasm
推荐:?url + instantiateStreaming:
import wasmUrl from "./heavy.wasm?url";
async function loadWasm() {
const { instance } = await WebAssembly.instantiateStreaming(fetch(wasmUrl));
return instance.exports;
}
关键点:
- instantiateStreaming 边下载边编译(比先下载后编译快)
- WASM 模块要 import 函数(如内存、env)→ 传 imports 参数
- 初始化是异步的 → 模块加载完再调导出函数
- 不同工具链产物(Rust/C++/AssemblyScript)加载姿势略不同
Vite 的 WASM 支持:原生支持 .wasm import;用 .wasm?init 得到初始化函数(Vite 5.2+ 的实验能力),传统工具链产物则用 ?url 手动实例化。
记忆:WASM 三姿势——直接 import(同步)、
?url+ instantiateStreaming(可控推荐)、异步 init 包(wasm-bindgen 类);初始化是异步的,先 init 再调用。
7. Worker + WASM 组合模式
最佳组合:Worker 里跑 WASM——WASM 释放 Worker 线程的计算力,Worker 不让主线程卡顿:
主线程 → postMessage(原始数据)
Worker 线程 → 加载 WASM → 原生计算 → 返回结果
(首屏后的加载/编译都发生在 Worker,主线程零感知)
图片处理示例(worker + wasm):
// image.worker.js
import wasmUrl from "./resize.wasm?url";
let wasmExports;
self.onmessage = async (e) => {
if (!wasmExports) {
const { instance } = await WebAssembly.instantiateStreaming(fetch(wasmUrl));
wasmExports = instance.exports;
}
const { imageData } = e.data;
const result = wasmExports.resize(imageData.data, imageData.width, imageData.height);
self.postMessage(result, [result]); // 转移回去
};
// 主线程
const worker = new Worker(new URL("./image.worker.js", import.meta.url));
worker.postMessage({ imageData: raw }, [raw.data.buffer]); // 转移输入
经典组合场景:
- 图片/视频处理:resize、滤镜、编解码
- 加密/哈希:SHA、AES(WASM 库如 hash-wasm)
- 压缩:zlib、brotli(WASM 编译)
- 大数据解析:JSON 流、二进制协议
记忆:Worker + WASM = 计算力 + 不阻塞——Worker 内加载 WASM 后处理大数据、结果 Transferable 传回;图片/加密/压缩/解析四类场景最受益。
8. 性能权衡:线程、内存与传输
三本账要一起算:
① 线程账:Worker 启动成本(每次 ~几十 ms)、CPU 核数上限
② 内存账:每 Worker 一份运行时;WASM 线性内存大小可配
③ 传输账:postMessage 克隆 vs 转移;数据多大决定走哪条路
决策框架:
| 场景 | 主线程 | Worker | 是否值得 |
|---|---|---|---|
| 计算 < 16ms | ✓ | — | 不值得 |
| 计算 16-100ms | 分片 | 可选 | 视频率 |
| 计算 > 100ms | — | ✓ | 值得 |
| 高频数据流 | — | ✓ + Transferable | 值得 |
性能 checklist:
□ 先测量主线程耗时(Performance.now / DevTools Performance)
□ 大数据必须 Transferable(克隆会吃掉 Worker 的优势)
□ WASM 内存按需配(WebAssembly.Memory({initial, maximum}))
□ Worker 复用(池),别每任务创建销毁
□ 首屏不加载 Worker/WASM → 用户交互后再动态加载
// 动态加载:滚动/点击后再启动 Worker + WASM
const { default: CalcWorker } = await import("./calc.worker?worker");
const worker = new CalcWorker();
记忆:三本账——线程(启动成本+核数)、内存(每 Worker 一份运行时)、传输(克隆 vs 转移);>100ms 值得上 Worker、大数据必转移、WASM 内存按需配、首屏后动态加载。
9. 真实场景与工程实践
场景 A:大文件解析(Excel/CSV)
主线程 → 文件 ArrayBuffer → 转移给 Worker
Worker → WASM 解析(sheetjs wasm 或流式解析)→ 行数组转移回
主线程 → 渲染表格
场景 B:前端加密
import { sha256 } from "hash-wasm"; // WASM 哈希库
// 大文件分块在 Worker 里流式哈希,避免主线程卡顿
场景 C:图片压缩上传
用户选图 → Worker 里 WASM 压缩(WebP/AVIF)→ 转移结果 → 上传
工程实践清单:
□ Worker 代码保持"纯数据"(不碰 DOM/状态)
□ 通信协议版本化(字段变更向后兼容)
□ 失败/超时处理(Worker 卡死要能检测)
□ 生产环境日志从 Worker 转发(self.postMessage 打点)
□ 测试:Worker 单测(Vitest worker mock)+ 集成
// Worker 内部错误上报
try { /* 计算 */ }
catch (err) { self.postMessage({ type: "error", message: err.message }); }
记忆:实践四要点——纯数据、协议版本化、超时检测、日志转发;大文件解析/加密/图片压缩三类落地最典型。
10. 速查表与一句话记忆
| 需求 | 做法 |
|---|---|
| Worker 加载 | new URL(..., import.meta.url) / ?worker |
| Worker 内 ESM | type: "module" 模块 Worker |
| 并发控制 | Worker 池(核数-1) |
| 大数据传递 | Transferable(零拷贝) |
| 双向高频 | SharedArrayBuffer + Atomics(要 COOP/COEP) |
| WASM 加载 | ?url + instantiateStreaming |
| WASM + Worker | Worker 内加载 WASM,主线程零阻塞 |
| 首屏性能 | 动态 import,交互后再加载 |
| 决策 | >100ms 才值得、大数据必转移 |
一句话记忆:Worker 把重活挪出 UI 线程、WASM 让这些活跑得接近原生——Vite 原生支持两者:Worker 用 new URL(..., import.meta.url)/?worker(模块 Worker 共享依赖避免重复打包)、大数据必须 Transferable 零拷贝、双向高频才用 SharedArrayBuffer + Atomics(要 COOP/COEP 头)、WASM 用 ?url + instantiateStreaming 且初始化异步;Worker 池控制并发(核数-1)、首屏后动态加载;先测主线程耗时,>100ms 才值得——线程、内存、传输三本账一起算,Worker + WASM 就是可控的并行工具箱。
延伸阅读
- /vite-asset-processing/ — 资源管线与 Worker 概览
- /vite-build-optimization/ — 代码分割与公共 chunk
- /vite-hmr-internals/ — 模块图与依赖
- /vite-vitest-testing/ — Worker 单元测试
- [[rust]] — WASM 工具链(wasm-bindgen)
- [[golang]] — WASM 编译到 WebAssembly
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。