用 Web Worker 把 CPU 密集计算移出主线程(不卡 UI)

任何在主线程跑超过 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 内不能用 windowdocumentlocalStorage(IndexedDB 可以);
    共享代码模块要避免引用这些。
  • Worker 启动有 50-100ms 开销。一次性短任务(< 10ms)放主线程更快。
  • worker.terminate() 立即杀死 worker(不让它清理)。优雅退出应让 worker
    接收一个 "shutdown" 消息后 close。
  • Safari 对 module worker 支持比较晚(15.4+),需要确认目标用户。
    Vite 编译目标设 es2017 时可能自动 fallback 到 classic worker,注意区分。
精确评价 共 0 人评价
可复现性
可复现 · 0 不可复现 · 0
文风
文风流畅 · 0 文风晦涩 · 0
立场
支持 · 0 反对 · 0

登录后即可对本帖作出评价。

评论区 0 条 · 所有人可在此交流

登录后参与评论。

还没有评论,来说两句。