前端5月27日 18:05
OffscreenCanvas 如何在 Web Worker 中进行渲染?OffscreenCanvas 提供了一个可以脱离屏幕渲染的 Canvas 对象,使得 Canvas 绘图操作能够在 Web Worker 线程中执行,将复杂的图形计算从主线程剥离,避免阻塞用户交互和页面渲染。这个 API 在处理大型动画、图像处理、3D 渲染等场景下能够带来显著的性能提升。
## 为什么需要 OffscreenCanvas
浏览器的主线程同时负责 JavaScript 执行、DOM 操作、样式计算、布局和绘制。当 Canvas 上执行复杂渲染时,计算任务会占用主线程时间片,导致页面卡顿、事件响应延迟。OffscreenCanvas 的核心思路是将 Canvas 渲染与 DOM 完全解耦:主线程只负责 DOM 更新,Worker 线程负责 Canvas 绘制,两者并发运行,互不阻塞。
具体来说,传统的 Canvas 渲染流水线中,JavaScript 绘制调用和浏览器合成帧是串行执行的;使用 OffscreenCanvas 后,Worker 中的绘制通过 `commit()` 直接将缓冲区提交给 Display Compositor,跳过了非合成器动画的冗长流水线,走最短渲染路径。
## 核心概念
### OffscreenCanvas 的两种创建方式
**方式一:从 DOM Canvas 转移控制权**
通过 `canvas.transferControlToOffscreen()` 将页面上已有的 `<canvas>` 元素的控制权转移为 OffscreenCanvas 对象,然后发送给 Worker。转移后,主线程不能再对该 Canvas 调用 `getContext()` 等绘制方法。
**方式二:在 Worker 中直接创建**
使用 `new OffscreenCanvas(width, height)` 在 Worker 中直接创建一个独立的 OffscreenCanvas,不与任何 DOM 元素关联。这种方式适用于不需要直接显示、只做离屏计算(如图像处理生成 ImageBitmap)的场景。
### 控制权转移的不可逆性
`transferControlToOffscreen()` 只能对一个 Canvas 元素调用一次。调用后,Canvas 的绘制控制权完全交给 OffscreenCanvas,主线程的 Canvas 上下文失效。如果需要恢复,只能销毁并重新创建 Canvas 元素。
### 支持的渲染上下文
OffscreenCanvas 支持以下上下文类型:
- `2d`:Canvas 2D 渲染上下文,支持大部分标准 2D API
- `webgl` / `webgl2`:WebGL 渲染上下文,支持 3D 渲染
- `bitmaprenderer`:ImageBitmap 渲染上下文,用于显示 ImageBitmap
需要注意,某些依赖 DOM 的 API 在 Worker 中不可用,如 `toDataURL()`、`toBlob()`。替代方案是使用 `transferToImageBitmap()` 生成 ImageBitmap,再传回主线程处理。
## 基本使用
### 主线程:转移 Canvas 到 Worker
```javascript
// 主线程
const canvas = document.getElementById('myCanvas');
// 将 Canvas 控制权转移为 OffscreenCanvas
const offscreen = canvas.transferControlToOffscreen();
// 创建 Worker
const worker = new Worker('canvas-worker.js');
// 通过 Transferable 传输,零拷贝
worker.postMessage({ canvas: offscreen }, [offscreen]);
```
`postMessage` 的第二个参数是 Transferable 列表。OffscreenCanvas 是 Transferable 对象,传输时不进行结构化克隆,而是直接转移所有权,性能开销极低。
### Worker 线程:接收并绘制
```javascript
// canvas-worker.js
let canvas, ctx;
self.onmessage = function(e) {
if (e.data.canvas) {
canvas = e.data.canvas;
ctx = canvas.getContext('2d');
render();
}
};
function render() {
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.fillStyle = '#4a90d9';
ctx.fillRect(50, 50, 100, 100);
requestAnimationFrame(render);
}
```
Worker 中的 `requestAnimationFrame` 与主线程的行为一致,会在每个渲染帧回调绘制函数。
## 三种渲染提交方式
OffscreenCanvas 有三种将绘制结果呈现到屏幕的方式,适用场景和性能特征各不相同。
### 方式一:自动提交(push 模式)
当 OffscreenCanvas 从 DOM Canvas 通过 `transferControlToOffscreen()` 创建时,Worker 中每帧绘制完毕后,浏览器会在下一个合成帧自动将内容推送到对应的 DOM Canvas 上显示。这是最简单的使用方式,上面的基本示例就是这种模式。
### 方式二:commit() 手动提交
对于 WebGL 上下文,可以调用 `gl.commit()` 手动将当前帧提交给 Display Compositor。这种方式走最短渲染路径,直接将缓冲区发送给合成器,性能最优。但 `commit()` 是同步调用,Worker 会阻塞直到帧显示完成。
```javascript
// webgl-worker.js
self.onmessage = function(e) {
const canvas = e.data.canvas;
const gl = canvas.getContext('webgl');
function render() {
gl.clearColor(0.0, 0.0, 0.0, 1.0);
gl.clear(gl.COLOR_BUFFER_BIT);
// ... 绘制操作
gl.commit(); // 手动提交帧
requestAnimationFrame(render);
}
render();
};
```
### 方式三:transferToImageBitmap() 零拷贝传输
调用 `offscreen.transferToImageBitmap()` 会将当前 OffscreenCanvas 的绘制内容生成一个 ImageBitmap 对象,同时清空原 Canvas 的缓冲区。ImageBitmap 是 Transferable 对象,可以零拷贝传回主线程,通过 ImageBitmapRenderingContext 显示。
```javascript
// Worker 中
const bitmap = offscreen.transferToImageBitmap();
self.postMessage({ type: 'frame', bitmap }, [bitmap]);
// 主线程中
const displayCanvas = document.getElementById('display');
const bitmapCtx = displayCanvas.getContext('bitmaprenderer');
worker.onmessage = function(e) {
if (e.data.type === 'frame') {
bitmapCtx.transferFromImageBitmap(e.data.bitmap);
}
};
```
这种方式的优势在于可以精确控制帧同步时机,确保 Canvas 内容与 DOM 更新同步。但 `transferToImageBitmap()` 调用后原 Canvas 缓冲区被清空,需要重新绘制才能继续使用。
### 三种方式对比
| 维度 | 自动提交 | commit() | transferToImageBitmap() |
|------|---------|----------|------------------------|
| 同步性 | 异步,与 DOM 更新不同步 | 同步阻塞 Worker | 同步,可精确控制时机 |
| 性能 | 较好 | 最优,最短渲染路径 | 好,零拷贝传输 |
| 实现复杂度 | 最低 | 中等 | 较高,需主线程配合 |
| 适用场景 | 大部分动画场景 | H5 游戏、高性能渲染 | 需要帧同步的场景 |
## 实际应用场景
### 复杂粒子动画
粒子动画需要每帧更新大量粒子的位置和绘制,计算密集。将粒子逻辑移到 Worker 后,主线程保持流畅响应。
```javascript
// 主线程
const canvas = document.getElementById('canvas');
const offscreen = canvas.transferControlToOffscreen();
const worker = new Worker('particle-worker.js');
worker.postMessage({
canvas: offscreen,
width: canvas.width,
height: canvas.height
}, [offscreen]);
// 窗口大小变化时通知 Worker
window.addEventListener('resize', () => {
worker.postMessage({
type: 'resize',
width: canvas.width,
height: canvas.height
});
});
```
```javascript
// particle-worker.js
let canvas, ctx, particles = [];
self.onmessage = function(e) {
if (e.data.canvas) {
canvas = e.data.canvas;
ctx = canvas.getContext('2d');
initParticles(e.data.width, e.data.height);
render();
}
if (e.data.type === 'resize') {
canvas.width = e.data.width;
canvas.height = e.data.height;
initParticles(e.data.width, e.data.height);
}
};
function initParticles(w, h) {
particles = [];
for (let i = 0; i < 2000; i++) {
particles.push({
x: Math.random() * w,
y: Math.random() * h,
vx: (Math.random() - 0.5) * 2,
vy: (Math.random() - 0.5) * 2,
size: Math.random() * 3 + 1
});
}
}
function render() {
ctx.clearRect(0, 0, canvas.width, canvas.height);
// 批量绘制:合并为一个路径,一次 fill
ctx.beginPath();
for (const p of particles) {
p.x += p.vx;
p.y += p.vy;
if (p.x < 0 || p.x > canvas.width) p.vx *= -1;
if (p.y < 0 || p.y > canvas.height) p.vy *= -1;
ctx.moveTo(p.x + p.size, p.y);
ctx.arc(p.x, p.y, p.size, 0, Math.PI * 2);
}
ctx.fillStyle = 'rgba(100, 150, 255, 0.7)';
ctx.fill();
requestAnimationFrame(render);
}
```
### 图像处理
图像的像素级操作(灰度化、滤镜、卷积等)是典型的计算密集型任务。在 Worker 中处理可以避免处理期间页面完全冻结。
```javascript
// 主线程
const canvas = document.getElementById('canvas');
const offscreen = canvas.transferControlToOffscreen();
const worker = new Worker('image-worker.js');
// 注意:ImageBitmap 是 Transferable,可以传给 Worker
async function processImage(imageUrl) {
const response = await fetch(imageUrl);
const blob = await response.blob();
const bitmap = await createImageBitmap(blob);
worker.postMessage({
canvas: offscreen,
bitmap: bitmap,
filter: 'grayscale'
}, [offscreen, bitmap]);
}
processImage('/path/to/image.jpg');
```
```javascript
// image-worker.js
self.onmessage = function(e) {
const canvas = e.data.canvas;
const ctx = canvas.getContext('2d');
const bitmap = e.data.bitmap;
canvas.width = bitmap.width;
canvas.height = bitmap.height;
ctx.drawImage(bitmap, 0, 0);
bitmap.close(); // 释放 ImageBitmap 资源
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
const data = imageData.data;
// 灰度化处理
for (let i = 0; i < data.length; i += 4) {
const avg = data[i] * 0.299 + data[i + 1] * 0.587 + data[i + 2] * 0.114;
data[i] = avg; // R
data[i + 1] = avg; // G
data[i + 2] = avg; // B
// data[i + 3] 保持不变(Alpha)
}
ctx.putImageData(imageData, 0, 0);
};
```
这里有一个关键细节:原始的 `Image` 对象不能通过 `postMessage` 传递给 Worker(它不是 Transferable 也不可结构化克隆)。正确做法是用 `createImageBitmap()` 将图片转为 ImageBitmap,它是 Transferable 对象,可以零拷贝传输。
### WebGL 3D 渲染
Three.js 等框架在渲染复杂 3D 场景时,可以将整个渲染循环放到 Worker 中,主线程只处理 UI 交互。
```javascript
// 主线程
const canvas = document.getElementById('glCanvas');
const offscreen = canvas.transferControlToOffscreen();
const worker = new Worker('webgl-worker.js');
worker.postMessage({ canvas: offscreen }, [offscreen]);
// 转发用户交互给 Worker
canvas.addEventListener('mousemove', (e) => {
const rect = canvas.getBoundingClientRect();
worker.postMessage({
type: 'mousemove',
x: e.clientX - rect.left,
y: e.clientY - rect.top
});
});
```
```javascript
// webgl-worker.js
let gl, canvas;
let mouseX = 0, mouseY = 0;
self.onmessage = function(e) {
if (e.data.canvas) {
canvas = e.data.canvas;
gl = canvas.getContext('webgl2');
initScene();
render();
}
if (e.data.type === 'mousemove') {
mouseX = e.data.x;
mouseY = e.data.y;
}
};
function initScene() {
// WebGL 初始化:编译着色器、创建缓冲区等
gl.clearColor(0.0, 0.0, 0.0, 1.0);
}
function render() {
gl.clear(gl.COLOR_BUFFER_BIT);
// ... 基于 mouseX/mouseY 更新相机或场景
requestAnimationFrame(render);
}
```
## 主线程与 Worker 的通信
OffscreenCanvas 本身解决了渲染问题,但交互事件(鼠标、键盘、触摸)仍然只能在主线程捕获。需要通过 `postMessage` 将事件数据传递给 Worker。
### 事件转发模式
```javascript
// 主线程:转发交互事件
canvas.addEventListener('click', (e) => {
worker.postMessage({
type: 'click',
x: e.clientX - canvas.getBoundingClientRect().left,
y: e.clientY - canvas.getBoundingClientRect().top
});
});
// Worker:响应交互
self.onmessage = function(e) {
if (e.data.type === 'click') {
handleClick(e.data.x, e.data.y);
}
};
```
### 双向通信:Worker 通知主线程
Worker 也可以向主线程发送消息,例如报告渲染状态、返回处理结果。
```javascript
// Worker
self.postMessage({ type: 'renderComplete', fps: currentFPS });
// 主线程
worker.onmessage = function(e) {
if (e.data.type === 'renderComplete') {
console.log('渲染完成,FPS:', e.data.fps);
}
};
```
## 注意事项与常见陷阱
### Canvas 控制权只能转移一次
```javascript
// 错误:对同一个 Canvas 多次调用
const offscreen1 = canvas.transferControlToOffscreen();
const offscreen2 = canvas.transferControlToOffscreen(); // 抛出 InvalidStateError
// 正确:只调用一次,将 OffscreenCanvas 发给一个 Worker
const offscreen = canvas.transferControlToOffscreen();
worker.postMessage({ canvas: offscreen }, [offscreen]);
```
### getContext 顺序不可逆
在主线程中,`transferControlToOffscreen()` 必须在 `getContext()` 之前调用。如果已经获取了上下文,再调用转移方法会抛出异常。
```javascript
// 错误:先获取上下文再转移
const ctx = canvas.getContext('2d');
const offscreen = canvas.transferControlToOffscreen(); // 抛出异常
// 正确:先转移再在 Worker 中获取上下文
const offscreen = canvas.transferControlToOffscreen();
worker.postMessage({ canvas: offscreen }, [offscreen]);
// Worker 中:ctx = canvas.getContext('2d')
```
### Worker 中不可用的 API
Worker 没有 DOM 环境,以下 Canvas 相关 API 不可用:
- `toDataURL()`:无法在 Worker 中序列化为 Data URL
- `toBlob()`:无法在 Worker 中生成 Blob
- `createImageBitmap(img)` 中传入 `HTMLImageElement`:Worker 中不存在 Image 元素
替代方案是使用 `transferToImageBitmap()` 获取 ImageBitmap,传回主线程后用 `canvas.toDataURL()` 处理。
### requestAnimationFrame 的行为差异
在 Worker 中,`requestAnimationFrame` 的回调时机由浏览器的渲染调度决定。当页面处于后台标签页时,回调频率会降低甚至暂停,这与主线程的 `requestAnimationFrame` 行为一致。如果需要后台持续渲染(如视频处理),应使用 `setTimeout` 或 `setInterval` 替代。
## 浏览器兼容性
截至当前,OffscreenCanvas 的浏览器支持情况:
| 浏览器 | 最低支持版本 |
|--------|-------------|
| Chrome | 69+ |
| Edge | 79+ |
| Firefox | 105+ |
| Safari | 16.4+ |
| Opera | 64+ |
全局兼容率约 95%,主流浏览器均已支持。Safari 16.4 最初仅支持 2D 上下文,WebGL 支持在后续版本补齐。对于需要兼容旧浏览器的项目,应做特性检测和降级:
```javascript
if (typeof OffscreenCanvas === 'function' &&
'transferControlToOffscreen' in HTMLCanvasElement.prototype) {
// 使用 OffscreenCanvas
const offscreen = canvas.transferControlToOffscreen();
worker.postMessage({ canvas: offscreen }, [offscreen]);
} else {
// 降级:在主线程渲染
renderOnMainThread(canvas);
}
```
## 性能优化策略
### 批量绘制减少调用次数
每次调用 `fill()`、`stroke()` 都会触发一次绘制指令提交。将多个图形合并到一个路径中,只调用一次 `fill()`,可以显著减少 GPU 指令开销。
```javascript
// 低效:每个粒子单独绘制
for (const p of particles) {
ctx.beginPath();
ctx.arc(p.x, p.y, p.size, 0, Math.PI * 2);
ctx.fill();
}
// 高效:合并为一个路径,一次 fill
ctx.beginPath();
for (const p of particles) {
ctx.moveTo(p.x + p.size, p.y);
ctx.arc(p.x, p.y, p.size, 0, Math.PI * 2);
}
ctx.fill();
```
### 使用 ImageBitmap 替代 Image 元素
`createImageBitmap()` 返回的 ImageBitmap 对象已解码就绪,绘制时无需再次解码,比 `drawImage(img, ...)` 更快。且 ImageBitmap 是 Transferable,可以零拷贝跨线程传输。
```javascript
const response = await fetch('texture.png');
const blob = await response.blob();
const bitmap = await createImageBitmap(blob);
// 在 Worker 中直接绘制,无需解码
ctx.drawImage(bitmap, 0, 0);
// 使用完毕后释放
bitmap.close();
```
### 控制渲染频率
并非所有场景都需要 60fps 渲染。对于不需要流畅动画的场景(如图表绘制),可以通过节流降低渲染频率,减少 CPU 和 GPU 开销。
```javascript
const TARGET_FPS = 30;
const FRAME_INTERVAL = 1000 / TARGET_FPS;
let lastRenderTime = 0;
function render(timestamp) {
if (timestamp - lastRenderTime >= FRAME_INTERVAL) {
// 执行渲染
ctx.clearRect(0, 0, canvas.width, canvas.height);
// ... 绘制逻辑
lastRenderTime = timestamp;
}
requestAnimationFrame(render);
}
```
### 及时释放资源
Worker 中的 Canvas 和 ImageBitmap 会占用 GPU 内存。不再使用时需要主动释放:
```javascript
// 释放 ImageBitmap
bitmap.close();
// Worker 终止时,浏览器会自动回收资源
// 但主动清理是好习惯
self.close();
```
## 何时使用 OffscreenCanvas
OffscreenCanvas 并非所有场景都适用。以下判断标准可以参考:
**适合使用的场景:**
- Canvas 动画帧率低于 30fps,且主线程同时需要处理用户交互
- 图像处理耗时超过 16ms(一帧的时间预算)
- 3D 渲染场景复杂,GPU 指令准备时间长
- 页面有多个 Canvas 需要并发渲染
**不需要使用的场景:**
- 简单的静态绘制或低频更新
- Canvas 操作本身很快(< 5ms),瓶颈不在这里
- 需要频繁调用 `toDataURL()` 等 Worker 不支持的 API
- 需要兼容不支持 OffscreenCanvas 的旧浏览器且降级成本太高
引入 OffscreenCanvas 会增加代码复杂度(Worker 通信、事件转发、调试困难),在性能瓶颈不在 Canvas 时不应盲目使用。标签
Web Worker
Web Worker 是 HTML5 提供的一种在后台线程中运行脚本的机制。它允许网页脚本在后台线程中运行,而不会阻塞主线程,从而提高网页的性能和响应能力。例如,当一个网页需要进行大量的计算(如复杂的数据加密、图像渲染中的复杂算法等),如果在主线程中进行,会导致页面冻结,用户无法进行其他操作,如滚动页面、点击按钮等。而使用 Web Worker,这些计算任务可以放在后台线程中执行,主线程依然可以响应用户的交互操作。

