任何在主线程跑超过 50ms 的 JS 都会让交互掉帧。常见 culprit:
- 大 JSON parse / stringify
- markdown 渲染、syntax highlight
- 客户端排序 / 过滤万条数据
- 图像 / 视频 / WASM 解码
正确做法:放 Web Worker。下面是从零到 production 的最小路径。
1. 经典 Worker(自己管字符串)
// main.js
const worker = new Worker('/worker.js')
worker.onmessage = e => console.log('got:', e.data)
worker.postMessage({ type: 'sort', payload: bigArray })
// worker.js
self.onmessage = e => {
if (e.data.type === 'sort') {
const sorted = e.data.payload.sort((a, b) => a - b)
self.postMessage(sorted)
}
}
毛病:字符串 type、回调地狱、worker 文件路径在打包工具里难管。
2. Vite + 模块 Worker(推荐)
Vite 原生支持 ?worker 后缀:
// worker.ts
self.onmessage = (e: MessageEvent<{ items: number[] }>) => {
const sorted = e.data.items.sort((a, b) => a - b)
self.postMessage(sorted)
}
export {} // 标记为 module
// main.ts
import MyWorker from './worker.ts?worker'
const worker = new MyWorker()
worker.postMessage({ items: [3, 1, 2] })
worker.onmessage = e => console.log(e.data)
Vite 会把 worker.ts 单独打包,部署后是独立 .js 文件。
3. Comlink:把 worker 当普通对象用
每次 postMessage / onmessage 写起来很烦。Comlink 把消息通信封装成
"调远程方法":
npm i comlink
// worker.ts
import { expose } from 'comlink'
const api = {
sort(items: number[]) {
return items.sort((a, b) => a - b)
},
async process(data: Row[]) {
// 任意复杂同步 / 异步逻辑
return data.map(transform).filter(predicate)
},
}
export type Api = typeof api
expose(api)
// main.ts
import { wrap, Remote } from 'comlink'
import Worker from './worker.ts?worker'
import type { Api } from './worker'
const worker = new Worker()
const api: Remote<Api> = wrap<Api>(worker)
// 用起来像普通对象
const sorted = await api.sort([3, 1, 2])
const processed = await api.process(rows)
代码主线非常清晰,类型完整。Comlink 内部还是 postMessage,但你不需要管。
4. Transferable:避免大数据拷贝
postMessage 默认深拷贝传过去。对 ArrayBuffer / ImageBitmap / OffscreenCanvas
可以用 transfer,把所有权直接交给 worker(O(1) 操作):
// main
const buf = new Float32Array(10_000_000).buffer
worker.postMessage({ buf }, [buf])
// buf 这边变成 length=0,不能再用
// worker
self.onmessage = e => {
const arr = new Float32Array(e.data.buf)
// ... 处理
self.postMessage({ result: arr.buffer }, [arr.buffer])
}
对于几十 MB 以上的数据,transfer vs 拷贝差距可以从几秒到 0。
5. SharedArrayBuffer:多 worker 共享同一块内存
需要服务端发 COOP / COEP header:
Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: require-corp
否则现代浏览器禁用 SharedArrayBuffer(Spectre 缓解)。
const shared = new SharedArrayBuffer(1024 * 1024)
const view = new Int32Array(shared)
worker1.postMessage({ buf: shared })
worker2.postMessage({ buf: shared })
// 三方都看到同一块内存;用 Atomics.* 同步
6. Worker Pool
CPU 任务很多时一个 worker 不够,开 N 个:
class Pool<T extends object> {
private workers: Remote<T>[]
private next = 0
constructor(n: number, factory: () => Remote<T>) {
this.workers = Array.from({ length: n }, factory)
}
call<K extends keyof T>(method: K, ...args: any[]) {
const w = this.workers[this.next++ % this.workers.length]
return (w[method] as any)(...args)
}
}
const pool = new Pool(
navigator.hardwareConcurrency || 4,
() => wrap<Api>(new Worker())
)
await pool.call('sort', items)
navigator.hardwareConcurrency 是浏览器报的核心数,通常等于物理核数。
7. 不要把 React 状态搬进 Worker
worker 没有 DOM,没法 React。它的工作是 纯计算 + 返回数据。
返回数据后主线程拿到才能 setState / 渲染。
8. 调试
Chrome DevTools 的 Sources 面板能看到 worker 上下文(左侧 frames 列表),
断点、step、查变量都正常。
踩过的坑
- worker 内不能用
window、document、localStorage(IndexedDB 可以);
共享代码模块要避免引用这些。 - Worker 启动有 50-100ms 开销。一次性短任务(< 10ms)放主线程更快。
- worker.terminate() 立即杀死 worker(不让它清理)。优雅退出应让 worker
接收一个 "shutdown" 消息后 close。 - Safari 对 module worker 支持比较晚(15.4+),需要确认目标用户。
Vite 编译目标设es2017时可能自动 fallback 到 classic worker,注意区分。
登录后参与评论。