
1. 为什么需要 Web Worker先聊聊主线程的痛1.1 认识主线程浏览器究竟在忙什么我们在浏览器里打开一个页面表面上看到的是 HTML、CSS、JavaScript 各司其职但实际上所有 JavaScript 代码都运行在同一个线程上这个线程就是主线程。它不只是执行你的业务逻辑还要同时负责解析和执行 JavaScript 代码处理 DOM 的增删改查、样式计算、布局和绘制接收并派发用户交互事件滚动、点击、输入、触摸执行浏览器内部的垃圾回收等任务。也就是说主线程其实是浏览器的总指挥一个人干着好几份活。如果某一段 JavaScript 代码执行时间过长总指挥就腾不出手来处理后续的渲染、布局和用户输入事件。表现出来就是页面卡顿、滚动掉帧、点击无反应、输入框打字延迟用指标来衡量的话就是 Long Task 时间过长、FID首次输入延迟和 TBT总阻塞时间严重超标。举一个最典型的例子在主线程上执行一段大规模排序或数据处理比如遍历十万条记录做字段计算在数据量小的机器上耗时可能达到几百毫秒甚至数秒。这段同步代码执行期间整个页面处于假死状态用户任何操作都会被积压到队列后端等待当前任务彻底结束才继续执行。我实测过一个十万人名数据去重并模糊搜索的场景如果不做任何优化在主线程里跑一次全量模糊匹配谷歌浏览器里大概耗时 900ms 到 1.5s期间页面滚动完全卡死。如果用户触发搜索时输入框还在按下键盘要等一秒才有反应——这种体验在真实业务中是不能接受的。1.2 Worker 能做什么、不能做什么Web Worker 的出现就是给浏览器增加多线程能力的标准方案。它允许你在单独的线程里跑 JavaScript 代码这个线程和主线程并行执行互不阻塞。主线程发起一个任务Worker 在后台计算计算结果通过消息事件传回主线程整个过程中主线程继续处理页面渲染和用户交互。但有一点必须一开始就说清楚Worker 并不是一个完整独立的浏览器环境它有很多能力限制。最大的限制是Worker 线程里没有 DOM、没有 window 对象也没有 document 对象。你不能在 Worker 里操作 DOM不能读取页面元素不能调用 alert、confirm 这类阻塞式交互 API也不能使用 localStorage 会话相关接口。它有自己的全局对象 WorkerGlobalScope可以用绝大多数纯计算的 JavaScript API。包括但不限于XMLHttpRequest / fetch 发起网络请求定时器 setTimeout / setInterval / clearTimeout文件类 APIFileReader 等与部分 IndexedDB 操作Canvas 相关的 OffscreenCanvas 离屏渲染能力数学库、加密 APISubtleCrypto、TypedArray 等高性能数据结构。所以Worker 最适合做的事情是那些开销大、计算密集、又不依赖 DOM 的逻辑。这类任务统称为CPU密集型任务。比如大批量数据的过滤、排序、去重、规则引擎匹配图像像素级处理灰度化、卷积滤波、缩放裁剪音视频的编解码转封装加密解密、哈希计算复杂路径规划、游戏物理运算Excel/JSON 大文件解析配合流式读取等。简单总结一下设计原则凡是需要在主线程上跑超过 50ms 的同步计算都应该考虑搬到 Worker 里去。50ms 大约是一帧动画渲染时间的 3 倍超过这个值浏览器就会出现可见卡顿可以说是主线程任务拆分的一条经验红线。2. 三类 Worker 怎么选Dedicated、Shared 和 Service2.1 Dedicated Worker日常使用最多的那一个Dedicated Worker 直译是专用 Worker意思是一个 Worker 实例只归属于创建它的那个页面/上下文不能跨页面共用。日常开发里说的用 Web Worker绝大多数场景指的就是它。Dedicated Worker 的创建方式很简单在主线程里用构造函数指定一个 JS 文件路径const worker new Worker(/workers/task.js); worker.postMessage({ type: init, payload: { data: sourceData } }); worker.onmessage (event) { const result event.data; // 处理 Worker 返回的结果 }; worker.onerror (error) { // 捕获 Worker 内部抛出的异常 console.error(Worker error:, error.message); };Worker 文件内部则通过 self 全局对象来监听主线程的调用self.onmessage (event) { const { type, payload } event.data; if (type init) { // 在 Worker 里做真正耗时的计算 const result heavyCompute(payload.data); self.postMessage(result); } };Dedicated Worker 的特点是一对一成组一个页面 page 对应一个或多个 Worker但每个 Worker 都属于这个页面自己。它没有共享复杂度实现最简单调试最直接绝大多数业务场景优先用它就够了。2.2 Shared Worker跨页面共享计算能力的方案Shared Worker 则允许多个同源页面共享同一个 Worker 实例。比如你在多个浏览器标签页里打开了同一个后台管理系统的不同菜单这些页面可以共同连接到一个 Shared Worker 上把某些公共的轮询任务、缓存查询、实时数据聚合统一交给这个共享 Worker 去做避免每个页面各自起一个后台任务白白浪费 CPU 和内存。Shared Worker 的创建和通信方式与 Dedicated Worker 不同它不能直接用 new SharedWorker(url) 然后 postMessage而是要通过端口通信if (SharedWorker in window) { const sharedWorker new SharedWorker(/workers/shared-task.js); sharedWorker.port.start(); sharedWorker.port.postMessage({ type: fetch-data }); sharedWorker.port.onmessage (event) { // 拿到共享计算结果 const result event.data; }; }Worker 内部也要用端口监听self.onconnect (event) { const port event.ports[0]; port.onmessage (msgEvent) { const { type } msgEvent.data; if (type fetch-data) { port.postMessage(doSharedTask()); } }; port.start(); };这里有个重要细节Shared Worker 里的全局对象 self 是一个 SharedWorkerGlobalScope它没有 onmessage 这个事件处理入口必须通过 onconnect 里的 port 来收发消息。第一次接触的人很容易在这里卡住直接把代码从上往下拷就会出现明明创建了 SharedWorker但消息一直没反应的问题。Shared Worker 的另一个特性是只有在页面全部关闭、Worker 自身没有 pending 任务时浏览器才会回收这个实例。如果你在 Worker 里加了定时器要确保在页面卸载时主动关闭掉否则后台会一直驻留。使用 Shared Worker 最常见的坑是调试起来比较麻烦DevTools 里需要单独开对应的面板不同浏览器对 Shared Worker 的生命周期表现也不一致。我的建议是只有当确实存在多标签页共享计算场景时再用 Shared Worker单页面场景用 Dedicated Worker 就足够了。2.3 Service Worker它严格来说是另一种东西很多人会把 Service Worker 和 Web Worker 搞混因为它们长得确实像。但 Service Worker 的本质是一个网络代理层它拦截页面发起的请求、管理应用缓存、支持离线能力、接管推送消息它的生命周期和页面没有直接的绑定关系而是由浏览器统一调度。Service Worker 的核心价值是网络层而不是计算层。虽然理论上你也能在 Service Worker 里跑一些计算逻辑但它不适合做大批量 CPU 密集的任务。原因在于Service Worker 的全局上下文是 ServiceWorkerGlobalScope和 Dedicated Worker 不同它必须在 HTTPS 或 localhost 环境下才能运行部署条件更严格它和页面的通信方式主要是 fetch 事件拦截和 postMessage 两种设计目标不偏向数据计算。如果你只是为了做并行计算选 Dedicated Worker 而不是 Service Worker。如果是为了做离线缓存、请求拦截、PWA 能力再单独引入 Service Worker。两者不要混为一谈。2.4 选型建议表需求类型推荐方案原因页面内大量计算、数据过滤、图像处理Dedicated Worker创建简单、通信直接、生命周期好掌控多个同源标签页共享一个计算实例Shared Worker端口通信跨页面共享状态和结果网络拦截、离线缓存、消息推送Service Worker网络层代理能力本身不是计算线程Worker 内继续拆分子任务可以从 Worker 再创建 Sub Worker极少见通常多 Worker 足够复用3. 通信机制背后从结构化克隆到可转移对象3.1 postMessage 与结构化克隆算法主线程和 Worker 之间通信的唯一通道是 postMessage 和 onmessage 事件。往 postMessage 里传什么、怎么传直接影响通信效率和 Worker 内部的计算速度。postMessage 支持的数据类型非常多普通对象、数组、字符串、数字、布尔值、TypedArray、Map、Set、Date、RegExp、Blob、File 等都可以传。但传输机制不是引用传递而是通过结构化克隆算法深拷贝一份数据。也就是说主线程里传入的数据和 Worker 里收到的数据是两个完全独立的对象互相之间没有引用关系修改任意一方都不会影响另一方的状态。这个机制的好处是隔离性极强不会出现多线程环境下的数据竞争问题坏处是拷贝本身有开销。如果你每次 postMessage 都传一个几百兆的 ArrayBuffer光拷贝这份数据就要花掉几十毫秒甚至更多计算本身反而没那么贵了。结构化克隆算法还要求被克隆的对象是可序列化的如果你传了一个包含函数、Symbol、DOM 节点的对象过去就会直接抛出 DataCloneError 异常。3.2 可转移对象真正搬家的数据为了应对数据太大、拷贝太贵的问题postMessage 支持第二种传输方式可转移对象Transferable Objects。它的核心原理是把数据的所有权直接转移给目标线程源线程不能再访问这份数据整个转移过程只是移动引用不进行逐字节的拷贝开销非常低。最常见的可转移对象是 ArrayBuffer 和 MessagePort。用法如下// 主线程侧 const buffer new ArrayBuffer(1024 * 1024 * 100); // 构造 100MB 的二进制缓冲区 worker.postMessage({ buffer }, [buffer]); // 第二个参数传入可转移对象列表 // 注意buffer 已被转移主线程这边不能再使用读取长度都会报错 // Worker 侧 self.onmessage (event) { const { buffer } event.data; // 这里的 buffer 是全新的 ArrayBuffer可以直接读写 };使用可转移对象后postMessage 的耗时从拷贝 100MB 数据的几十毫秒降到近乎零。代价是数据源端的引用被掏空了如果有多个 Worker 需要并行读取同一份数据不能都用转移方式可以考虑先用一个主线程持有原始数据按分片分别拷给每个 Worker。完整支持可转移对象的类型还有一些比如 OffscreenCanvas 的 control以及 AudioBuffer 等但日常用得最多的还是 ArrayBuffer。3.3 SharedArrayBuffer共享内存的进阶玩法相比转移还有第三种更极端的方案SharedArrayBuffer。它允许主线程和 Worker 同时持有同一块 ArrayBuffer 的数据引用读写都是直接作用于同一块物理内存。这样就不再需要 postMessage 拷贝数据而是通过 Atomics 对象做原子操作来控制并发读写。// 主线程 const sharedBuffer new SharedArrayBuffer(1024 * 1024 * 64); // 64MB const sharedArray new Float64Array(sharedBuffer); worker.postMessage({ sharedBuffer }); // Worker 内 self.onmessage (event) { const sharedArray new Float64Array(event.data.sharedBuffer); // 直接读写 sharedArray无需拷贝无需 postMessage 返回 Atomics.store(sharedArray, 0, 42); };SharedArrayBuffer 的性能优势非常明显因为彻底消除了一次完整数据拷贝同一份线程间的数据同步成本几乎降至零。但它的引入也带来了严重的共享内存并发问题。为了安全浏览器在 2018 年曾因 Spectre 漏洞短暂禁用 SharedArrayBuffer之后在 2020 年重新开放但前提是页面必须处于跨域隔离状态即配置了 COOP 和 COEP 响应头或者处于 https 明确跨源隔离场景。普通开发者在没有跨域隔离的本地调试环境里SharedArrayBuffer 可能直接 undefined。所以我的建议是先想清楚 SharedArrayBuffer 是否真的能带来足够大的收益再考虑引入。大多数业务场景里主线程切分数据 Worker 各自算完后把结果 postMessage 回去就完全够用了没必要为了性能提前上共享内存的一大堆复杂度。4. 一个完整的实操案例任务分片与多 Worker 协作4.1 先搞清楚场景十万人名数据筛选为了让整篇内容落地我挑一个非常典型的业务场景来演示一个后台系统里有十万人名记录包含姓名、部门、手机号、入职时间等字段。用户要求支持前缀模糊搜索输入关键字实时过滤同时要统计过滤后的结果数量与部门分布。十万人如果直接塞到主线程里做正则匹配在普通办公电脑上大概要跑 500ms 到 1s。用户每次输入一个字符都触发一次整个输入框会明显卡顿。这个场景非常适合用 Web Worker 来做。直接上方案主线程接收用户输入把全部数据发给一个 WorkerWorker 在内部做分片过滤每次搜索都返回过滤结果集。但这个方案有个隐患每次搜索都要重新把全部数据发给 Worker即使数据本身不变化通信成本也很高。更合理的做法是页面首次加载时把数据一次性发给 WorkerWorker 内部保存一份数据副本后续用户输入搜索时主线程只发一个关键字Worker 在本地直接用预先保存的数据做过滤再把结果 postMessage 回主线程。这样既避免了重复拷贝大数据又把计算负载完全移出了主线程。4.2 主线程侧怎么封装 Worker先写一个最小可用的 Worker 封装类。它的职责是管理 Worker 的创建、消息回调、错误处理以及销毁逻辑class FilterWorker { constructor(workerUrl) { this.worker new Worker(workerUrl); this.worker.onmessage (event) this.handleMessage(event); this.worker.onerror (error) this.handleError(error); this.pendingCallbacks new Map(); this.taskCounter 0; } // 主线程向 Worker 发送任务并注册回调函数 sendTask(payload, onSuccess) { const taskId this.taskCounter; this.pendingCallbacks.set(taskId, onSuccess); this.worker.postMessage({ taskId, payload }); } // Worker 返回结果时根据 taskId 找到对应的回调并执行 handleMessage(event) { const { taskId, result } event.data; const callback this.pendingCallbacks.get(taskId); if (callback) { callback(result); this.pendingCallbacks.delete(taskId); } } handleError(error) { console.error(Worker error:, error.message, error.filename, error.lineno); } destroy() { this.worker.terminate(); this.worker null; } }这段封装有一个值得注意的设计每次 sendTask 都生成一个 taskIdWorker 返回结果时带上同样的 taskId主线程再通过 Map 找到对应的回调。这样多个任务并发发出时结果不会串。否则你只能用一个全局 onmessage 去分发消息一旦同时发出多个请求根本分不清哪个结果属于哪个任务。你的 Worker 文件里也需要对应维护 taskIdlet cacheData []; self.onmessage (event) { const { taskId, payload } event.data; if (payload.type init) { cacheData payload.data; self.postMessage({ taskId, result: { type: init, ok: true } }); } else if (payload.type search) { const filtered CACHE_DERIVE_FN.filterData(cacheData, payload.keyword); self.postMessage({ taskId, result: { type: search, data: filtered } }); } };这里值得说明的是 CACHE_DERIVE_FN 这个思路Worker 内部用一个变量保存初始化时收到的全部数据把大数据的加载、保存、过滤全部收拢在 Worker 里主线程只负责发任务。4.3 Worker 里的任务分片思路十万人名数据在 Worker 内部做正则过滤时如果一口气对全部数据做同步循环虽然不影响主线程但 Worker 本身的耗时还是存在。而且 Worker 线程在执行时也会占用 CPU 资源如果同时开了多个 Worker后续发进来的任务会排队。任务分片的核心思路其实很简单把十万条数据拆成多个小块每块处理一小部分后先用 setTimeout 或专门的响应事件空隙让线程有机会喘口气再继续处理下一块。但用 Worker 之后分片的意义已经发生了变化——不再是为了避免阻塞主线程而是为了不让某个 Worker 一直霸占线程导致其他 Worker 无法及时处理自己的任务。更实用的分片方法是在 Worker 内部用一个任务队列每次搜索任务只处理其中一部分数据然后把部分结果立即 postMessage 回主线程主线程收到部分结果后进行合并。比如把十万人人数据按 5 万切成两块Worker 先处理前五万条并返回结果再处理后五万条返回结果主线程把两次返回合并。这样做的好处是即使数据量再翻倍用户也能在不到总耗时一半的时间看到部分结果体验提升非常明显。const CHUNK_SIZE 50000; function processChunk(cacheData, keyword, startIndex, length) { const result []; const regex new RegExp(keyword.replace(/[.*?^${}()|[\]\\]/g, \\$), i); const end Math.min(startIndex length, cacheData.length); for (let i startIndex; i end; i) { const item cacheData[i]; if (regex.test(item.name) || regex.test(item.phone)) { result.push(item); } } return result; }4.4 多 Worker 并行与结果归并单个 Worker 已经能跑十万条数据不卡主线程但当数据量再往上走比如五十万条单 Worker 的耗时也从几百毫秒涨到几秒体感还是偏慢。此时可以引入多 Worker 并行把数据分成多个分片每个 Worker 各处理一部分主线程统一归并结果。function createParallelWorkers(workerUrl, data, chunkSize 50000, workerCount 4) { const chunks []; for (let i 0; i data.length; i chunkSize) { chunks.push(data.slice(i, i chunkSize)); } const workers []; const workerCountReal Math.min(workerCount, chunks.length); for (let i 0; i workerCountReal; i) { const w new FilterWorker(workerUrl); w.worker.postMessage({ type: init, data: chunks[i] }); workers.push(w); } return workers; }多 Worker 并行有一个细节要提前设计好归并的顺序问题。每个 Worker 处理的数据分片编号是固定的归并时不能简单地按照 Worker 返回的先后顺序直接拼接因为各分片的处理速度可能不一样谁先处理完谁就先回消息。稳妥的做法是给每个分片编号主线程持有一个结果容器按分片编号填进去最后按编号顺序合并。// 主线程归并逻辑 const resultsMap new Map(); let expectedCount 0; function sendSearchTask(keyword) { resultsMap.clear(); expectedCount workers.length; workers.forEach((worker, index) { worker.worker.postMessage({ type: search, keyword, chunkIndex: index }); }); } worker.worker.onmessage (event) { const { chunkIndex, result } event.data; resultsMap.set(chunkIndex, result); if (resultsMap.size expectedCount) { const finalResult []; for (let i 0; i expectedCount; i) { finalResult.push(...resultsMap.get(i)); } renderResult(finalResult); } };这种分片 编号 按顺序归并的思路在并行计算里非常常见不管你是用 Web Worker 还是其他框架逻辑套路是相通的。实际工程里可以再加一些防御性逻辑比如某个 Worker 抛异常时要重试剩余分片或者对返回结果做耗时统计。这里也顺便说一下 workerCount 选多少合适。经验是Worker 数量不建议超过 CPU 核心数减一。因为浏览器主页面本身就占用一个核心进程你把所有核心都占满页面自身的渲染、交互、动画就没人管了。在 8 核机器上开 4 到 6 个 Worker 是比较稳健的选择再多不仅无法获得收益反而会因为线程调度开销拖慢整体速度。5. 常见问题与调试实录5.1 内存泄漏Worker 用完真的要 terminate 吗Worker 如果只是做一次性计算比如导入一个 Excel 文件后做数据清洗计算完就再也不用它参与交互了那么它的生命周期应该由你来主动收尾。最简单直接的办法是调用 terminate() 方法直接杀掉 Worker 线程worker.terminate(); worker null;如果不对一次性 Worker 做 terminate它会在后台一直驻留占用线程和内存。虽然浏览器最终会在页面关闭时回收资源但页面本身是长时间运行的 SPA 场景时不断累积的 Worker 会导致内存持续上涨绝大多数前端团队排查内存泄漏的第一条经验就是你检查过没有一直挂在后台的 Worker对于那些生命周期和页面完全一致的 Worker比如从页面初始化时就建立、页面关闭前才销毁的常驻 Worker可以在页面卸载时统一 terminatewindow.addEventListener(beforeunload, () { if (worker) { worker.terminate(); worker null; } });5.2 错误处理onerror 捕捉不到的坑Worker 内部如果抛出异常会触发 Worker 实例的 onerror 事件。但这里有一个高频踩坑点如果 Worker 里发生了一个 Promise 的 rejection并且没有被捕获onerror 不一定能拿到完整的堆栈。尤其是使用了 async/await 语法时异常处理链路更容易断裂。在 Worker 内部建议统一用一个 try/catch 包裹整个任务处理逻辑把异常信息主动 post 回主线程self.onmessage async (event) { const { taskId } event.data; try { const result await handleTask(event.data); self.postMessage({ taskId, result, error: null }); } catch (error) { self.postMessage({ taskId, result: null, error: { message: error.message, stack: error.stack } }); } };主线程的 onmessage 里额外处理这种 error 字段if (event.data.error) { console.error(Task error:, event.data.error); return; } const finalResult event.data.result;这样处理的好处是错误信息带着 taskId 一起回来你可以精确地知道哪个任务失败了而不是全局一个笼统的 onerror 在那里报个无出错的异常信息。5.3 调试技巧DevTools 里的 Worker 面板很多人写 Worker 代码遇到 bug 时习惯直接在浏览器主控制台看日志。但 Worker 内部的 console.log 和 console.error 默认情况下不会出现在主控制台上需要打开 DevTools 的 Sources 面板在左侧的辅助线程Workers列表里找到对应线程点进去就能看到那个线程自己的 Console 和 Call Stack 信息。Chrome 的操作流程是打开 DevTools - 打开 Sources 面板 - 在左侧文件树底部找到 Workers 分组 - 点击对应的 worker 文件 - 进入到 Dedicated Workers 调试面板。在这里你可以对 Worker 代码打断点、单步调试、查看作用域变量和调用栈和平时调试普通代码没有区别。另外如果你的 Worker 代码是经过打包压缩或 source map 处理的建议在调试环境关闭压缩或保留 source map否则断点打得非常痛苦。5.4 其他注意点SSR 场景与加载路径在服务端渲染框架中使用 Worker 时要尤其注意 Worker 的创建时机。由于 SSR 环境没有 window 对象直接执行 new Worker() 会直接抛错。正确做法是在判断当前运行环境是浏览器环境后才创建if (typeof window ! undefined typeof window.Worker ! undefined) { const worker new Worker(/workers/task.js); }Worker 脚本的加载路径也经常被忽视。new Worker(url) 中的 url 是相对于当前的文档 URL 来解析的而不是相对于你当前所在的 JS 文件。这意味着如果页面在 /admin/user-list 路由下裸写 new Worker(worker.js) 会去请求 /admin/worker.js大概率 404。解决办法有两个在构建工具的配置里使用 worker-loader 或 Vite 的按需导入插件让打包器自动处理路径或者用显式的绝对路径/workers/task.js并确保该路径在你的静态资源服务中可访问。用Blob URL把一份 JS 字符串在运行时打包成 Worker 也是一种技巧const workerScript self.onmessage (e) { const result JSON.parse(e.data).list.map((x) x * 2); self.postMessage(result); }; ; const blobUrl URL.createObjectURL(new Blob([workerScript], { type: application/javascript })); const worker new Worker(blobUrl);这个方法很适合做一些小规模临时任务但缺点是无法单独调试 Worker 代码错误堆栈显示的都是 blob: 开头的路径排查成本偏高。建议只在确实需要动态生成脚本时用。写了这么多回头总结一下我在真实项目里的使用感受。Web Worker 并不是一个高不可攀的高级特性它最核心的价值就一句话把主线程腾出来让页面保持流畅。边界条件、通信开销、生命周期管理和错误链路是你实际工作中最需要花时间处理的四件事。建议新项目里凡是遇到超过 50ms 的同步计算先试试能不能搬到 Worker 里跑数据量大的业务场景先做一次性能基线测试看看主线程的 Long Task 和 FID 指标到底是多少。有了数据支撑再决定是否启用多 Worker 并行会更稳妥一些。