服务端5月27日 16:10
Service Worker 生命周期有哪些阶段,如何实现离线缓存?Service Worker 是浏览器在后台独立运行的脚本,充当页面与网络之间的代理。它最大的价值在于:即使页面关闭也能拦截请求、管理缓存,从而实现离线访问、推送通知和后台同步。本文从注册到激活,逐阶段讲清它的生命周期,并给出离线缓存的完整实现和五种缓存策略的适用场景。
## 注册 Service Worker
注册是生命周期的起点。在主线程中调用 `navigator.serviceWorker.register()`,浏览器会下载并解析 Service Worker 脚本:
```javascript
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('/service-worker.js', { scope: '/' })
.then(registration => {
console.log('注册成功,作用域:', registration.scope);
})
.catch(error => {
console.log('注册失败:', error);
});
}
```
`scope` 参数决定了 Service Worker 能拦截哪些页面请求,默认值是脚本所在目录。注册成功后,浏览器会在后台启动安装流程。
注意:Service Worker 必须在 HTTPS 环境下运行(localhost 除外),这是浏览器强制的安全要求。
## 生命周期的六个阶段
Service Worker 的生命周期独立于网页,从注册到废弃共经历六个状态:
1. **Parsed(已解析)**——浏览器下载并解析脚本,尚未安装
2. **Installing(安装中)**——执行 `install` 事件回调,通常用于预缓存资源
3. **Installed / Waiting(已安装,等待激活)**——安装成功,等待旧版本释放控制权
4. **Activating(激活中)**——执行 `activate` 事件回调,通常用于清理旧缓存
5. **Activated(已激活)**——完全就绪,可以拦截 fetch 请求
6. **Redundant(废弃)**——被新版本替换或安装失败,不再生效
### Install 阶段:预缓存关键资源
`install` 事件在注册后首次触发,且只触发一次。这是预缓存核心静态资源的最佳时机:
```javascript
const CACHE_NAME = 'app-cache-v1';
const PRECACHE_URLS = [
'/',
'/styles/main.css',
'/script/main.js',
'/images/logo.png'
];
self.addEventListener('install', event => {
event.waitUntil(
caches.open(CACHE_NAME)
.then(cache => cache.addAll(PRECACHE_URLS))
.then(() => self.skipWaiting())
);
});
```
`event.waitUntil()` 接收一个 Promise,浏览器会等到它 resolve 后才认为安装完成。如果 Promise reject,安装失败,Service Worker 进入 Redundant 状态。
`self.skipWaiting()` 的作用是跳过 Waiting 阶段,让新的 Service Worker 立即激活。这在需要快速上线的场景很有用,但要注意:如果旧页面还在运行,新旧缓存可能冲突。
### Activate 阶段:清理旧缓存
`activate` 事件在新 Service Worker 取得控制权后触发。主要用途是删除上一版遗留的缓存:
```javascript
self.addEventListener('activate', event => {
event.waitUntil(
caches.keys().then(cacheNames => {
return Promise.all(
cacheNames
.filter(name => name !== CACHE_NAME)
.map(name => {
console.log('删除旧缓存:', name);
return caches.delete(name);
})
);
}).then(() => self.clients.claim())
);
});
```
`self.clients.claim()` 让新的 Service Worker 立即控制所有页面,而不需要页面刷新。配合 `skipWaiting()` 使用可以实现"注册即生效"。
### Waiting 阶段的更新机制
当页面已经有一个活跃的 Service Worker 时,浏览器检测到脚本文件变化(逐字节比较)后会启动新的安装。但新版本安装成功后不会立即激活,而是进入 Waiting 状态,直到:
- 所有使用旧版本的页面标签页被关闭
- 或调用 `skipWaiting()` 强制跳过等待
这意味着:如果用户长时间不关闭标签页,新版本可能一直处于等待状态。实践中可以通过 `controllerchange` 事件提示用户刷新:
```javascript
navigator.serviceWorker.addEventListener('controllerchange', () => {
console.log('Service Worker 已更新,页面将刷新');
window.location.reload();
});
```
## Fetch 事件:拦截与缓存请求
Service Worker 激活后,所有匹配 scope 的网络请求都会触发 `fetch` 事件。最基础的拦截逻辑——缓存命中就返回,否则走网络并缓存响应:
```javascript
self.addEventListener('fetch', event => {
event.respondWith(
caches.match(event.request)
.then(cachedResponse => {
if (cachedResponse) {
return cachedResponse;
}
return fetch(event.request).then(networkResponse => {
if (!networkResponse || networkResponse.status !== 200 || networkResponse.type !== 'basic') {
return networkResponse;
}
const responseToCache = networkResponse.clone();
caches.open(CACHE_NAME).then(cache => {
cache.put(event.request, responseToCache);
});
return networkResponse;
});
})
);
});
```
`event.respondWith()` 必须在 `fetch` 事件中调用,它接收一个 Promise,浏览器会用 resolve 的 Response 替代原始网络响应。
注意 `response.clone()` 的使用:Response 对象是流式的,只能读取一次,必须克隆一份再存入缓存。
## 五种缓存策略及适用场景
不同的资源类型需要不同的缓存策略,以下是五种常用模式的代码和适用场景:
### Cache First(缓存优先)
优先读缓存,缓存未命中才走网络。适合不常变化的静态资源(字体、图片、CSS 框架):
```javascript
self.addEventListener('fetch', event => {
event.respondWith(
caches.match(event.request)
.then(response => response || fetch(event.request))
);
});
```
### Network First(网络优先)
优先走网络,网络失败再读缓存。适合需要最新数据但也要离线可用的页面(新闻列表、用户信息):
```javascript
self.addEventListener('fetch', event => {
event.respondWith(
fetch(event.request)
.then(response => {
const clone = response.clone();
caches.open(CACHE_NAME).then(cache => cache.put(event.request, clone));
return response;
})
.catch(() => caches.match(event.request))
);
});
```
### Stale While Revalidate(先用缓存,后台更新)
立即返回缓存(如果有的话),同时发起网络请求更新缓存。用户拿到的是可能过时的数据,但响应最快。适合对实时性要求不高但追求速度的场景(文章内容、配置信息):
```javascript
self.addEventListener('fetch', event => {
event.respondWith(
caches.match(event.request).then(cachedResponse => {
const fetchPromise = fetch(event.request).then(networkResponse => {
caches.open(CACHE_NAME).then(cache => {
cache.put(event.request, networkResponse.clone());
});
return networkResponse;
});
return cachedResponse || fetchPromise;
})
);
});
```
### Cache Only(仅缓存)
只从缓存读取,不发起网络请求。适合预缓存的离线页面(App Shell):
```javascript
self.addEventListener('fetch', event => {
event.respondWith(caches.match(event.request));
});
```
### Network Only(仅网络)
只走网络,不使用缓存。适合非 GET 请求或实时性要求极高的接口(支付、登录):
```javascript
self.addEventListener('fetch', event => {
event.respondWith(fetch(event.request));
});
```
## 推送通知
Service Worker 可以在页面关闭后接收推送消息,这是 PWA 的核心能力之一。
### 订阅推送
主线程中注册并订阅推送服务:
```javascript
navigator.serviceWorker.register('/service-worker.js').then(registration => {
return registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array('YOUR_PUBLIC_KEY')
});
}).then(subscription => {
// 将 subscription 发送到后端保存
console.log('推送订阅成功:', subscription);
});
```
`applicationServerKey` 是 VAPID 公钥,用于服务器向推送服务认证身份。
### 处理推送事件
Service Worker 中监听 `push` 事件并显示通知:
```javascript
self.addEventListener('push', event => {
const data = event.data ? event.data.json() : { title: '新消息', body: '' };
event.waitUntil(
self.registration.showNotification(data.title, {
body: data.body,
icon: '/images/icon.png',
badge: '/images/badge.png',
vibrate: [100, 50, 100]
})
);
});
self.addEventListener('notificationclick', event => {
event.notification.close();
event.waitUntil(clients.openWindow('/'));
});
```
## 后台同步
Background Sync API 让用户在网络恢复时自动重试失败的请求,即使页面已经关闭:
```javascript
// 主线程:注册同步任务
navigator.serviceWorker.ready.then(registration => {
registration.sync.register('sync-messages');
});
// service-worker.js:处理同步
self.addEventListener('sync', event => {
if (event.tag === 'sync-messages') {
event.waitUntil(syncMessages());
}
});
async function syncMessages() {
const messages = getPendingMessages();
await fetch('/api/sync-messages', {
method: 'POST',
body: JSON.stringify(messages)
});
}
```
## 调试与更新
Chrome DevTools 的 Application 面板可以查看 Service Worker 状态:打开 DevTools -> Application -> Service Workers,能看到当前注册的 Service Worker 及其状态,支持手动更新、注销和跳过等待。
手动触发更新检测:
```javascript
navigator.serviceWorker.ready.then(registration => {
registration.update();
});
```
浏览器也会定期检查 Service Worker 脚本更新(默认 24 小时),但实际项目中通常需要在代码变更后尽快让用户获取新版本。
## 实践要点
- **缓存版本管理**:每次发布修改 `CACHE_NAME` 的版本号,确保 `activate` 阶段能清理旧缓存
- **渐进增强**:所有功能都要做特性检测(`if ('serviceWorker' in navigator)`),不支持时优雅降级
- **响应克隆**:Response 是流式对象,缓存前必须 `clone()`,否则原始响应被消费后无法再读取
- **HTTPS 强制**:生产环境必须部署 HTTPS,开发时 localhost 可用
- **scope 限制**:Service Worker 只能拦截 scope 范围内的请求,默认是脚本所在目录及其子路径
- **更新策略**:合理选择 `skipWaiting` + `clients.claim` 的组合,避免新旧缓存冲突导致页面异常
服务端5月27日 15:59
SharedWorker 如何实现跨标签页通信?SharedWorker 是 Web Worker 的一种特殊形式,允许多个浏览器上下文(标签页、窗口、iframe)共享同一个 Worker 实例。与 Dedicated Worker 的一对一模型不同,SharedWorker 通过端口(MessagePort)机制实现一对多通信,是浏览器原生提供的跨标签页通信方案之一。
## SharedWorker 的通信机制
SharedWorker 的核心在于端口通信模型。每个页面连接到同一个 SharedWorker 时,Worker 内部通过 `onconnect` 事件获得一个独立的 `MessagePort`,页面和 Worker 之间通过这个端口双向收发消息。
需要特别注意的是,主线程必须显式调用 `port.start()` 才能激活端口的消息接收功能,这是初学者最容易遗漏的步骤。
```javascript
// 主线程
const worker = new SharedWorker('shared-worker.js');
worker.port.start(); // 必须调用,否则 onmessage 不会触发
worker.port.postMessage({ type: 'greeting', text: 'Hello' });
worker.port.onmessage = (event) => {
console.log('来自 Worker 的消息:', event.data);
};
```
Worker 端通过 `self.onconnect` 监听新连接,从事件中取出端口并管理:
```javascript
// shared-worker.js
const ports = [];
self.onconnect = (event) => {
const port = event.ports[0];
ports.push(port);
port.start();
port.onmessage = (e) => {
// 广播给所有其他连接
ports.forEach((p) => {
if (p !== port) {
p.postMessage(e.data);
}
});
};
};
```
## 实现跨标签页通信的完整方案
跨标签页通信的关键在于 Worker 端维护所有连接的端口列表,当某个端口收到消息时,将消息转发给其他所有端口。同时需要处理新连接加入时的状态初始化问题。
### 连接管理与消息广播
```javascript
// shared-worker.js
const connections = new Map();
let connectionId = 0;
self.onconnect = (event) => {
const port = event.ports[0];
const id = ++connectionId;
connections.set(id, port);
port.start();
// 通知新连接其 ID
port.postMessage({ type: 'connected', id });
// 通知其他连接有新成员加入
broadcast({ type: 'peer-joined', id }, id);
port.onmessage = (e) => {
const { type, data, target } = e.data;
if (type === 'broadcast') {
broadcast({ type: 'message', from: id, data }, id);
} else if (type === 'send-to' && target) {
// 定向发送给指定连接
const targetPort = connections.get(target);
if (targetPort) {
targetPort.postMessage({ type: 'private', from: id, data });
}
}
};
};
function broadcast(message, excludeId) {
connections.forEach((port, connId) => {
if (connId !== excludeId) {
port.postMessage(message);
}
});
}
```
### 主线程封装
主线程可以将 SharedWorker 的通信封装为更易用的接口:
```javascript
// cross-tab-channel.js
class CrossTabChannel {
constructor(workerUrl) {
this.worker = new SharedWorker(workerUrl);
this.port = this.worker.port;
this.listeners = new Map();
this.id = null;
this.port.start();
this.port.onmessage = (event) => {
const { type, id } = event.data;
if (type === 'connected') {
this.id = id;
return;
}
const handlers = this.listeners.get(type) || [];
handlers.forEach((handler) => handler(event.data));
};
}
on(type, handler) {
if (!this.listeners.has(type)) {
this.listeners.set(type, []);
}
this.listeners.get(type).push(handler);
}
send(data) {
this.port.postMessage({ type: 'broadcast', data });
}
sendTo(targetId, data) {
this.port.postMessage({ type: 'send-to', target: targetId, data });
}
}
// 使用
const channel = new CrossTabChannel('shared-worker.js');
channel.on('message', (data) => {
console.log(`来自标签页 ${data.from}:`, data.data);
});
channel.send('你好,其他标签页!');
```
## 典型应用场景
### 跨标签页状态同步
最常见的场景是多个标签页共享同一份状态。例如用户在某个标签页切换了主题,其他标签页立即响应:
```javascript
// shared-worker.js
let state = { theme: 'light', user: null };
self.onconnect = (event) => {
const port = event.ports[0];
port.start();
// 新连接立即获取当前状态
port.postMessage({ type: 'state-init', state });
port.onmessage = (e) => {
if (e.data.type === 'state-update') {
state = { ...state, ...e.data.payload };
broadcast({ type: 'state-changed', state }, port);
}
};
};
```
### WebSocket 连接共享
在一个标签页建立 WebSocket 连接,其他标签页通过 SharedWorker 复用同一条连接,减少服务器压力和网络开销:
```javascript
// shared-worker.js
const ports = [];
let ws = null;
self.onconnect = (event) => {
const port = event.ports[0];
ports.push(port);
port.start();
// 懒初始化 WebSocket
if (!ws) {
ws = new WebSocket('wss://example.com/realtime');
ws.onmessage = (msg) => {
const data = JSON.parse(msg.data);
ports.forEach((p) => p.postMessage({ type: 'ws-message', data }));
};
ws.onclose = () => {
ws = null; // 允许重连
};
}
port.onmessage = (e) => {
if (e.data.type === 'ws-send' && ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify(e.data.payload));
}
};
};
```
## 连接断开的检测
SharedWorker 没有内置的连接断开通知机制。`port.onclose` 事件在当前规范中并不可靠,标准做法是通过心跳检测来判断连接是否存活:
```javascript
// shared-worker.js
const connections = new Map();
const HEARTBEAT_INTERVAL = 5000;
const HEARTBEAT_TIMEOUT = 10000;
self.onconnect = (event) => {
const port = event.ports[0];
const id = Date.now() + Math.random();
let lastActive = Date.now();
connections.set(id, { port, lastActive });
port.start();
port.postMessage({ type: 'connected', id });
port.onmessage = (e) => {
lastActive = Date.now();
// 处理其他消息...
};
};
// 定期检查连接活性
setInterval(() => {
const now = Date.now();
connections.forEach((conn, id) => {
if (now - conn.lastActive > HEARTBEAT_TIMEOUT) {
connections.delete(id);
}
});
}, HEARTBEAT_INTERVAL);
```
主线程配合发送心跳:
```javascript
// 主线程
setInterval(() => {
worker.port.postMessage({ type: 'heartbeat' });
}, 5000);
```
## 浏览器兼容性
SharedWorker 的兼容性需要重点关注:
- Chrome、Firefox、Edge:完整支持
- Safari:从 Safari 16(2022 年)开始支持,更早版本不支持
- 移动端浏览器:支持有限,iOS Safari 16+ 支持,Android Chrome 支持
在生产环境中,如果需要兼容旧版 Safari 或移动端,应提供降级方案,比如回退到 BroadcastChannel API 或 localStorage + storage 事件。
## 与其他跨标签页通信方案的对比
| 方案 | 通信方向 | 数据类型 | 兼容性 | 适用场景 |
|------|---------|---------|--------|---------|
| SharedWorker | 双向 | 任意结构化克隆数据 | Chrome/Firefox/Edge/Safari 16+ | 需要共享逻辑和状态的场景 |
| BroadcastChannel | 单向广播 | 任意结构化克隆数据 | Chrome/Firefox/Edge/Safari 15.4+ | 简单的一对多通知 |
| localStorage + storage 事件 | 单向广播 | 仅字符串 | 所有浏览器 | 简单状态同步的降级方案 |
| postMessage(同源 iframe) | 双向 | 任意结构化克隆数据 | 所有浏览器 | iframe 间通信 |
BroadcastChannel 的 API 更简洁,适合纯广播场景;SharedWorker 更适合需要在 Worker 端维护状态或执行逻辑的场景(如 WebSocket 共享连接)。两者不是互斥的,可以根据需求选择。
## 常见陷阱
### 忘记调用 port.start()
这是最常见的错误。Dedicated Worker 不需要这一步,但 SharedWorker 的端口必须手动激活:
```javascript
// 错误:消息无法接收
const worker = new SharedWorker('worker.js');
worker.port.onmessage = handler; // 永远不会触发
// 正确:先启动端口
const worker = new SharedWorker('worker.js');
worker.port.start();
worker.port.onmessage = handler;
```
### SharedWorker 内部无法访问 DOM 和 localStorage
SharedWorker 运行在独立的 Worker 线程中,无法访问 `window`、`document`、`localStorage` 等 DOM API。如果需要持久化数据,只能通过 IndexedDB 或将数据回传给主线程由主线程写入 localStorage。
### 调试方法
SharedWorker 无法在普通开发者工具的 Sources 面板中直接看到。Chrome 中需要访问 `chrome://inspect/#workers`,在 Shared Workers 区域找到对应的 Worker 点击 inspect 打开独立的调试窗口。
### 同源限制
SharedWorker 严格受同源策略约束。只有协议、域名、端口完全相同的页面才能共享同一个 Worker 实例。不同子域之间也无法共享,除非通过 `document.domain` 设置(但该特性已被废弃)。服务端5月27日 14:02
Web Worker 有哪些限制?怎么解决?## 为什么 Worker 有这么多限制
Worker 的限制不是偷懒,是设计上的安全选择。浏览器最核心的约束是:**DOM 操作不是线程安全的**。两个线程同时改同一个 DOM 节点,后果不可预测。所以 Worker 干脆被隔离了——不能碰 DOM、不能碰大部分浏览器 API,只能通过 postMessage 通信。
理解了这个前提,限制就不是"不能做什么",而是"怎么绕过去"。
## 限制一:不能访问 DOM
这是最大的限制。Worker 里没有 `document`、没有 `window`、没有任何 DOM API。
```javascript
// ❌ Worker 里直接报错
document.getElementById('app');
window.innerWidth;
```
**解决方式**:计算在 Worker 里做,DOM 操作回主线程执行。
```javascript
// Worker:只算数据
self.onmessage = (e) => {
const positions = calculateLayout(e.data.items);
self.postMessage({ positions });
};
// 主线程:拿到结果后操作 DOM
worker.onmessage = (e) => {
const { positions } = e.data;
positions.forEach(({ id, x, y }) => {
document.getElementById(id).style.transform = `translate(${x}px, ${y}px)`;
});
};
```
这个模式有个名字叫"数据驱动渲染"——Worker 产出数据,主线程负责映射到 DOM。虚拟 DOM 框架(React/Vue)天然适合这种模式:Worker 里做 diff 计算,把最小更新集传给主线程 apply。
如果需要频繁操作 Canvas,用 `OffscreenCanvas` 把 Canvas 上下文转移给 Worker:
```javascript
const canvas = document.getElementById('canvas');
const offscreen = canvas.transferControlToOffscreen();
worker.postMessage({ canvas: offscreen }, [offscreen]);
// Worker 里直接绘制
self.onmessage = (e) => {
const ctx = e.data.canvas.getContext('2d');
ctx.fillStyle = 'red';
ctx.fillRect(0, 0, 100, 100);
};
```
## 限制二:不能用 localStorage
localStorage 是同步 API,多线程同时读写会产生竞态条件,所以 Worker 被禁止访问。
**解决方式**:用 IndexedDB 替代。IndexedDB 是异步的,Worker 可以直接使用。
```javascript
// Worker 里直接操作 IndexedDB
const request = indexedDB.open('myDB', 1);
request.onupgradeneeded = (e) => {
e.target.result.createObjectStore('data', { keyPath: 'id' });
};
request.onsuccess = (e) => {
const db = e.target.result;
const tx = db.transaction('data', 'readwrite');
tx.objectStore('data').put({ id: 1, value: 'from worker' });
};
```
如果你非要从 Worker 里读写 localStorage 的数据,让主线程做中转:
```javascript
// Worker 请求读取
self.postMessage({ type: 'getLocalStorage', key: 'token' });
// 主线程中转
worker.onmessage = (e) => {
if (e.data.type === 'getLocalStorage') {
const value = localStorage.getItem(e.data.key);
worker.postMessage({ type: 'localStorageResult', key: e.data.key, value });
}
};
```
但这样每读一次都要跨线程通信,性能很差。能用 IndexedDB 就用 IndexedDB。
## 限制三:不能发起 XHR 请求
XMLHttpRequest 的同步模式(`open(method, url, false)`)会阻塞线程,在 Worker 里被禁止。但异步 XHR 其实也不推荐——用 fetch 替代。
**解决方式**:Worker 里用 fetch,它是异步的且完全支持。
```javascript
// Worker 里直接发请求
self.onmessage = async (e) => {
const response = await fetch('https://api.example.com/data');
const data = await response.json();
self.postMessage({ data });
};
```
WebSocket 和 EventSource 也能在 Worker 里正常使用,不受限制。
## 限制四:不能加载跨域脚本
Worker 脚本必须和主页面同源。跨域 URL 直接创建会报 `SecurityError`。
**解决方式 1**:Blob URL 内联。
```javascript
// 先 fetch 跨域脚本内容,再创建 Blob Worker
const response = await fetch('https://cdn.example.com/worker.js');
const code = await response.text();
const blob = new Blob([code], { type: 'text/javascript' });
const worker = new Worker(URL.createObjectURL(blob));
```
注意:这绕过了同源限制但引入了新风险——你加载的跨域代码可能被篡改。确保 CDN 可信,最好配上 SRI(Subresource Integrity)。
**解决方式 2**:`importScripts` 可以加载跨域脚本(Worker 内部)。
```javascript
// worker.js
importScripts('https://cdn.example.com/lib.js');
```
`importScripts` 不受同源限制,但受 CSP 的 `script-src` 约束。
## 限制五:没有 window 对象
Worker 的全局对象是 `self`(`DedicatedWorkerGlobalScope`),不是 `window`。很多挂在 `window` 上的东西在 Worker 里不存在。
| 主线程有 | Worker 里 | 替代方案 |
|----------|-----------|----------|
| `window` | `self` | 直接用 `self` |
| `window.location` | `self.location`(只读) | 能读不能改 |
| `window.navigator` | `self.navigator` | 大部分属性可用 |
| `window.alert()` | 不存在 | 用 postMessage 通知主线程 |
| `window.setTimeout` | `self.setTimeout` | 正常可用 |
| `window.fetch` | `self.fetch` | 正常可用 |
| `window.indexedDB` | `self.indexedDB` | 正常可用 |
## 限制六:通信有序列化开销
postMessage 默认用结构化克隆,数据要拷贝一份。小数据无所谓,大数据(几 MB 以上)拷贝开销可能比计算本身还大。
**解决方式**:
| 方案 | 适用场景 | 原理 |
|------|----------|------|
| Transferable | 大 ArrayBuffer/Blob 单向传输 | 所有权转移,零拷贝 |
| SharedArrayBuffer | 高频双向读写同一块数据 | 共享内存,Atomics 同步 |
| 批量发送 | 大量小消息 | 攒批发,减少序列化次数 |
详见 [Web Worker 通信全解析](/topic/446298779224)。
## 限制七:脚本路径是相对 HTML 的
```javascript
// 如果 HTML 在 /pages/index.html
// Worker 脚本在 /workers/task.js
new Worker('task.js'); // ❌ 会找 /pages/task.js
new Worker('/workers/task.js'); // ✅ 绝对路径
```
在打包工具里更容易搞错。Vite/Webpack 5 的正确写法:
```javascript
const worker = new Worker(
new URL('./worker.js', import.meta.url),
{ type: 'module' }
);
```
`import.meta.url` 是当前模块的 URL,`new URL` 相对于它解析,打包工具会正确处理路径。
## 总结:一张表搞定
| 限制 | 解决方案 |
|------|----------|
| 不能访问 DOM | Worker 算数据,主线程操作 DOM;用 OffscreenCanvas |
| 不能用 localStorage | 用 IndexedDB 替代 |
| 不能用同步 XHR | 用 fetch 替代 |
| 不能加载跨域脚本 | Blob URL 或 importScripts |
| 没有 window 对象 | 用 self 替代 |
| 通信有序列化开销 | Transferable / SharedArrayBuffer / 批量发送 |
| 脚本路径问题 | `new URL('./worker.js', import.meta.url)` |
这些限制的本质就是一条:**Worker 是数据处理器,不是 UI 控制器**。把计算放进去,把渲染留在外面,架构对了限制就不是问题。
服务端5月27日 14:02
Web Worker 怎么调试?## Worker 调试为什么难
Worker 跑在独立线程里,`console.log` 能用但输出混在主线程日志里不好找,断点默认不生效,报错了堆栈和主线程是断开的。但只要知道工具在哪,调试 Worker 并不比调主线程难多少。
## Chrome DevTools:最常用的方式
### 找到 Worker 线程
打开 DevTools → Sources 面板 → 左侧 Threads 区域。主线程和 Worker 线程会分开列出,点击 Worker 线程就能看到它的源码、设断点、看调用栈。
如果 Threads 区域没出现 Worker,检查两个地方:
1. DevTools 设置(F1)→ 勾选"Workers"下的"Auto-expand"
2. 确认 Worker 已经被创建——在 Console 里输入 `chrome && chrome.debugger` 确认
### 在 Worker 里打断点
和主线程一样:Sources 面板里打开 Worker 的 JS 文件,点行号设断点。Worker 里代码执行到断点会暂停,主线程不受影响(但 postMessage 会排队等 Worker 恢复)。
### 专用 Worker 的 Console
Worker 里的 `console.log` 会输出到 DevTools Console,但前面没有线程标识,容易和主线程日志混淆。建议在 Worker 里加前缀:
```javascript
// worker.js
function log(...args) {
console.log('[Worker]', ...args);
}
log('开始处理数据', data.length);
```
### Shared Worker 和 Service Worker 的调试入口
这两种 Worker 不在页面的 DevTools 里直接显示,需要单独打开:
- **Shared Worker**:访问 `chrome://inspect/#workers`,能看到所有 Shared Worker 实例,点击 inspect 打开独立 DevTools 窗口
- **Service Worker**:DevTools → Application 面板 → Service Workers 区域,可以查看注册状态、手动触发 update、模拟推送事件
## console 之外的调试手段
### 结构化日志
比加前缀更进一步,用结构化日志让 Worker 的输出可追溯:
```javascript
// worker.js
function log(level, event, data = {}) {
console.log(JSON.stringify({
source: 'worker',
level,
event,
timestamp: Date.now(),
...data
}));
}
log('info', 'task-start', { taskId: 1, dataSize: 10000 });
log('error', 'task-failed', { taskId: 1, error: err.message });
```
这样日志可以统一采集和分析,线上排查问题时不用对着混在一起的 Console 猜哪条是 Worker 输出的。
### 消息日志:窥探通信内容
Worker 的 bug 经常出在通信环节——发了消息但格式不对,或者该回消息的没回。写一个消息拦截器记录所有 postMessage:
```javascript
// 主线程:拦截 Worker 通信
function createDebugWorker(url) {
const worker = new Worker(url);
const originalPostMessage = worker.postMessage.bind(worker);
worker.postMessage = (data, transfer) => {
console.log('[Main → Worker]', JSON.stringify(data).slice(0, 200));
originalPostMessage(data, transfer);
};
worker.onmessage = (e) => {
console.log('[Worker → Main]', JSON.stringify(e.data).slice(0, 200));
};
return worker;
}
const worker = createDebugWorker('worker.js');
```
Worker 端也加一层:
```javascript
// worker.js
const originalPostMessage = self.postMessage.bind(self);
self.postMessage = (data, transfer) => {
console.log('[Worker → Main]', JSON.stringify(data).slice(0, 200));
originalPostMessage(data, transfer);
};
```
这样每次通信都有日志,消息丢了、格式错了一目了然。上线前记得删掉或用环境变量控制开关。
### Performance 面板分析 Worker 性能
DevTools Performance 面板会录制所有线程的活动。录制一段操作后,在时间轴上能看到:
- Main 线程的活动(紫色是渲染,黄色是脚本)
- Worker 线程的活动(独立一行,黄色标记脚本执行)
- postMessage 的发送和接收时间点
如果发现 Worker 任务执行时间过长,点击对应的黄色条块能看到函数调用栈和耗时分布,精确定位热点函数。
## 常见调试场景
### Worker 没有响应
排查步骤:
1. 确认 Worker 创建成功——`worker.onerror` 有没有触发
2. 确认消息发出去了——用消息拦截器看 `[Main → Worker]` 日志
3. 确认 Worker 收到了消息——在 Worker 入口加 `log('received', e.data)`
4. 确认 Worker 没有卡在死循环——Performance 面板看 Worker 线程是否一直在执行
5. 确认 Worker 没有报错——检查 Console 是否有未捕获异常
最常见的两个原因:Worker 脚本路径错了(创建时就失败了,但 onerror 没监听),或者消息格式不匹配(Worker 里 `e.data.type` 判断分支没命中)。
### 内存泄漏
Worker 长时间运行后内存持续上涨:
1. DevTools → Memory 面板 → 选择 Worker 线程 → 拍 Heap Snapshot
2. 对比两次 Snapshot,看哪些对象只增不减
3. 常见原因:闭包引用了大对象、事件监听器没移除、定时器没清除
```javascript
// Worker 里常见的泄漏模式
self.onmessage = (e) => {
const hugeData = e.data;
// 泄漏:闭包引用了 hugeData,永远不会被 GC
setInterval(() => {
console.log(hugeData.length); // hugeData 被闭包持有
}, 1000);
};
```
修复方式:用完即释放,或者定时器保存引用,不需要时 `clearInterval`。
### Shared Worker 连不上
SharedWorker 的调试入口在 `chrome://inspect/#workers`。常见问题:
- `port.start()` 忘了调用——消息收不到但不报错
- 连接 URL 必须完全一致(包括 query string)——两个页面用不同 URL 创建的 SharedWorker 是两个独立实例
- 同源策略——不同源的页面不能共享同一个 Worker
## 调试工具速查
| 工具 | 用途 | 入口 |
|------|------|------|
| DevTools Sources | 断点、单步、调用栈 | F12 → Sources → Threads |
| DevTools Console | Worker 日志 | F12 → Console |
| DevTools Performance | Worker 性能分析 | F12 → Performance |
| DevTools Memory | Worker 内存快照 | F12 → Memory → 选 Worker 线程 |
| chrome://inspect/#workers | Shared/Service Worker 调试 | 地址栏直接访问 |
| Application → Service Workers | Service Worker 状态管理 | F12 → Application |
## 上线前的调试清理
调试代码(日志拦截器、前缀 console、消息追踪)上线前必须清理或条件化。推荐用环境变量控制:
```javascript
const DEBUG = typeof self !== 'undefined' && self.location?.search?.includes('debug=1');
function log(...args) {
if (DEBUG) console.log('[Worker]', ...args);
}
```
这样开发时 URL 加 `?debug=1` 就能看到 Worker 日志,线上默认关闭不影响性能。
服务端5月27日 14:02
Web Worker 性能怎么优化?## 先搞清楚瓶颈在哪
Worker 性能优化不是玄学,瓶颈就三个地方:**创建开销**、**通信开销**、**计算开销**。先 Profiler 看哪个是瓶颈,再对症下药,别瞎优化。
## 创建开销:复用比重建快 100 倍
`new Worker()` 不是免费的。浏览器要分配线程、解析脚本、初始化上下文,一次创建大概 10-50ms。如果你每次任务都新建再 terminate,开销比任务本身还大。
### Worker 池
和数据库连接池一个道理——预先创建好,任务来了分配,做完了归还:
```javascript
class WorkerPool {
constructor(workerUrl, size = navigator.hardwareConcurrency || 4) {
this.workers = [];
this.queue = [];
for (let i = 0; i < size; i++) {
const worker = new Worker(workerUrl);
worker.busy = false;
worker.onmessage = (e) => {
const { resolve } = worker.task;
delete worker.task;
worker.busy = false;
this.processQueue();
resolve(e.data);
};
this.workers.push(worker);
}
}
exec(data) {
return new Promise((resolve) => {
const worker = this.workers.find(w => !w.busy);
if (worker) {
worker.busy = true;
worker.task = { resolve };
worker.postMessage(data);
} else {
this.queue.push({ data, resolve });
}
});
}
processQueue() {
if (this.queue.length === 0) return;
const worker = this.workers.find(w => !w.busy);
if (!worker) return;
const { data, resolve } = this.queue.shift();
worker.busy = true;
worker.task = { resolve };
worker.postMessage(data);
}
}
// 使用
const pool = new WorkerPool('worker.js', 4);
const result = await pool.exec({ type: 'sort', data: largeArray });
```
Worker 池适合任务频繁但单个任务不太大的场景。如果任务很少(比如页面生命周期内就跑一两次),直接 `new Worker()` 就行,别过度设计。
## 通信开销:序列化才是大头
Worker 通信的瓶颈不在网络,在序列化。`postMessage` 默认用结构化克隆,数据量大的时候拷贝耗时惊人。
### Transferable:零拷贝传大数据
```javascript
const buffer = new Float64Array(1_000_000);
// 慢:结构化克隆,拷贝 8MB 数据
worker.postMessage({ data: buffer });
// 快:转移所有权,零拷贝
worker.postMessage({ data: buffer }, [buffer.buffer]);
// 注意:转移后主线程不能再访问 buffer
```
实测数据:
| 数据大小 | 结构化克隆 | Transferable |
|----------|-----------|--------------|
| 100KB | ~0.5ms | ~0.05ms |
| 1MB | ~5ms | ~0.1ms |
| 10MB | ~50ms | ~0.2ms |
10MB 以上的数据,不用 Transferable 等于白用 Worker。
### SharedArrayBuffer:跳过序列化
Transferable 虽然零拷贝,但只能单向传——发过去主线程就没了。如果你需要双向频繁读写同一块数据,用 SharedArrayBuffer:
```javascript
const shared = new SharedArrayBuffer(1024 * 1024);
const view = new Float64Array(shared);
// 主线程和 Worker 共享同一块内存
worker.postMessage({ shared });
// Worker 里直接读写
self.onmessage = (e) => {
const view = new Float64Array(e.data.shared);
Atomics.store(view, 0, 42); // 原子写入
};
```
需要配合 `Atomics` 做原子操作,服务端还要配 COOP/COEP 头,门槛比 Transferable 高。但高频通信场景下收益巨大——完全没有序列化开销。
### 批量发送:减少通信次数
每秒 postMessage 100 次和 1 次发 100 条数据,后者快得多。序列化有固定开销(即使数据很小也要走一遍结构化克隆流程),减少次数比减少数据量更有效:
```javascript
// 慢:逐条发送
data.forEach(item => worker.postMessage(item));
// 快:攒批发送
worker.postMessage({ batch: data });
```
## 计算开销:用多 Worker 并行
单 Worker 的计算速度和主线程 JS 一样,只是不卡 UI。要真正加速,得把任务拆给多个 Worker 并行跑:
```javascript
function parallelSort(data, workerCount = 4) {
const chunkSize = Math.ceil(data.length / workerCount);
const chunks = [];
for (let i = 0; i < workerCount; i++) {
chunks.push(data.slice(i * chunkSize, (i + 1) * chunkSize));
}
return Promise.all(chunks.map((chunk, i) => {
return new Promise((resolve) => {
const worker = new Worker('sort-worker.js');
worker.onmessage = (e) => resolve(e.data);
worker.postMessage(chunk);
});
})).then(sortedChunks => {
// 合并已排序的分片
return mergeSortedArrays(sortedChunks);
});
}
```
实测 100 万元素数组排序:
| 方案 | 耗时 |
|------|------|
| 主线程单线程 | ~800ms(UI 卡死) |
| 单 Worker | ~800ms(UI 正常) |
| 4 Worker 并行 | ~250ms(UI 正常) |
Worker 数量不要超过 CPU 核心数,`navigator.hardwareConcurrency` 可以拿到。多了反而会因为线程调度开销变慢。
## 内存管理
Worker 占的内存不会自动释放,必须显式 `terminate()`。如果页面生命周期内不再需要某个 Worker,立刻关掉:
```javascript
// 任务完成后关闭
worker.onmessage = (e) => {
handleResult(e.data);
worker.terminate(); // 释放线程和内存
};
// 或者超时强制关闭
const timeout = setTimeout(() => worker.terminate(), 30000);
worker.onmessage = (e) => {
clearTimeout(timeout);
handleResult(e.data);
};
```
长时间运行的 Worker 要注意内存泄漏——Worker 里的闭包、事件监听器、定时器如果不用了不清理,内存会持续上涨。在 Worker 里加个定期自检:
```javascript
setInterval(() => {
const used = performance.memory?.usedJSHeapSize;
if (used && used > 50 * 1024 * 1024) { // 超过 50MB
self.postMessage({ type: 'memory-warning', used });
}
}, 10000);
```
## 懒加载:按需创建 Worker
不是所有 Worker 都要在页面加载时就创建。用 `new URL()` + 动态 import 实现按需加载,首屏不需要的 Worker 等用到时再创建:
```javascript
async function getWorker() {
if (!workerInstance) {
workerInstance = new Worker(
new URL('./heavy-worker.js', import.meta.url),
{ type: 'module' }
);
}
return workerInstance;
}
// 用户点击"导出"按钮时才创建
button.onclick = async () => {
const worker = await getWorker();
worker.postMessage(exportData);
};
```
## 优化优先级
按收益从大到小排:
1. **Transferable 替代结构化克隆**(大数据场景立竿见影)
2. **Worker 池复用**(频繁创建销毁场景收益大)
3. **批量发送减少通信次数**(高频小消息场景)
4. **多 Worker 并行**(计算密集型场景)
5. **SharedArrayBuffer**(超高频双向通信场景,门槛高但收益最大)
6. **懒加载**(首屏性能敏感场景)
服务端5月27日 14:02
Web Worker 有哪些安全风险?## Worker 不是法外之地
很多人以为 Worker 跑在独立线程里,安全性就天然有保障。恰恰相反——Worker 引入了新的攻击面:跨域脚本加载、postMessage 注入、SharedArrayBuffer 竞态,每一个都可能被利用。本文把 Web Worker 相关的安全问题和防御手段讲清楚。
## 同源策略:第一道防线
Worker 脚本必须和主页面同源(协议 + 域名 + 端口一致)。这是浏览器强制的,不是建议。
```javascript
// 跨域加载 → 直接报错
new Worker('https://evil.com/worker.js'); // SecurityError
// 同源加载 → 正常
new Worker('/workers/task.js');
```
但同源策略有绕过方式,而这些绕过方式本身就是安全隐患。
### Blob URL 的风险
用 Blob URL 可以绕过同源限制,创建内联 Worker:
```javascript
// 从任意字符串创建 Worker
const code = 'self.onmessage = (e) => { /* ... */ }';
const blob = new Blob([code], { type: 'text/javascript' });
new Worker(URL.createObjectURL(blob));
```
问题在于:如果 `code` 的内容来自用户输入或外部 API,攻击者就能注入任意代码在 Worker 里执行。**永远不要用不受信任的数据构造 Worker 脚本**。
用完后必须 `URL.revokeObjectURL()` 释放,否则内存泄漏。
### importScripts 的跨域加载
Worker 内部可以用 `importScripts()` 加载外部脚本,这个方法**不受同源限制**:
```javascript
// worker.js
importScripts('https://cdn.example.com/lib.js'); // 允许跨域
```
这是个设计选择——Worker 需要加载工具库。但这也意味着如果 CDN 被入侵或者 DNS 被劫持,恶意脚本就跑进了你的 Worker。
防御方式:在服务端配置 `Content-Security-Policy` 的 `script-src` 指令,限制 `importScripts` 能加载哪些来源的脚本。
## CSP 对 Worker 的约束
Worker 有自己的执行上下文,CSP 的约束方式和主页面不同:
- **同源 Worker 脚本**(通过 URL 加载):不受创建它的页面的 CSP 限制
- **Blob/data URL Worker**:继承创建它的页面的 CSP 策略
- **Worker 内的 importScripts**:受 Worker 自身的 CSP 约束(如果有)
这意味着如果你想限制 Worker 的行为,需要给 Worker 脚本的 HTTP 响应也加上 CSP 头:
```
Content-Security-Policy: script-src 'self' cdn.example.com
```
## postMessage 通信安全
postMessage 是 Worker 和主线程唯一的通信通道,也是 XSS 注入的潜在入口。
### 验证消息来源
主线程收到的消息不一定来自你的 Worker。特别是 SharedWorker 和 Service Worker 场景下,多个页面都能发消息:
```javascript
// 主线程:验证消息来源和格式
worker.onmessage = (e) => {
const data = e.data;
// 类型校验
if (typeof data !== 'object' || data === null) return;
if (typeof data.type !== 'string') return;
// 只处理已知的消息类型
const allowedTypes = ['result', 'progress', 'error'];
if (!allowedTypes.includes(data.type)) return;
// 处理消息
handleMessage(data);
};
// Worker 端同理:验证主线程发来的数据
self.onmessage = (e) => {
const data = e.data;
if (!data || typeof data.type !== 'string') return;
// ...
};
```
### 不要直接执行消息里的代码
```javascript
// 危险!永远不要这么做
self.onmessage = (e) => {
eval(e.data.code); // 任意代码执行
new Function(e.data.fn)(); // 同样危险
};
```
看似明显,但在模板引擎或动态逻辑场景里容易踩进去。如果必须根据消息执行不同逻辑,用白名单映射:
```javascript
const handlers = {
sort: (data) => { /* ... */ },
filter: (data) => { /* ... */ },
};
self.onmessage = (e) => {
const handler = handlers[e.data.type];
if (handler) handler(e.data.params);
};
```
## SharedArrayBuffer 的安全门槛
SharedArrayBuffer 允许主线程和 Worker 共享同一块内存,没有序列化开销。但它也带来了竞态条件风险——两个线程同时写同一个内存位置,数据就乱了。
浏览器对 SharedArrayBuffer 有严格的安全要求,服务端必须返回以下两个响应头,否则 `new SharedArrayBuffer()` 直接抛错:
```
Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: require-corp
```
这两个头不是"建议加",而是**强制要求**。原因是为了防止 Spectre 类的侧信道攻击——没有这些头,恶意页面可以通过 SharedArrayBuffer 读取跨域内存数据。
如果加上 COEP 后你的页面加载第三方资源(图片、脚本)出错了,需要给这些资源的响应加上 `Cross-Origin-Resource-Policy: cross-origin` 头。
## Worker 里能访问什么、不能访问什么
从安全角度看,Worker 的 API 限制本身就是一种防护:
| 能访问 | 不能访问 | 安全意义 |
|--------|----------|----------|
| fetch、WebSocket | document、DOM | 不能直接篡改页面 |
| IndexedDB | localStorage | 避免同步 I/O 竞态 |
| Cache API | window、parent | 隔离全局作用域 |
| Notifications | XMLHttpRequest | 推荐用 fetch 替代 |
| performance | location(只读) | 不能跳转页面 |
这些限制意味着即使 Worker 代码被攻破,攻击者也无法直接操作 DOM 或窃取 localStorage 中的 token。Worker 的攻击半径被刻意缩小了。
## 实际攻击场景
**场景 1:CDN 供应链攻击**。你的 Worker 用 `importScripts('https://cdn.example.com/lib.js')`,CDN 被入侵后恶意代码跑进了 Worker。防御:CSP 限制 script-src,或改用 npm 包 + 打包工具。
**场景 2:postMessage 中间人**。攻击者在页面注入脚本拦截 Worker 通信,篡改消息内容。防御:消息加签名校验,关键字段用加密传输。
**场景 3:Blob Worker 代码注入**。从服务端获取的配置数据直接拼进 Worker 代码字符串,攻击者通过配置接口注入恶意代码。防御:Worker 代码和数据严格分离,用 postMessage 传配置,不拼字符串。
## 安全检查清单
- Worker 脚本是否只从同源加载?如果是 Blob URL,代码来源是否可信?
- `importScripts` 加载的外部脚本是否有 CSP 保护?
- postMessage 通信是否做了类型校验和白名单过滤?
- 有没有用 `eval` 或 `new Function` 执行消息中的代码?
- SharedArrayBuffer 是否配了 COOP/COEP 响应头?
- Worker 脚本 MIME 类型是否为 `text/javascript`?
- Blob URL 用完后是否调用了 `revokeObjectURL`?
服务端5月27日 14:02
Web Worker 和主线程怎么通信?## 两种通信方式:拷贝和共享
Worker 和主线程之间不共享内存(SharedArrayBuffer 除外),数据必须"过桥"。过桥有两种方式:
**结构化克隆**(默认):数据完整拷贝一份,双方各持一份,互不影响。类似你复印一份文件给同事。
**Transferable 转移**:数据所有权直接移交,发送方丧失访问权。类似你把原件直接递给同事,自己手里没了。
```javascript
// 结构化克隆(默认)—— 数据拷贝
worker.postMessage({ data: largeArray });
// 主线程和 Worker 各有一份,largeArray 仍在
// Transferable 转移 —— 所有权移交
const buffer = new ArrayBuffer(1024 * 1024); // 1MB
worker.postMessage({ buffer }, [buffer]);
// buffer.byteLength === 0,主线程不能再用了
```
选哪种?小数据无所谓,大数据(超过 100KB 的 ArrayBuffer、Blob)用 Transferable,否则拷贝开销能吃掉你 Worker 带来的全部性能收益。
## 结构化克隆支持什么
`postMessage` 不是 JSON.stringify,它用的是浏览器内置的结构化克隆算法,能处理的东西比 JSON 多:
能传的:对象、数组、字符串、数字、布尔值、Date、RegExp、Blob、File、ArrayBuffer、TypedArray、Map、Set、ImageData、Error
不能传的:函数、DOM 节点、Symbol、有循环引用的对象(部分情况)
一个容易踩的坑:**对象的方法和原型链不会被克隆**。你传一个 class 实例过去,对面收到的是一个纯数据对象,方法全丢了。如果 Worker 需要调用方法,要么传纯数据重新构造,要么用 RPC 模式。
## 双向通信的实战写法
简单的 echo 通信谁都会写,但生产环境里你需要的是"请求-响应"模式——主线程发任务,Worker 算完回结果,最好还能 Promise 化。
```javascript
// 主线程:封装 RPC 风格的 Worker 通信
class WorkerRPC {
constructor(url) {
this.worker = new Worker(url);
this.id = 0;
this.pending = new Map();
this.worker.onmessage = (e) => {
const { id, result, error } = e.data;
const { resolve, reject } = this.pending.get(id);
this.pending.delete(id);
error ? reject(new Error(error)) : resolve(result);
};
}
call(method, params) {
return new Promise((resolve, reject) => {
const id = ++this.id;
this.pending.set(id, { resolve, reject });
this.worker.postMessage({ id, method, params });
});
}
}
// 使用
const rpc = new WorkerRPC('worker.js');
const sorted = await rpc.call('sort', { data: largeArray });
```
```javascript
// worker.js:处理 RPC 调用
const handlers = {
sort: ({ data }) => data.sort((a, b) => a - b),
filter: ({ data, condition }) => data.filter(condition),
};
self.onmessage = async (e) => {
const { id, method, params } = e.data;
try {
const result = await handlers[method](params);
self.postMessage({ id, result });
} catch (err) {
self.postMessage({ id, error: err.message });
}
};
```
这样主线程就可以 `await rpc.call('sort', data)` 了,比裸写 `postMessage` + `onmessage` 干净很多。
## SharedArrayBuffer:真正的共享内存
结构化克隆和 Transferable 本质上还是"传数据",有拷贝或转移开销。如果你要的是两个线程同时读写同一块内存,用 SharedArrayBuffer。
```javascript
// 主线程:创建共享内存
const shared = new SharedArrayBuffer(1024);
const view = new Int32Array(shared);
worker.postMessage({ shared });
// Worker:直接读写同一块内存
self.onmessage = (e) => {
const view = new Int32Array(e.data.shared);
// 用 Atomics 做原子操作,避免竞态
Atomics.add(view, 0, 1);
Atomics.store(view, 1, 42);
};
```
**关键点**:共享内存没有自动同步机制,必须用 `Atomics` API 做原子操作,否则两个线程同时写一个位置,数据就乱了。Atomics 提供了 `add`、`sub`、`compareExchange`、`wait`/`notify` 等操作,基本够用。
注意:SharedArrayBuffer 有安全限制,服务端必须返回 `Cross-Origin-Opener-Policy: same-origin` 和 `Cross-Origin-Embedder-Policy: require-corp` 两个响应头,否则浏览器直接拒绝。很多开发者在本地调试时发现能用,部署到生产环境就不行,就是这个头没配。
## 其他通信通道
除了 `postMessage`,还有几个不太常见但特定场景好用的通信方式:
**MessageChannel**:创建一对互相连接的端口,可以传给 Worker 作为私有通道。适合多个 Worker 之间直接通信,不经过主线程中转。
```javascript
const channel = new MessageChannel();
worker1.postMessage({ port: channel.port1 }, [channel.port1]);
worker2.postMessage({ port: channel.port2 }, [channel.port2]);
// 两个 Worker 现在可以直接通信了
```
**BroadcastChannel**:同源下所有标签页和 Worker 都能收发的广播通道。适合跨标签页同步状态。
```javascript
const bc = new BroadcastChannel('app-sync');
bc.postMessage({ type: 'data-updated', payload: newData });
bc.onmessage = (e) => { /* 收到其他页面的广播 */ };
```
## 通信性能的实际影响
很多人以为 Worker 通信开销可以忽略,实际上结构化克隆的耗时跟数据量正相关。实测数据:
| 数据量 | 结构化克隆耗时 | Transferable 耗时 |
|--------|---------------|-------------------|
| 10KB | ~0.1ms | ~0.05ms |
| 1MB | ~5ms | ~0.1ms |
| 10MB | ~50ms | ~0.2ms |
| 100MB | ~500ms | ~0.5ms |
数据量越大,结构化克隆越慢,Transferable 优势越明显。10MB 以上的数据,不用 Transferable 基本等于白用 Worker——拷贝时间比计算时间还长。
**实践建议**:如果 Worker 间通信频率高(每秒几十次以上),即使单次数据量小,也要考虑 SharedArrayBuffer + Atomics,省掉反复序列化的开销。
## 错误处理别忘了
Worker 内部抛出的异常不会冒泡到主线程,必须显式监听:
```javascript
worker.onerror = (e) => {
console.error('Worker 出错了:', e.message);
console.error('文件:', e.filename, '行号:', e.lineno);
// 可以选择重新创建 Worker
};
// Worker 内部也要处理异常
self.onmessage = (e) => {
try {
const result = riskyOperation(e.data);
self.postMessage({ id: e.data.id, result });
} catch (err) {
self.postMessage({ id: e.data.id, error: err.message });
}
};
```
生产环境里 Worker 挂了不重启,等于你的后台任务全停了。建议封装一个自动重启的 Worker 管理器:onerror 触发后 terminate 旧 Worker,new 一个新的,再把未完成的任务重放一遍。
服务端5月27日 14:00
Web Worker 有哪几种类型?Dedicated、Shared、Service 怎么选?## 三种 Worker,三种用途
浏览器里能叫"Worker"的有三种,干的事完全不一样:
| 类型 | 一句话定位 | 和页面关系 | 典型用途 |
|------|-----------|-----------|----------|
| Dedicated Worker | 后台计算线程 | 一对一,页面关了它就销毁 | 排序、解析、图像处理 |
| Shared Worker | 多页面共享的后台线程 | 多对一,所有同源页面共享 | 跨标签页状态同步 |
| Service Worker | 网络代理 + 离线缓存 | 独立生命周期,页面关了还活着 | PWA、离线、请求拦截 |
别搞混——Dedicated Worker 是拿来干活的,Shared Worker 是拿来共享的,Service Worker 是拿来代理网络的。
## Dedicated Worker:用得最多的那个
绝大多数时候你说的"Web Worker"就是它。一个页面创建,只有这个页面能用,页面关了 Worker 也跟着销毁。
```javascript
// 创建
const worker = new Worker('worker.js');
// 双向通信
worker.postMessage({ type: 'start', data: payload });
worker.onmessage = (e) => console.log('结果:', e.data);
// 关闭
worker.terminate();
```
也可以用 Blob URL 创建内联 Worker,不用单独的 JS 文件:
```javascript
const code = `
self.onmessage = (e) => {
const result = heavyCalc(e.data);
self.postMessage(result);
};
`;
const worker = new Worker(URL.createObjectURL(new Blob([code], { type: 'text/javascript' })));
```
Dedicated Worker 的生命周期很简单:创建 → 运行 → terminate 或页面关闭。没有什么"激活""等待"状态,不需要管理复杂状态机。
## Shared Worker:跨标签页的共享线程
多个同源标签页可以共用同一个 Shared Worker 实例。适合做跨页面状态同步——比如用户在标签页 A 加了购物车商品,标签页 B 实时看到数量更新。
```javascript
// 每个页面都这样创建,浏览器会复用同一个实例
const worker = new SharedWorker('shared-worker.js');
// 注意:SharedWorker 用 port 通信,不是直接 onmessage
worker.port.start();
worker.port.postMessage({ type: 'cart-update', item: 'iPhone 17' });
worker.port.onmessage = (e) => {
console.log('收到:', e.data);
};
```
Worker 端也不一样,用 `onconnect` 接收新连接:
```javascript
// shared-worker.js
const clients = [];
self.onconnect = (e) => {
const port = e.ports[0];
clients.push(port);
port.onmessage = (event) => {
// 广播给所有连接的页面
clients.forEach(client => {
client.postMessage(event.data);
});
};
};
```
**Shared Worker 的坑**:
- 调试困难——Chrome DevTools 里要单独打开 Shared Worker 的调试面板(`chrome://inspect/#workers`)
- 所有连接断开后 Worker 才会销毁,不是最后一个页面关了就立刻死
- `port.start()` 容易忘写,忘写了消息收不到但也不报错
## Service Worker:不是普通 Worker
Service Worker 是三种里最特殊的。它不是用来做计算的,而是浏览器的网络代理层:
- **拦截请求**:页面发出的 fetch 请求先经过 Service Worker,可以改写响应、返回缓存
- **离线支持**:把资源缓存下来,断网时也能访问
- **推送通知**:即使页面没打开,也能收到服务端推送
- **后台同步**:网络恢复时自动重试失败的请求
```javascript
// 注册
navigator.serviceWorker.register('/sw.js');
// sw.js
self.addEventListener('install', (event) => {
// 安装时预缓存资源
event.waitUntil(
caches.open('v1').then(cache => cache.addAll(['/index.html', '/app.js']))
);
});
self.addEventListener('fetch', (event) => {
// 拦截请求,先查缓存
event.respondWith(
caches.match(event.request).then(cached => cached || fetch(event.request))
);
});
```
Service Worker 的生命周期和其他两种完全不同:
```
安装(install) → 激活(activate) → 运行中
↑ ↓
等待(waiting) ← 更新发现
```
关键区别:Service Worker 在页面关闭后仍然存活,浏览器会在需要时唤醒它。这也是为什么它能处理推送通知和后台同步。
**Service Worker 不能做的事**:同步 XHR、访问 DOM、访问 localStorage。和 Dedicated Worker 一样受 API 限制,但更严格——连 `self.localStorage` 都没有,只能用 Cache API 和 IndexedDB。
## 怎么选
| 场景 | 选哪个 |
|------|--------|
| 页面内耗时计算(排序、解析) | Dedicated Worker |
| 多标签页共享状态 | Shared Worker |
| 离线缓存、请求拦截 | Service Worker |
| 推送通知 | Service Worker |
| 后台数据同步 | Service Worker |
| 图像/音视频处理 | Dedicated Worker |
一个常见错误:用 Shared Worker 做计算密集型任务。Shared Worker 的设计初衷是共享状态,不是共享算力。如果多个页面同时往一个 Shared Worker 发计算任务,它还是单线程处理,反而互相等待。
另一个常见错误:把 Service Worker 当普通 Worker 用。Service Worker 的生命周期管理复杂,它会在不可预期的时间被浏览器唤醒和终止。在它里面做长耗时计算是不靠谱的——可能算到一半就被杀了。
服务端5月27日 13:59
什么是 Web Worker?它如何解决页面卡顿问题?## JavaScript 的单线程困局
浏览器里,JS 和 UI 渲染共享同一个线程。这意味着一件事:JS 代码跑多久,页面就卡多久。当你排序 10 万条数据、解析 20MB 的 JSON、或者做复杂的图像运算时,用户看到的不是加载动画,而是冻住的页面——滚动没用,点击没用,连浏览器标签页都显示"无响应"。
Web Worker 就是冲着这个问题来的:给 JS 开一条独立线程,把耗时任务丢过去跑,主线程继续处理 UI。
## Worker 到底是什么
Worker 是浏览器提供的一个独立执行环境,和主线程平级运行。几个关键事实:
- **独立线程**:Worker 有自己的调用栈和事件循环,不会阻塞主线程
- **独立全局对象**:Worker 里没有 `window`,取而代之的是 `self`(`DedicatedWorkerGlobalScope`)
- **不能碰 DOM**:`document`、`element`、`localStorage` 一概不可用
- **只能用消息通信**:`postMessage` 发,`onmessage` 收,数据走结构化克隆
- **同源限制**:Worker 脚本必须和页面同源
## 怎么用
### 创建和通信
```javascript
// 主线程
const worker = new Worker('worker.js');
// 发数据给 Worker
worker.postMessage({ type: 'sort', data: largeArray });
// 接收 Worker 返回的结果
worker.onmessage = (e) => {
console.log('结果:', e.data.result);
};
// 出错处理
worker.onerror = (e) => {
console.error(`Worker 错误: ${e.message} (${e.filename}:${e.lineno})`);
};
// 不用了就关掉
worker.terminate();
```
```javascript
// worker.js
self.onmessage = (e) => {
const { type, data } = e.data;
if (type === 'sort') {
const result = data.sort((a, b) => a - b);
self.postMessage({ result });
}
};
```
### 内联 Worker:不想多一个文件
有时候 Worker 代码很短,单独建文件嫌麻烦。可以用 Blob URL 创建内联 Worker:
```javascript
const code = `
self.onmessage = (e) => {
const result = heavyCalc(e.data);
self.postMessage(result);
};
`;
const blob = new Blob([code], { type: 'application/javascript' });
const worker = new Worker(URL.createObjectURL(blob));
```
这在单文件组件或沙箱环境里特别好用。
### 多个 Worker 并行
一个 Worker 不够就开多个。浏览器对 Worker 数量没有硬限制,但每个 Worker 都占一个线程,开太多反而有调度开销。通常根据 CPU 核心数来定:
```javascript
const cores = navigator.hardwareConcurrency || 4;
const workers = Array.from({ length: cores }, () => new Worker('worker.js'));
// 把任务分片给多个 Worker
const chunkSize = Math.ceil(data.length / cores);
const results = await Promise.all(
workers.map((worker, i) => {
const chunk = data.slice(i * chunkSize, (i + 1) * chunkSize);
return new Promise((resolve) => {
worker.onmessage = (e) => resolve(e.data.result);
worker.postMessage({ data: chunk });
});
})
);
```
## 什么时候该用 Worker
不是所有耗时操作都需要 Worker。判断标准很简单:**会不会阻塞主线程超过 50ms?** 会就上 Worker,不会就不必。
**值得用 Worker 的场景**:
- 大数据排序、过滤、聚合(超过 1 万条记录的客户端处理)
- 文件解析(CSV、JSON、Excel)
- 图像处理(Canvas 像素操作、滤镜)
- 加密运算(RSA、AES 大数据量加密)
- 实时数据流处理(WebSocket 推送数据的聚合计算)
**不需要 Worker 的场景**:
- fetch 请求——本来就异步,不阻塞主线程
- 简单的 DOM 操作——Worker 做不了
- 定时器——setTimeout/setInterval 本身不阻塞
- 少量数据运算(几百条数据的遍历)
## Worker 的限制和绕过方式
| 限制 | 绕过方式 |
|------|----------|
| 不能访问 DOM | 把计算结果 postMessage 回主线程,主线程操作 DOM |
| 不能用 localStorage | 用 IndexedDB 替代,Worker 可以访问 |
| 不能用 XMLHttpRequest | 用 fetch 替代,Worker 支持 |
| 不能用 window 对象 | 用 self 替代全局对象 |
| 同源限制 | 用 Blob URL 创建内联 Worker |
| 通信有序列化开销 | 大数据用 Transferable 零拷贝,高频通信用 SharedArrayBuffer |
## Worker 的三种类型
**Dedicated Worker**:最常见的,和一个页面绑定,页面关了 Worker 也销毁。
**Shared Worker**:多个页面共享同一个 Worker 实例。适合多标签页同步状态的场景,比如购物车数量、未读消息数。创建方式不同:
```javascript
const worker = new SharedWorker('shared-worker.js');
worker.port.onmessage = (e) => { /* 收消息 */ };
worker.port.postMessage({ type: 'sync' });
```
**Service Worker**:本质是网络代理,拦截请求、管理缓存。PWA 的核心,和普通 Worker 用途完全不同,别混为一谈。
## 常见踩坑
**坑 1:频繁通信拖垮性能**。每秒 postMessage 几百次,序列化开销比计算本身还大。解决方案:批量发送,攒够一批再传;或者改用 SharedArrayBuffer 共享内存。
**坑 2:Worker 里抛的异常主线程收不到**。必须在主线程监听 `worker.onerror`,否则 Worker 静默挂掉你都不知道。
**坑 3:Transferable 传完后原数据变空**。`postMessage({ buffer }, [buffer])` 之后,主线程的 `buffer.byteLength` 变成 0。如果主线程还需要这个数据,先拷贝一份再传。
**坑 4:Worker 脚本路径是相对 HTML 的**,不是相对 JS 文件的。在打包工具(Webpack/Vite)里容易路径搞错,建议用 `new URL('./worker.js', import.meta.url)` 让打包工具正确处理。
```javascript
// Vite/Webpack 5 的正确写法
const worker = new Worker(
new URL('./worker.js', import.meta.url),
{ type: 'module' }
);
```
## 性能实测
在 Chrome 120 / M1 MacBook Pro 上,对 100 万元素数组做排序:
| 方案 | 耗时 | 主线程影响 |
|------|------|-----------|
| 主线程直接排序 | ~800ms | UI 完全卡死 |
| Worker 排序 | ~800ms | UI 正常响应 |
| 4 个 Worker 分片排序 | ~250ms | UI 正常响应 |
Worker 不加速计算,但释放主线程。多 Worker 并行才是真正的加速——代价是代码复杂度上去了,需要分片和合并结果。
服务端5月27日 12:27
Web Worker vs WebAssembly:线程和速度是两码事## 一句话搞清楚
Web Worker 解决的是"线程"问题——把 JavaScript 搬到后台跑,不卡 UI;WebAssembly 解决的是"速度"问题——让浏览器跑接近原生性能的代码。它俩不是竞争关系,更像是搭档:Worker 出线程,WASM 出算力,加在一起才是完整方案。
## 核心区别
| 维度 | Web Worker | WebAssembly |
|------|-----------|-------------|
| 解决什么问题 | JavaScript 单线程阻塞 | JavaScript 性能天花板 |
| 运行环境 | 独立线程,仍是 JS 引擎 | 沙箱虚拟机,跑二进制指令 |
| 语言 | 只能写 JavaScript | C/C++、Rust、Go 等编译而来 |
| 性能天花板 | 和主线程 JS 一样 | 接近原生(通常快 5-20 倍) |
| DOM 访问 | 不行,靠 postMessage 中转 | 不行,同样靠 JS 桥接 |
| 浏览器 API | fetch、IndexedDB、WebSocket 等 | 几乎没有,全靠 JS 胶水代码 |
| 通信成本 | 结构化克隆(深拷贝),可用 Transferable 零拷贝 | 调用 JS 函数,有上下文切换开销 |
| 适用场景 | I/O 密集、后台任务、并发处理 | 计算密集、图像/音视频/加密/物理引擎 |
简单说:**Worker 是多线程方案,WASM 是加速方案**。你选哪个,取决于瓶颈在哪——是"主线程太忙"还是"JS 跑得不够快"。
## 什么时候用 Web Worker
主线程被卡了,就用 Worker。最常见的信号:页面操作出现明显延迟,Chrome DevTools 的 Performance 面板里看到长任务(Long Tasks)超过 50ms。
典型场景:
**大列表排序/过滤**。前端拿到 10 万条数据做客户端筛选,主线程直接冻住。丢给 Worker 后,筛选完把结果 postMessage 回来,UI 全程流畅。
**文件处理**。用户上传 CSV/JSON 大文件,在 Worker 里解析、校验、转换格式,主线程只负责显示进度条。
**实时数据流**。WebSocket 推过来的行情数据,Worker 负责解包、聚合、计算指标,主线程只做渲染。
```javascript
// 主线程
const worker = new Worker('data-worker.js');
// 大数据丢给 Worker 处理,用 Transferable 避免拷贝开销
const buffer = new Float64Array(1_000_000);
worker.postMessage({ data: buffer }, [buffer.buffer]);
worker.onmessage = (e) => {
// 拿到处理结果,更新 UI
renderChart(e.data.result);
};
```
注意 Transferable Objects 的用法:`postMessage` 的第二个参数传 `[buffer.buffer]`,数据直接转移所有权而不是拷贝,大数据场景下差距巨大。
## 什么时候用 WebAssembly
JS 算不过来了,就用 WASM。典型信号:计算密集循环在 Profiler 里占了大量时间,而且算法本身已经是 O(n log n) 级别,没法再优化了。
典型场景:
**图像处理**。给图片加滤镜、裁剪、缩放,像素级操作在 JS 里慢得感人。用 Rust 或 C 写 WASM 模块,处理速度能提升 5-10 倍。
**加密/解密**。AES-256 加密 100MB 数据,JS 版本可能要好几秒,WASM 版本几百毫秒搞定。
**物理引擎/游戏**。碰撞检测、粒子系统,每帧都要大量浮点运算,WASM 是刚需。
**音视频编解码**。FFmpeg 编译成 WASM 在浏览器里跑,已经是很成熟的方案了。
```javascript
// 加载 WASM 模块
const response = await fetch('image-processor.wasm');
const { instance } = await WebAssembly.instantiateStreaming(response);
// 调用导出函数
const imageData = ctx.getImageData(0, 0, width, height);
const ptr = instance.exports.process(imageData.data, width, height);
```
WASM 最大的限制是它不能直接调浏览器 API。你需要写 JS 胶水代码(glue code)来桥接,比如 WASM 算完结果后通过共享内存传给 JS,JS 再操作 DOM 或 Canvas。
## 两者结合:Worker 里跑 WASM
真正高性能的 Web 应用,往往不是二选一,而是**把 WASM 塞进 Worker 里**——Worker 解决线程问题,WASM 解决速度问题,各司其职。
以浏览器端图像处理为例:
```javascript
// 主线程:只管 UI
const worker = new Worker('wasm-image-worker.js');
function processImage(file) {
const img = new Image();
img.onload = () => {
const canvas = document.createElement('canvas');
canvas.width = img.width;
canvas.height = img.height;
const ctx = canvas.getContext('2d');
ctx.drawImage(img, 0, 0);
const imageData = ctx.getImageData(0, 0, img.width, img.height);
// 把像素数据转移到 Worker(零拷贝)
worker.postMessage({ pixels: imageData.data.buffer, width: img.width, height: img.height }, [imageData.data.buffer]);
};
img.src = URL.createObjectURL(file);
}
worker.onmessage = (e) => {
// 拿到处理后的像素,渲染到 Canvas
const { pixels, width, height } = e.data;
const newImageData = new ImageData(new Uint8ClampedArray(pixels), width, height);
ctx.putImageData(newImageData, 0, 0);
};
```
```javascript
// wasm-image-worker.js:加载 WASM + 执行计算
let wasm = null;
self.onmessage = async (e) => {
if (!wasm) {
const { instance } = await WebAssembly.instantiateStreaming(fetch('filter.wasm'));
wasm = instance.exports;
}
const { pixels, width, height } = e.data;
// 在 WASM 里处理像素
const resultPtr = wasm.applyFilter(pixels, width, height);
const result = new Uint8Array(wasm.memory.buffer, resultPtr, width * height * 4);
// 结果传回主线程
self.postMessage({ pixels: result.buffer, width, height }, [result.buffer]);
};
```
这个架构的好处:主线程零负担,Worker 线程跑 WASM 接近原生速度,数据通过 Transferable 零拷贝传递。三重优化叠加,效果远超单独用任何一种。
## 性能差异有多大
实际测一下才有体感。以"100 万元素数组求平方根"为例:
| 方案 | 耗时(近似) |
|------|-------------|
| 主线程 JS | ~500ms(期间 UI 卡死) |
| Worker + JS | ~500ms(UI 不卡,但计算一样慢) |
| Worker + WASM | ~50ms(UI 不卡,计算快 10 倍) |
数据来源:Chrome 120,M1 MacBook Pro,具体数值因硬件和算法而异,但量级关系稳定。
关键点:**Worker 不加速计算,只解放主线程;WASM 才是加速计算的**。如果你把慢代码移到 Worker 里,它还是一样慢,只是不卡 UI 了。要真正快,得用 WASM。
## 选择决策
别纠结,按这个思路来:
1. **主线程卡不卡?** 卡 → 上 Worker
2. **Worker 里的计算够不够快?** 不够 → 把热点函数编译成 WASM
3. **两者都不需要?** 那就别用,引入 Worker 有通信开销,WASM 有编译和加载成本
一个常见的误区是"用了 Worker 就快了"——不是的,Worker 只是换个地方跑,JS 该慢还是慢。另一个误区是"WASM 能替代 JS"——也不是,WASM 搞不了 DOM、调不了 fetch、处理不了事件循环,离了 JS 胶水代码它寸步难行。
选对工具,别选"更高级"的。