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,跟主线程之间只能靠消息通信。
ts
// 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) => {
// 拿到结果,更新 UI
renderChart(e.data.result)
}
ts
// 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:
ts
// ❌ 克隆传输 → 大数组会卡主线程
worker.postMessage({ buffer: hugeFloat64Array })
// ✅ 转移所有权 → 零拷贝,瞬间完成
worker.postMessage({ buffer: hugeFloat64Array.buffer }, [hugeFloat64Array.buffer])
// 注意:transfer 之后,主线程的 hugeFloat64Array 就废了,长度变 0
transfer 是"移交"不是"复制"。数据从主线程转给 Worker,主线程就不能再用了。反过来 Worker 传结果回主线程也一样。这个设计挺好的------零拷贝,没有性能损失。但你得在架构上想清楚数据的所有权流转。
SharedArrayBuffer:真正的共享内存
如果你需要两边同时读写同一块数据,SharedArrayBuffer 是另一条路。
ts
// 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 的绘图上下文。
ts
// main.ts
const canvas = document.getElementById('chart') as HTMLCanvasElement
// 把 canvas 的控制权转给 Worker
const offscreen = canvas.transferControlToOffscreen()
worker.postMessage({ canvas: offscreen }, [offscreen])
// 转移之后,主线程不能再操作这个 canvas 了
ts
// 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)
// 在 Worker 里直接画,主线程完全不受影响
drawTenThousandPoints(ctx)
requestAnimationFrame(frame) // Worker 里也能用 rAF
}
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) // 每个点 2x2 像素
}
}
关键点:transferControlToOffscreen() 之后,这个 Canvas 的渲染完全在 Worker 线程。主线程上用户点按钮、滚页面、输入文字,丝滑得跟没有那个 Canvas 一样。
之前做过一个项目,地图上要实时画轨迹热力图,几千条轨迹同时渲染。没用 OffscreenCanvas 之前,缩放地图的时候肉眼可见地掉帧。搬到 Worker 之后,帧率稳在 55-60,体感完全不一样。
实战架构:计算和渲染都丢出去
一个典型的架构长这样:
arduino
┌──────────────┐ ┌──────────────────┐
│ 主线程 │ │ Render Worker │
│ │ canvas │ │
│ UI 交互 │ ───────→ │ OffscreenCanvas │
│ 事件监听 │ transfer │ 绑定 & 绑制 │
│ 状态管理 │ │ │
│ │ └──────┬───────────┘
│ │ │ 请求数据
│ │ ┌──────▼───────────┐
│ │ │ Compute Worker │
│ │ │ │
│ │ │ 数据计算/聚合 │
│ │ │ 坐标变换 │
└──────────────┘ └──────────────────┘
主线程只管 UI 交互和事件分发。计算丢给 Compute Worker,渲染丢给 Render Worker。两个 Worker 之间可以用 MessageChannel 直接通信,不用再绕回主线程。
ts
// 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' })
// Worker 之间直连的通道
const channel = new MessageChannel()
computeWorker.postMessage({ port: channel.port1 }, [channel.port1])
renderWorker.postMessage({ port: channel.port2 }, [channel.port2])
// 用户交互 → 通知 compute worker
canvas.addEventListener('wheel', (e) => {
computeWorker.postMessage({
type: 'zoom',
delta: e.deltaY,
center: { x: e.offsetX, y: e.offsetY }
})
})
ts
// 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) // 重新计算所有点的屏幕坐标
// 算完直接发给 render worker,不经过主线程
port.postMessage({ type: 'bindPoints', bindpoints: transformed })
}
}
这样主线程基本就是个"调度员",自己不干重活。
有些事没那么美好
说几个实际用下来觉得烦的地方。
调试体验一般。 Worker 里的代码在 DevTools 里能调试,但 Source Map 有时候会抽风,尤其是用 Vite 开发的时候。断点打不上、变量看不了,只能靠 console.log 硬查。这块工具链还有进步空间。
错误处理容易漏。 Worker 里抛异常不会冒泡到主线程。你得显式监听 error 事件,不然 Worker 默默挂了你都不知道。
ts
worker.onerror = (e) => {
console.error('Worker 挂了:', e.message, e.filename, e.lineno)
// 看情况决定是重启 Worker 还是降级到主线程执行
}
生命周期管理。 Worker 创建有开销(要加载和解析脚本),频繁创建销毁不划算。长驻 Worker 又得考虑内存泄漏。我一般的做法是搞个 Worker 池,初始化时创建 2~4 个,任务来了分配,空闲了回收但不销毁。
OffscreenCanvas 的兼容性。 2024 年底 Safari 才正式支持(Safari 16.4+),如果你的用户群里还有老版本 Safari......只能降级。
ts
// 特性检测 + 降级
function setupCanvas(canvas: HTMLCanvasElement) {
if (typeof canvas.transferControlToOffscreen === 'function') {
// 走 Worker 渲染
const offscreen = canvas.transferControlToOffscreen()
renderWorker.postMessage({ canvas: offscreen }, [offscreen])
} else {
// 降级:主线程渲染,能跑就行
fallbackRender(canvas)
}
}
什么时候不该用
Worker 不是银弹。搬进 Worker 意味着更复杂的代码结构、更难的调试、更多的通信协调。
几个不值得搬的场景:
- 计算本身就很快(< 5ms)。通信开销搞不好比计算本身还大
- 强依赖 DOM 的操作。Worker 里没有 DOM,你得把所有 DOM 相关的逻辑留在主线程
- 数据量小但交互频繁。每次交互都发一次 postMessage,序列化反序列化的开销会累积
一个粗暴的判断标准:如果某段逻辑执行时间稳定超过 16ms(一帧的预算),考虑搬。低于 16ms,别折腾。
和 WebAssembly 配合
提一嘴 Wasm。如果你的计算密集任务是纯数学运算(图像处理、物理模拟、加密解密),Worker + Wasm 是目前浏览器里能拿到的性能天花板。
ts
// compute.worker.ts
import init, { process_image } from './image_processor_bg.wasm'
self.onmessage = async (e) => {
await init() // 初始化 Wasm 模块(只需一次)
const inputBuffer = new Uint8Array(e.data.imageBuffer)
const result = process_image(inputBuffer, e.data.width, e.data.height)
// Wasm 算完 → transfer 回主线程或直接丢给 render worker
self.postMessage({ processed: result.buffer }, [result.buffer])
}
Worker 提供了独立线程,Wasm 提供了接近原生的执行速度。两者叠加,某些场景下性能提升能到 10 倍以上。当然,Wasm 本身的开发成本不低,如果 JS 够用就别上。
聊到这
主线程是稀缺资源。它要干的事太多了------处理用户输入、跑框架的更新逻辑、执行动画、计算布局。每一帧只有 16ms 的预算,你塞进去一个 50ms 的计算任务,用户就能感知到卡顿。
Worker 和 OffscreenCanvas 的价值不在于"让代码跑得更快",而在于"让主线程只干它该干的事"。计算归计算线程,渲染归渲染线程,主线程就管交互和调度。各司其职,互不干扰。
架构上多一层抽象,确实多一层复杂度。但当你的 Canvas 上要画几万个元素、要做实时数据可视化、要跑客户端 AI 推理的时候,这层抽象是值得的。
至于 SharedArrayBuffer 那套多线程共享内存的玩法,我觉得大部分前端场景还用不上。等哪天浏览器里跑的东西重到需要手动管内存同步了,那估计前端这个岗位的技能树也该长得不太一样了。