Worker 与消息:把工作移走,也把边界说清
Promise 让等待更容易表达,却不会把一个密集循环自动搬到另一个线程。若主线程忙着计算,页面仍可能无法响应。Dedicated Worker 提供独立执行环境,通过消息与页面协作;它没有页面的 document,不能直接改 DOM。
两个文件,观察消息往返
把下面文件放在同一目录,通过本地 HTTP 服务打开 HTML。不要直接双击以 file URL 测试多文件 Worker 加载。
// sum-worker.js
self.addEventListener('message', event => {
const { id, values } = event.data;
if (!Array.isArray(values) || !values.every(Number.isFinite)) {
self.postMessage({ id, error: '需要有限数字数组' });
return;
}
self.postMessage({ id, total: values.reduce((a, b) => a + b, 0) });
});<!doctype html>
<meta charset="utf-8">
<title>消息求和</title>
<output id="result">等待结果</output>
<script>
const result = document.querySelector('#result');
const worker = new Worker('./sum-worker.js');
worker.addEventListener('message', event => {
if (event.data.id !== 1) return;
result.textContent = event.data.error ?? `总和:${event.data.total}`;
worker.terminate();
});
worker.addEventListener('error', () => {
result.textContent = 'Worker 运行失败';
worker.terminate();
});
worker.postMessage({ id: 1, values: [4, 8, 12] });
</script>页面显示“总和:24”。这个计算很小,实际不值得专门创建 Worker;此处故意缩小计算量,让注意力放在消息契约。真实场景应比较启动和数据传输成本,别把 Worker 当免费加速按钮。
编号让主线程可以关联请求与结果。多个任务同时在途时,要保留对应关系,并决定取消、过期结果、业务错误和运行异常各自如何处理。
消息不是共享一个普通对象
默认消息使用结构化克隆。支持的值比 JSON 多,包括不少内建对象和循环引用;函数、DOM 节点等不能这样直接发送,发送不支持的数据可能抛出 DataCloneError。接收侧通常得到独立数据,修改它不会自动修改发送侧原对象。
可转移对象是另一种方式。以下片段假设已有 Worker 引用,仅展示转移步骤:
const buffer = new ArrayBuffer(8);
worker.postMessage({ buffer }, [buffer]);
console.log(buffer.byteLength); // 0,发送侧缓冲区已分离转移列表把所有权交给接收侧,发送方不能继续当原始缓冲区仍可用。ArrayBuffer 必须实际包含在消息数据里,仅放转移列表并不替你创建接收方能读取的字段。
SharedArrayBuffer 则是共享内存的特殊路线,需要满足浏览器隔离要求,并通过 Atomics 等处理并发;它与普通消息克隆不同。不要由此倒推“所有 Worker 对象都共享内存”。
用一次往返观察所有权交接
前面的 8 字节例子只观察发送端分离,还没看到接收方如何使用它。下面两个独立文件通过本地 HTTP 服务运行。这个实验修改三个数字,不是为了比较性能,而是为了追踪同一批字节由谁负责。
// transfer-worker.js
self.addEventListener('message', event => {
const { id, buffer } = event.data;
if (!(buffer instanceof ArrayBuffer)) {
self.postMessage({ id, error: '需要 ArrayBuffer' });
return;
}
const values = new Uint8Array(buffer);
for (let i = 0; i < values.length; i++) values[i] += 1;
self.postMessage({ id, buffer }, [buffer]);
// 这里 buffer 已分离,Worker 也不能继续当作可写数据使用。
});<!doctype html>
<meta charset="utf-8">
<title>缓冲区往返</title>
<pre id="result"></pre>
<script>
const result = document.querySelector('#result');
const source = new Uint8Array([10, 20, 30]);
const worker = new Worker('./transfer-worker.js');
worker.addEventListener('message', event => {
if (event.data.id !== 'batch-1') return;
if (event.data.error) result.textContent += event.data.error;
else {
const returned = new Uint8Array(event.data.buffer);
result.textContent += `返回数据:${[...returned]}\n`;
result.textContent += `旧视图长度仍为:${source.byteLength}\n`;
}
worker.terminate();
});
worker.addEventListener('error', () => {
result.textContent += 'Worker 失败';
worker.terminate();
});
worker.postMessage({ id: 'batch-1', buffer: source.buffer }, [source.buffer]);
result.textContent = `发送后旧视图长度:${source.byteLength}\n`;
</script>输出旧视图长度 0、返回数据 11,21,31、旧视图长度仍为 0。字节回到主线程,不等于原来的 source 对象“复活”;要使用返回消息里的新 buffer 创建视图。两次转移都使发送方失去原缓冲区访问权,所以不会发生主线程与 Worker 同时随意改同一普通 ArrayBuffer 的情况。
如果去掉转移列表,结构化克隆会复制可克隆数据,发送方通常仍保有自己的内容。是否转移应由调用契约决定:后面还需要原数据时,要么保留副本,要么等结果返回,不要为了少复制一次而破坏调用方持有的数据。
还要区分三种失败:发送不支持克隆的值时,postMessage 可能同步抛错;Worker 执行脚本异常会触发错误事件;业务输入不符合约定可以作为带 id 的错误消息返回。只有最后一种天然对应你的任务编号,不能把运行环境崩溃伪装成某一条正常响应。
长期运行的任务管理器还需在 Worker 终止时拒绝所有待完成任务,移除对应映射;否则 Promise 会永远等消息。每次收到结果后也应删除已完成的编号,不要让结果表成为另一种内存积压。
关闭与 Service Worker 的职责
主线程用 worker.terminate() 立即终止 Worker;Worker 内部可调用 self.close() 停止后续任务。立即终止不承诺执行 finally 清理,所以需要保存的重要结果应在有序协议中确认,不能只在终止时寄希望于最后一条消息。
Dedicated Worker 通常服务于创建它的页面任务。Service Worker 则可处在页面与网络之间,按作用域处理请求、缓存与离线能力;它有安装、激活等生命周期,需要安全上下文(本地开发的 localhost 有相应例外),浏览器可以在空闲时终止它。不要在其全局变量里存放唯一一份长期状态,也不要把注册完成误当作当前页面已经受控。它同样不能直接访问 DOM。
用两个版本观察 Service Worker 的交接
把下面两个文件放入一个独立的 sw-lab 目录,通过 localhost 的 HTTP 服务打开 sw-lab/index.html。不要直接双击文件,也不要把实验脚本放到现有网站根目录:本例默认只管理 sw.js 所在目录。保持这个页面打开,先观察 v1,再修改文件观察 v2。
// sw.js
const VERSION = 'v1';
self.addEventListener('install', event => {
console.log(VERSION, 'install');
event.waitUntil(Promise.resolve());
});
self.addEventListener('activate', event => {
console.log(VERSION, 'activate');
event.waitUntil(Promise.resolve());
});
self.addEventListener('message', event => {
if (event.data === 'activate-update') {
event.waitUntil(self.skipWaiting());
}
});
self.addEventListener('fetch', event => {
const requested = new URL(event.request.url);
const versionURL = new URL('version.txt', self.registration.scope);
if (requested.href === versionURL.href) {
event.respondWith(new Response(VERSION, {
headers: { 'Content-Type': 'text/plain; charset=utf-8' }
}));
}
});<!doctype html>
<meta charset="utf-8">
<title>观察 Service Worker 更新</title>
<button id="check" disabled>检查更新</button>
<button id="activate" disabled>启用等待版本</button>
<button id="read" disabled>读取版本</button>
<pre id="log"></pre>
<script type="module">
const output = document.querySelector('#log');
const log = text => { output.textContent += text + '\n'; };
const check = document.querySelector('#check');
const activate = document.querySelector('#activate');
const read = document.querySelector('#read');
function showController() {
log(`当前页面受控:${Boolean(navigator.serviceWorker.controller)}`);
}
try {
navigator.serviceWorker.addEventListener('controllerchange', showController);
showController();
const registration = await navigator.serviceWorker.register('./sw.js', {
updateViaCache: 'none'
});
log('注册完成:这不是页面受控的证明');
function describe() {
log(`安装中=${registration.installing?.state ?? '无'} 等待=${registration.waiting?.state ?? '无'} 活动=${registration.active?.state ?? '无'}`);
activate.disabled = !registration.waiting;
}
function watchInstalling() {
const worker = registration.installing;
worker?.addEventListener('statechange', describe);
describe();
}
registration.addEventListener('updatefound', watchInstalling);
watchInstalling();
check.disabled = false;
read.disabled = false;
check.addEventListener('click', async () => {
try { await registration.update(); describe(); }
catch (error) { log(`更新失败:${error.message}`); }
});
activate.addEventListener('click', () => {
registration.waiting?.postMessage('activate-update');
});
read.addEventListener('click', async () => {
try {
const response = await fetch('./version.txt', { cache: 'no-store' });
log(response.ok ? `响应版本:${await response.text()}` : `HTTP ${response.status}`);
} catch (error) { log(`读取失败:${error.message}`); }
});
} catch (error) { log(`注册失败:${error.message}`); }
</script>按下面的顺序实验,每一步只改变一件事:
- 首次注册。 等活动版本显示 activated。当前页最初仍可能显示未受控,因为它是在安装完成前加载的。此时请求 version.txt 通常得到静态服务器的 404;目录里不需要这个实际文件。安装状态推进较快,页面监听可能错过早期状态,不能要求日志一定列出所有中间值。
- 普通刷新一次。 已激活的 v1 接管这次正常导航,页面显示受控;点击读取,得到 v1。不要使用强制绕过缓存的刷新,也不要在开发者工具开启绕过 Service Worker。
- 只将 sw.js 中 v1 改成 v2,保存后检查更新。 新脚本内容发生变化才产生新版本。保持旧页面打开,新版本安装后进入 waiting,读取仍得到 v1。update 完成不代表新版本已经控制页面。
- 点击启用等待版本。 消息发给 waiting,而不是当前 controller。它调用 skipWaiting,请求提前进入激活流程;等待 controllerchange 后再读取,得到 v2。这个按钮演示主动交接,实际应用还需保证新 Worker 与旧页面代码兼容。
install 与 activate 中的 waitUntil 把异步准备工作交给浏览器跟踪;本例故意只传已兑现 Promise,以观察状态而不引入缓存策略。真实安装可以等待必需资源准备,失败会使该次安装失败,不能把“开始下载”当作准备完成。fetch 的 respondWith 则提供本次请求的响应,本例只接管一个准确 URL,其余请求继续正常网络流程。
激活与认领已有页面是两个问题。 首次安装时,本例没有调用 clients.claim,因此保留“安装前打开的页面尚未受控”这一步;若在 activate 中用 event.waitUntil(self.clients.claim()),可以在激活时认领作用域内符合条件的页面。它不跳过 waiting;skipWaiting 也不等同于让任意尚未受控的页面立即受控。
浏览器可以终止空闲 Service Worker,之后为事件重新启动;VERSION 是脚本中的常量,不是需要跨启动保存的业务状态。本例没有实现离线页面:离线响应一个虚拟文本,与缓存整份页面及其资源不同。实验后在开发者工具 Application 的 Service Workers 中只取消本实验目录的注册,并关闭实验标签页,避免残留版本影响下次观察。
跨窗口 postMessage 要核对收件人与来件
窗口通信的 API 也叫 postMessage,但发送目标是另一个窗口引用,不能照搬 Worker 的参数含义。下面是嵌入页面的接收逻辑示意;frame 必须是页面里已经取得的 iframe 元素。
function acceptMessages(frame, expectedOrigin, handle) {
const controller = new AbortController();
window.addEventListener('message', event => {
if (event.origin !== expectedOrigin) return;
if (event.source !== frame.contentWindow) return;
const data = event.data;
if (!data || data.type !== 'resize' ||
!Number.isFinite(data.height) || data.height < 0) return;
handle(Math.min(data.height, 2000));
}, { signal: controller.signal });
return () => controller.abort();
}发送时也使用准确 targetOrigin。只看 data.type 不验证来源,相当于谁喊口令都开门;只检查来源但不验证数据结构,也会让意外数据进入业务逻辑。窗口导航后来源可能改变,因此每条消息都要检查。
练习
为什么
Promise.resolve().then(() => 很长的循环)不能代替 Worker?转移 ArrayBuffer 后为什么发送方 byteLength 为零?窗口消息为什么还要检查 event.source?
Worker把buffer转回后,主线程最初的source视图为什么仍为零长度?应该读取哪一个对象?
给消息求和增加空数组和含字符串数组两次独立验证:保留不同 id,空数组应返回 0,字符串应返回业务错误。不要在第一条结果后 terminate;收到两条结果后才终止。另在 Service Worker 实验中先记录 waiting 时读取到的版本,再主动启用新版本比较。