本文目录
前台只有一个人时,收银、回答问题和搬运货物都排在同一条队伍里。后厨另派一位员工,只能通过传菜口沟通,却能让前台不必亲自完成切菜。浏览器的主线程也承担脚本、样式、布局和输入响应;Web Worker(网页工作线程)提供了一个不直接操作 DOM 的后台工位。
先猜:Worker 为什么不能直接改页面
Worker 与主线程同属页面相关的浏览器能力,但不共享主线程的 JavaScript 全局对象与堆(默认也不能共享普通对象引用),各自有独立的执行环境与调用栈。它不能直接访问 document,结果需要通过消息交回主线程,再由主线程更新界面。
主线程为什么会卡
JavaScript 任务通常在主线程上串行执行。大数组排序、图片数据处理、复杂 JSON 转换或密码计算如果连续占用几十毫秒,点击、滚动和绘制都只能等待。Worker 的价值不是让算法本身变快,而是把这段等待从负责交互的队列移走。
// main.js
const worker = new Worker(new URL('./worker.js', import.meta.url), { type: 'module' })
worker.addEventListener('message', ({ data }) => {
result.textContent = `结果:${data.total}`
})
worker.postMessage({ numbers: [3, 1, 4, 1, 5] })// worker.js
self.addEventListener('message', ({ data }) => {
const total = data.numbers.reduce((sum, value) => sum + value, 0)
self.postMessage({ total })
})postMessage 会把消息排入对方的事件队列。发送后不要假设对方立刻处理,也不要把它当成同步函数调用。
消息不是共享对象
普通对象通常经过 structured clone(结构化克隆)复制。主线程修改原对象,不会改变 Worker 已经收到的那一份。这种隔离减少了并发读写的复杂度,但复制大数组和深层对象也会消耗时间与内存。
对于 ArrayBuffer 等二进制数据,可以使用 Transferable(可转移对象):所有权转移后,发送方的缓冲区会被置为不可用。它减少复制,却要求调用者明确谁拥有这块数据。
Worker 不是万能后台
Worker 不能直接操作 DOM、读取主线程里的响应式状态,也不会自动解决网络、内存或算法复杂度问题。它本身还有创建成本、序列化成本和消息调度成本。很小的计算搬过去,可能反而更慢。
在 Vue 中,通常让组件负责启动和终止 Worker,把输入数据作为消息发送,把结果写回 ref。组件卸载时调用 worker.terminate(),否则后台工位可能继续运行。
消息边界也是设计边界
不要把 Worker 当作“把函数挪到另一个文件”这么简单。主线程需要定义输入消息和输出消息的形状,最好带上任务 id、状态和错误信息。多个任务同时排队时,结果可能按完成顺序返回,而不是按发送顺序返回;如果界面只关心最新一次搜索,就要在主线程丢弃过期结果。
Worker 里的异常不会自动变成主线程的异常。可以监听 error 和 messageerror,在 Worker 内把可恢复错误转成结构化消息。对于不可恢复的状态,重建 Worker 往往比尝试继续使用一个坏掉的后台工位更清晰。
共享内存是另一种取舍
SharedArrayBuffer 和 Atomics 可以让多个执行环境看到同一块内存,但这会重新引入同步、竞态和死锁风险,而且受到跨源隔离等安全条件限制。大多数页面先用消息传递就够了:数据边界更明显,也更容易在 DevTools 中追踪。
判断是否该使用 Worker,可以先看 Performance:如果 Long Task 的主要时间确实来自纯计算,再比较“主线程计算”和“Worker 计算 + 传输结果”的总耗时。不要因为 API 看起来高级就提前拆分,拆分本身也是复杂度。
在真实页面里,Worker 最适合处理“输入明确、输出明确、几乎不需要 DOM”的工作:搜索索引、文本解析、压缩、格式转换和数据预处理。它不适合把整套业务状态搬走,也不适合频繁发送小消息。一个实用的边界是:主线程保留用户意图和界面状态,Worker 只负责可重复的计算;这样用户取消操作时,主线程可以忽略旧任务,Worker 也可以被安全终止。
还要注意模块 Worker 的加载路径和部署方式。使用 new URL('./worker.js', import.meta.url) 能让构建工具正确追踪文件;直接拼接运行时字符串,可能在生产构建后找不到资源。
实用边界可以收成三句:先在 Performance 里确认卡顿来自纯计算,再比较「主线程算完」与「Worker 算完 + 传回结果」的总耗时;接口尽量小(任务、结果、错误、取消),并用版本号丢掉过期结果;需要立刻驱动输入或动画时,仍让主线程路径保持短,Worker 只负责提前准备数据。异步回调不等于换了执行线程——这是它和 setTimeout / Promise 最容易混淆的一点。
容易踩的坑
Worker 不能直接更新 DOM,结果必须经消息回到主线程;因此“开了 Worker 就一定流畅”并不成立——渲染、合并结果和消息处理仍可能占满主线程。网络请求本身也未必需要 Worker:只有纯计算或消息处理成为瓶颈时,拆线程才划算。先在 Performance 里确认卡点,再比较“主线程算完”与“Worker 算完 + 传回”的总耗时。