Web Worker 与 OffscreenCanvas:把主线程从重活里解放出来

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-PolicyCross-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 那套多线程共享内存的玩法,我觉得大部分前端场景还用不上。等哪天浏览器里跑的东西重到需要手动管内存同步了,那估计前端这个岗位的技能树也该长得不太一样了。

相关推荐
Lee川2 小时前
深度拆解:基于面向对象思维的“就地编辑”组件全模块解析
javascript·架构
codingWhat2 小时前
手撸一个「能打」的 React Table 组件
前端·javascript·react.js
进击的尘埃2 小时前
用 TypeScript 的 infer 搓一个类型安全的深层路径访问工具
javascript
yuki_uix2 小时前
Object.entries:优雅处理 Object 的瑞士军刀
前端·javascript
Lee川2 小时前
JavaScript 面向对象编程全景指南:从原始字面量到原型链的终极进化
javascript·面试
Neptune16 小时前
JavaScript回归基本功之---类型判断--typeof篇
前端·javascript·面试
进击的尘埃6 小时前
微前端沙箱隔离:qiankun 和 wujie 到底在争什么
javascript
子兮曰8 小时前
后端字段又改了?我撸了一个 BFF 数据适配器,从此再也不怕接口“屎山”!
前端·javascript·架构
颜酱10 小时前
一步步实现字符串计算器:从「转整数」到「带括号与优化」
javascript·后端·算法