标签

Web Worker

Web Worker 是 HTML5 提供的一种在后台线程中运行脚本的机制。它允许网页脚本在后台线程中运行,而不会阻塞主线程,从而提高网页的性能和响应能力。例如,当一个网页需要进行大量的计算(如复杂的数据加密、图像渲染中的复杂算法等),如果在主线程中进行,会导致页面冻结,用户无法进行其他操作,如滚动页面、点击按钮等。而使用 Web Worker,这些计算任务可以放在后台线程中执行,主线程依然可以响应用户的交互操作。

Web Worker
前端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 时不应盲目使用。
服务端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 胶水代码它寸步难行。 选对工具,别选"更高级"的。