本文目录
银行网点可以有后台清算中心专门算账、对流水,但面对客户的只有柜台窗口。屏幕上的余额、打印出来的回单,最终都要由柜台更新。
浏览器里的 JavaScript 主线程就像柜台窗口:能碰 DOM、能响应点击、能改页面文字。Dedicated Worker 是后台清算:能认真算数据,却不能直接跑到前台改屏幕上的数字。
流式读取省下了等待,却没省掉计算
上一章里,我们用 for await...of 边收边处理响应。网络等待期间,主线程可以做别的事;可如果每一块到达后都要做大量解析、解压或统计,页面仍可能卡住。
第四章讲过,用 setTimeout 把大循环切成小批次,能让浏览器在批次之间处理输入和渲染。但总计算量不会消失;若每一批仍要跑几百毫秒,用户还是会感到一顿一顿。若工作本质是 CPU 密集,而且与 DOM 无关,更合适的分工往往是:主线程负责界面与协调,Worker 负责重计算。
先猜输出:Worker 里的日志谁先出现
主页面:
const worker = new Worker('./hash-worker.js', { type: 'module' })
worker.onmessage = (event) => {
console.log('主线程收到', event.data)
}
worker.postMessage({ text: 'hello' })
console.log('主线程已发送')hash-worker.js:
self.onmessage = (event) => {
console.log('Worker 收到', event.data)
self.postMessage({ digest: event.data.text.length })
}常见顺序是:
主线程已发送
Worker 收到 { text: 'hello' }
主线程收到 { digest: 5 }postMessage 不会等 Worker 算完才返回;主线程登记消息后继续执行。Worker 在另一条线程里处理消息,再通过 onmessage 把结果送回主线程。两边各自有 JavaScript 执行环境,不会把同一段同步代码拆到两个核心上同时跑。
注意主线程的 worker.onmessage 本身也在事件循环里作为任务执行。若你在 onmessage 里又做重活,仍会形成主线程长任务。Worker 只是把“可迁移的计算”挪走,结果处理和 UI 更新通常仍要刻意写轻。
Worker 不是“免费多核 async”
new Worker(url) 会启动一个独立的全局对象,通常记作 DedicatedWorkerGlobalScope。它有 self、importScripts(经典 Worker)或 ES module 导入能力,却没有 document、window 和 DOM API。
这意味着:
- Worker 里不能直接
document.querySelector。 - Worker 里发起的
fetch结果要经postMessage送回主线程,主线程再更新界面。 - 每个 Worker 对应一个独立线程上的 JavaScript 环境;它不是把同一个函数切成两半并行执行。
主线程仍是页面交互的枢纽。Worker 解决的是“这段计算能不能离开主线程”,不是“所有异步都自动变快”。网络请求、await、定时器回调,讨论的是执行权何时回到主线程;Worker 讨论的是另一段 JavaScript 能否在别的线程里跑。
postMessage 传的是消息,不是共享变量
两边通过消息通信:
// main.js
worker.postMessage({ jobId: 1, payload: largeArray })
// worker.js
self.onmessage = (event) => {
const { jobId, payload } = event.data
const result = heavyTransform(payload)
self.postMessage({ jobId, result })
}postMessage 的第二个参数可以列出 Transferable 对象。对 ArrayBuffer 来说,转移所有权后,发送方线程里的同一块缓冲通常变为不可用,接收方获得这块内存,而不必完整复制一份:
const buffer = new ArrayBuffer(1024)
worker.postMessage({ buffer }, [buffer])
// 此后主线程里的 buffer 字节长度为 0不传第二参数时,数据会经过 结构化克隆算法 复制到另一边。对象图、循环引用、Map、Set、许多内置类型可以克隆,但 DOM 节点、某些带原型的复杂对象不行。函数本身也不能克隆传递后在另一边直接调用——消息里通常只传数据描述和可序列化结果。
大对象频繁克隆会带来可观开销。图像处理时,更常见的做法是把像素放进 ArrayBuffer 或 ImageBitmap,用 Transferable 交给 Worker;算完再传回摘要或新缓冲,而不是每次复制整张图。
请求和响应要自己做配对
消息是异步投递的。postMessage 返回只表示消息已排队,不表示 Worker 已经处理完。若需要“发一条、等一条”的语义,业务要自己维护协议:
let jobId = 0
const pending = new Map()
function callWorker(worker, payload) {
const id = jobId += 1
return new Promise((resolve, reject) => {
pending.set(id, { resolve, reject })
worker.postMessage({ id, payload })
})
}
worker.onmessage = (event) => {
const { id, result, error } = event.data
const job = pending.get(id)
if (!job) return
pending.delete(id)
if (error) job.reject(new Error(error))
else job.resolve(result)
}这和第十章里 Promise 表达异步完成是同一类问题:Worker 不会替你把 postMessage 自动变成 await。库(例如 Comlink)做的,本质上也是在这层协议上封装。
Worker 也有自己的事件循环
Worker 线程里同样有任务与微任务。self.onmessage 回调在 Worker 的事件循环中作为任务运行,一项任务仍会 run-to-completion。
因此 Worker 里写一个五秒同步循环,只会卡住这个 Worker,不会直接冻结主线程页面;但主线程若傻等 Worker 结果而不更新界面,用户仍会觉得“没反应”。常见模式是主线程显示加载状态,Worker 完成后 postMessage 结果,主线程在 onmessage 里渲染。
Worker 内抛出的未捕获错误,会通过 worker.onerror 报告给创建方;若消息本身无法被克隆或反序列化,还可能触发 messageerror。这两种错误都不会自动变成主线程里某个 Promise 的 rejection,除非你的封装层把它们转换过去。
worker.terminate() 会粗暴结束 Worker,正在进行的计算和未处理消息可能丢失。更稳妥的是在 Worker 内识别“停止”消息,清理资源后让脚本自然结束;页面卸载时,也应终止不再需要的 Worker,避免后台线程空转。
模块 Worker 与经典 Worker
现代项目更常用模块 Worker:
const worker = new Worker(new URL('./worker.ts', import.meta.url), {
type: 'module',
})它允许在 Worker 内使用 import,与 Vite、Webpack 等打包工具更好协作。经典 Worker 则依赖 importScripts 按顺序拉脚本,适合简单场景或旧环境。
无论哪种,Worker 脚本仍受同源等安全策略约束。路径写错、MIME 类型不是 text/javascript 或 application/javascript,常见表现是 Worker 创建失败或立即报错。开发环境里用 new URL(..., import.meta.url),能避免相对路径在打包后指向错误位置。
少量 Worker 池化,而不是无限新建
每个 Worker 是一个线程加一套运行时,内存与调度开销会累积。对大量短任务,反复 new Worker 往往比主线程直接算还慢。
更常见的模式是启动固定数量的 Worker,把任务队列分发给空闲实例:
const pool = Array.from({ length: 4 }, () => new Worker('./worker.js'))
let cursor = 0
function runTask(payload) {
const worker = pool[cursor]
cursor = (cursor + 1) % pool.length
return callWorker(worker, payload)
}任务是否适合池化,要看单次计算是否足够重、消息是否足够小。轻量任务频繁跨线程,通信成本可能盖过计算收益。
主线程编排,Worker 计算,结果再回来
一个完整流程通常长这样:
async function runAnalysis(file) {
setStatus('解析中…')
const buffer = await file.arrayBuffer()
const result = await callWorker(worker, { buffer }, [buffer])
renderChart(result)
setStatus('完成')
}主线程负责读文件、更新 UI、把 ArrayBuffer 转给 Worker;Worker 负责纯计算;主线程在 Promise resolve 后渲染。注意 await callWorker 等待的是消息往返,不是 Worker 里的 fetch 自动回到 DOM。
若 Worker 还要再请求网络,它可以在自己线程里 await fetch,完成后 postMessage 摘要。主线程不必为了 Worker 内部的网络等待而阻塞,但仍要在 onmessage 里处理最终结果。这和第十章的分工一致:await 暂停的是当前 async 函数,Worker 里的 await 暂停的是 Worker 脚本里的 async 函数,两边事件循环彼此独立。
图像缩略图是典型场景:主线程用 createImageBitmap(file) 得到位图,必要时 transferable 给 Worker 做压缩;Worker 返回较小的 ArrayBuffer 或元数据,主线程再创建 Blob URL 显示。任何一步试图在 Worker 里 document.querySelector,都会直接报错——这不是权限配置问题,而是 API 根本不存在。
什么时候不必上 Worker
不是所有卡顿都需要 Worker。若瓶颈是:
- 单次 DOM 读写过多,应减少布局抖动或批量更新;
- 网络慢,应优化接口、缓存或流式消费(第十一章);
- 主线程任务只是几十毫秒,跨线程克隆数据反而更慢;
那么继续用事件循环切片或优化算法可能更划算。Worker 适合“可明确隔离的 CPU 密集段 + 结果远小于原始数据 + 主线程需要保持交互”的组合。决策时量一下 Performance:长任务是否在主线程、消息大小是否可接受、Worker 启动与销毁频率是否过高。
从页面生命周期看,组件卸载时应 terminate() 或让 Worker 自行退出,避免路由切换后仍有后台线程处理已失效的任务。若 Worker 结果返回时组件已销毁,主线程的 onmessage 仍可能触发,需要在回调里检查挂载状态,这和第十章 async 函数恢复后检查 UI 是否仍需要结果,是同一类边界问题。
浏览器对同源 Worker 数量没有很小的人工上限,但操作系统线程和内存仍有限。移动端上滥用 Worker 可能反而增加发热与耗电。把 Worker 当作“重活外包”,而不是“把所有函数都扔进去”,更接近实际工程里的用法。上线前在目标设备上各测一次主线程与 Worker 两种实现,比背结论可靠。
容易踩的坑:Worker 不是万能性能按钮
第一种误解是“开了 Worker,页面就一定流畅”。若瓶颈在 DOM 更新、布局或主线程上的大量小任务,把计算挪走帮助有限。若每条消息都带几兆数据并频繁克隆,通信本身可能比在主线程算更慢。
第二种是“Worker 里可以偷偷改页面”。不能。所有 UI 变更仍须回到主线程。Worker 适合数据处理、解析、加密、图像像素运算等;OffscreenCanvas 等少数 API 允许在 Worker 里绘制,再交回主线程显示,但那是专门接口,不是通用 DOM。
第三种是把 Worker 当成 Service Worker。Service Worker 面向离线缓存、网络拦截与推送,生命周期和 API 都不同。Dedicated Worker 面向页面发起的计算任务,页面关闭后通常随之结束。
第四种是“Worker 里也能用 Vue / React 直接渲染组件”。组件库和 DOM 绑定在主线程;Worker 里最多做与 UI 无关的纯逻辑,或配合专门 API 处理离屏资源。
调试 Worker 时,Chrome DevTools 的 Sources 面板可以单独打开 Worker 上下文,Performance 也能录制 Worker 线程。若只看到主线程仍然很长,要确认计算是否真的在 Worker 内执行,还是消息往返之外仍有大量主线程 JSON 解析或 DOM 更新。性能优化要先定位瓶颈线程,再决定是否值得承担跨线程通信成本。
SharedArrayBuffer 与 Atomics 允许两个线程共享同一块内存,但需要页面满足跨源隔离等安全要求,使用场景也比消息传递窄得多。大多数业务页面仍以 postMessage 为主;不要把“多线程”默认理解成“共享变量随便改”。
下一步:代码文件之间如何建立依赖
Worker 脚本本身也是一个需要被加载、解析和执行的 JavaScript 文件。主应用里的 import 看起来只是换了个路径,背后却是另一套模块图:哪些依赖先求值、循环引用怎样处理、动态 import() 为什么返回 Promise。
下一章从图书馆借书的分工说起,拆开 ES 模块的加载与执行边界。