Web Worker 与 OffscreenCanvas:把主线程从重活里解放出来
页面上有个 Canvas 在画图表,数据量一上来,拖拽、缩放直接卡成幻灯片。打开 DevTools 看火焰图,一帧干到 200ms,全是 JS 执行时间。按钮点了没反应,算法优化了好几轮还是没用。
问题不在算法,在主线程。
浏览器主线程是个单行道——JS 执行、DOM 更新、事件处理、样式计算全挤在一条线上。往 Canvas 上画 10 万个点的时候,按钮的点击回调只能排队等着。这不是"优化一下就好了"的事,得从架构层面换思路。
Web Worker + OffscreenCanvas,把单行道变成双车道。
先搞清楚瓶颈在哪
不是所有卡顿都该搬进 Worker。搬之前先确认:瓶颈是计算,还是渲染?
Chrome Performance 面板录一段,看火焰图:
- 大块黄色(Scripting)→ 计算瓶颈,Worker 能救
- 大块绿色(Painting)→ 渲染瓶颈,换思路(减少绘制面积、分层)
- 大块紫色(Layout/Style)→ DOM 结构问题,跟 Worker 没关系
确认是计算瓶颈之后,再往下看。
Web Worker 基础:隔离但不共享
Worker 跑在独立线程,有自己的事件循环。代价是:不能访问 DOM,不能访问 window,跟主线程之间只能靠消息通信。
运行// main.ts
const worker = new Worker(new URL('./heavy.worker.ts', import.meta.url), {
type: 'module'
})
worker.postMessage({ type: 'calc', data: hugeArray })
worker.onmessage = (e) => {
renderChart(e.data.result)
}
运行// heavy.worker.ts
self.onmessage = (e) => {
if (e.data.type === 'calc') {
const result = heavyComputation(e.data.data)
self.postMessage({ result })
}
}
function heavyComputation(data: number[]) {
return data.sort((a, b) => a - b).reduce(/* ... */)
}
真用起来有几个坑。
postMessage 的序列化成本
postMessage 传数据走结构化克隆(Structured Clone)。传个小对象没感觉,传个 50MB 的 Float64Array,光序列化就能卡主线程几百毫秒,本末倒置了。
解法是 Transferable Objects:
运行// ❌ 克隆传输 → 大数组会卡主线程
worker.postMessage({ buffer: hugeFloat64Array })
// ✅ 转移所有权 → 零拷贝
worker.postMessage({ buffer: hugeFloat64Array.buffer }, [hugeFloat64Array.buffer])
// transfer 之后主线程的 hugeFloat64Array 长度变 0,不能再用
transfer 是"移交"不是"复制"——数据从主线程转给 Worker,主线程就失去访问权。零拷贝,没有性能损失,但架构上要想清楚数据的所有权流转。
SharedArrayBuffer:真正的共享内存
如果需要两边同时读写同一块数据,SharedArrayBuffer 是另一条路。
运行// main.ts
const sab = new SharedArrayBuffer(1024 * 1024) // 1MB 共享内存
const view = new Int32Array(sab)
worker.postMessage({ sab }) // 不需要 transfer,两边都能用
Atomics.store(view, 0, 42)
// Worker 里也能读到这个 42
不过 SharedArrayBuffer 在业务项目里用得不多。一方面要配 COOP/COEP 响应头(Cross-Origin-Opener-Policy 和 Cross-Origin-Embedder-Policy),部署上得改 Nginx 配置;另一方面并发读写要用 Atomics 做同步,心智负担跟写 C 的多线程差不多。
大部分场景 Transferable 就够了。
OffscreenCanvas:Worker 里直接画
Web Worker 能算但不能画——没有 DOM 访问权限。计算完的数据要画到 Canvas 上,正常情况下得传回主线程再画。
OffscreenCanvas 解决的就是这个问题:让 Worker 直接操作 Canvas 的绘图上下文。
运行// main.ts
const canvas = document.getElementById('chart') as HTMLCanvasElement
const offscreen = canvas.transferControlToOffscreen()
worker.postMessage({ canvas: offscreen }, [offscreen])
// 转移之后,主线程不能再操作这个 canvas
运行// render.worker.ts
let ctx: OffscreenCanvasRenderingContext2D
self.onmessage = (e) => {
if (e.data.canvas) {
const canvas = e.data.canvas as OffscreenCanvas
ctx = canvas.getContext('2d')!
startRenderLoop()
}
}
function startRenderLoop() {
function frame() {
ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height)
drawTenThousandPoints(ctx)
requestAnimationFrame(frame)
}
frame()
}
function drawTenThousandPoints(ctx: OffscreenCanvasRenderingContext2D) {
for (let i = 0; i < 10000; i++) {
const x = Math.random() * ctx.canvas.width
const y = Math.random() * ctx.canvas.height
ctx.fillStyle = `hsl(${(i / 10000) * 360}, 70%, 50%)`
ctx.fillRect(x, y, 2, 2)
}
}
transferControlToOffscreen() 之后,Canvas 的渲染完全在 Worker 线程完成。主线程上的按钮点击、页面滚动、文字输入完全不受影响。
之前做地图上的实时轨迹热力图,几千条轨迹同时渲染,没用 OffscreenCanvas 的时候缩放地图掉帧明显。搬到 Worker 之后帧率稳在 55-60,体感差距很大。
实战架构:计算和渲染都丢出去
典型架构:
┌──────────────┐ ┌──────────────────┐
│ 主线程 │ │ Render Worker │
│ │ canvas │ │
│ UI 交互 │ ───────→ │ OffscreenCanvas │
│ 事件监听 │ transfer │ bindbindbindbin │
│ 状态管理 │ │ │
│ │ └──────┬───────────┘
│ │ │ 请求数据
│ │ ┌──────▼───────────┐
│ │ │ Compute Worker │
│ │ │ │
│ │ │ 数据计算/聚合 │
│ │ │ 坐标变换 │
└──────────────┘ └──────────────────┘
主线程只管 UI 交互和事件分发。计算丢给 Compute Worker,渲染丢给 Render Worker。两个 Worker 之间用 MessageChannel 直接通信,不绕回主线程。
运行// main.ts —— 搭建通信管道
const computeWorker = new Worker(new URL('./compute.worker.ts', import.meta.url), { type: 'module' })
const renderWorker = new Worker(new URL('./render.worker.ts', import.meta.url), { type: 'module' })
const channel = new MessageChannel()
computeWorker.postMessage({ port: channel.port1 }, [channel.port1])
renderWorker.postMessage({ port: channel.port2 }, [channel.port2])
canvas.addEventListener('wheel', (e) => {
computeWorker.postMessage({
type: 'zoom',
delta: e.deltaY,
center: { x: e.offsetX, y: e.offsetY }
})
})
运行// compute.worker.ts
let port: MessagePort
self.onmessage = (e) => {
if (e.data.port) {
port = e.data.port
return
}
if (e.data.type === 'zoom') {
const transformed = transformAllPoints(e.data)
port.postMessage({ type: 'bindPoints', points: transformed })
}
}
主线程基本就是个调度员,自己不干重活。
实际用下来的几个痛点
调试体验一般。 Worker 里的代码在 DevTools 里能调试,但 Source Map 有时候不太稳定,尤其是 Vite 开发环境下。断点打不上、变量看不了的情况偶尔出现,最后还是靠 console.log 排查。
错误处理容易漏。 Worker 里抛异常不会冒泡到主线程,必须显式监听 error 事件。不监听的话 Worker 默默挂了完全没有感知。
运行worker.onerror = (e) => {
console.error('Worker 挂了:', e.message, e.filename, e.lineno)
}
生命周期管理。 Worker 创建有开销(加载和解析脚本),频繁创建销毁不划算。长驻 Worker 又得注意内存泄漏。比较稳妥的做法是搞个 Worker 池,初始化时创建 2~4 个,任务来了分配,空闲了回收但不销毁。
OffscreenCanvas 的兼容性。 Safari 16.4+(2024 年底)才正式支持。老版本 Safari 需要降级处理:
运行function setupCanvas(canvas: HTMLCanvasElement) {
if (typeof canvas.transferControlToOffscreen === 'function') {
const offscreen = canvas.transferControlToOffscreen()
renderWorker.postMessage({ canvas: offscreen }, [offscreen])
} else {
fallbackRender(canvas) // 主线程渲染
}
}
什么时候不该用
Worker 不是银弹。搬进 Worker 意味着更复杂的代码结构、更难的调试、更多的通信协调。
几个不值得搬的场景:
- 计算本身就很快(< 5ms):通信开销可能比计算本身还大
- 强依赖 DOM 的操作:Worker 里没有 DOM,相关逻辑只能留在主线程
- 数据量小但交互频繁:每次交互都发 postMessage,序列化反序列化的开销会累积
粗暴的判断标准:某段逻辑执行时间稳定超过 16ms(一帧的预算),考虑搬。低于 16ms,不折腾。
Worker + WebAssembly 的组合
如果计算密集任务是纯数学运算(图像处理、物理模拟、加密解密),Worker + Wasm 是目前浏览器里的性能天花板。
运行// compute.worker.ts
import init, { process_image } from './image_processor_bg.wasm'
self.onmessage = async (e) => {
await init()
const inputBuffer = new Uint8Array(e.data.imageBuffer)
const result = process_image(inputBuffer, e.data.width, e.data.height)
self.postMessage({ processed: result.buffer }, [result.buffer])
}
Worker 提供独立线程,Wasm 提供接近原生的执行速度。两者叠加,某些场景下性能提升能到一个数量级。不过 Wasm 本身开发成本不低,JS 够用的情况下没必要上。
总结
主线程是稀缺资源——处理用户输入、跑框架更新逻辑、执行动画、计算布局,每一帧只有 16ms 预算。塞进去一个 50ms 的计算任务,用户就能感知到卡顿。
Worker 和 OffscreenCanvas 的核心价值不是"让代码跑得更快",而是"让主线程只干它该干的事"。计算归计算线程,渲染归渲染线程,主线程管交互和调度,各司其职。
架构上多一层抽象确实多一层复杂度。但 Canvas 上要画几万个元素、做实时数据可视化、跑客户端 AI 推理的时候,这层抽象值得投入。