
上个月做物流大屏可视化的时候我狠狠体验了一把页面卡死的绝望感。前端要实时清洗和聚合几十万条车辆轨迹数据我第一版图省事直接在页面里写循环统计结果一加载整个页面就像冻住一样滚动条拖不动地图拖拽一顿一顿用户点哪都没反应。后来排查定位问题就出在标题里那句话上JS主线程上的任务必须按顺序执行这个过程中没办法同时做其他事情。复杂计算一直霸占着主线程页面渲染和用户交互只能排队干等。Web Worker 就是来解决这个痛点的。它给 JS 补上了一种“分配任务”的能力可以把耗时的装盘流程拆给后台线程完成主线程则腾出手来继续响应点击、渲染界面。我第一次接触 Worker 时觉得这个 API 有点绕真正写进去之后才发现并不复杂。这篇文章我会从“为什么需要它”讲起再给一个完整的实操案例最后把我踩过的坑整理出来希望对同样被主线程卡顿困扰的人有帮助。1. 为什么需要 Web Worker从 JS 单线程的死穴说起1.1 事件循环看着像并行其实都在一条队列里先说一个很多人容易混淆的点。浏览器里JS脚本执行、页面渲染、用户事件响应大部分工作都挤在同一个主线程上。你写setTimeout、发fetch、注册点击回调表面看各干各的实际上都往事件循环的任务队列里排队。JS引擎一次只处理一个任务同步代码必须从头跑到尾遇到耗时的大计算后面排队的任务全部堵车。这里有个基础概念得先理清调用栈、执行上下文、事件循环。每次函数调用都会创建一个执行上下文压进调用栈。栈没被清空事件循环就没法去取下一个宏任务。微任务队列虽然优先级高但它依然跑在同一个线程里。换句话说只要CPU被一个大for循环占着不管是setTimeout还是Promise都插不上话该卡照样卡。有些同学把“异步”和“并行”混为一谈。Promise的异步解决的其实是等待类的场景比如等接口返回、等定时器触发真正跑密集计算的时候主线程还是独一份。你试着在主线程跑一亿次循环哪怕外面包十个Promise页面照样卡住因为计算本身没挪地方。这个误区我在评审别人代码时见过太多次大家总觉得用了 async/await 就“不阻塞了”其实异步只是让你在等待时不阻塞计算来了照样堵。1.2 复杂计算为什么会让整个页面卡住浏览器的渲染流程包括样式计算、布局、绘制和 JS 执行共享主线程。JS 一旦忙起来渲染帧就排不上用户看到的就是卡顿、动画丢帧、点击反馈延迟。更麻烦的是现在的主线程还要负责requestAnimationFrame、输入事件、滚动合成等任何一个环节被长任务占住体感都是灾难级的。我总结过几个最容易触发长任务的场景大数组排序、遍历、分组统计、去重超大 JSON 字符串的JSON.parse解析图片或视频帧的像素处理、滤镜计算加密、哈希、压缩这类 CPU 密集算法游戏里的寻路、物理碰撞计算这些场景有同一个特点计算密集而且必须老老实实按顺序跑完没法跳过也没法拆成并行请求。就像文章开头说的“装盘”——一双手只能按步骤一道菜一道菜装没法同时去擦桌子。而 Web Worker 要做的事本质上就是“再给 JS 一双手”。1.3 问题的核心是“计算型”任务不是“等待型”任务Web Worker 是浏览器提供的一个 Web API允许你创建独立于主线程的后台脚本线程。它和主线程通过postMessage收发消息两边各跑各的谁也不阻塞谁。重计算丢进 worker 之后主线程只负责显示结果、处理交互页面自然就顺滑了。但这里有个容易掉进去的坑不是所有“异步问题”都适合上 Worker。你得分清两类任务——等待型任务比如网络请求、定时器靠Promise就能解决计算型任务比如大循环、大解析才值得丢给 worker。我见过有人给每个 click 回调都开一个 worker结果代码复杂度上去了卡顿一点没改善。后面我会专门讲哪些场景不该用。2. 先搞懂 Web Worker 的工作机制再动手写2.1 主线程与 Worker 之间是一条双向消息通道Worker 不是主线程代码的复制品它运行在一个独立的全局上下文里DedicatedWorkerGlobalScope没有window、没有document、没有 DOM。它和主线程唯一的沟通方式就是消息通道。创建方式很简单const worker new Worker(./worker.js);主线程给 worker 发消息用postMessage接收 worker 的消息用onmessageworker.postMessage({ type: start, data: someData }); worker.onmessage function (e) { console.log(Worker 返回, e.data); };worker 内部写法类似唯一区别是全局对象要用self// worker.js self.onmessage function (e) { const result doHeavyTask(e.data); self.postMessage(result); };很多新手第一次写 worker会在里面用window.postMessage然后发现调不到原因就是 worker 里根本没有window。我建议所有 worker 脚本统一用self语义清晰也不会跟主线程混淆。2.2 消息传递的本质结构化克隆而不是 JSON主线程和 worker 传数据用的不是JSON.stringify而是结构化克隆算法structured clone。它能保留更丰富的类型信息Date、Map、Set、TypedArray、循环引用的对象都能原样传过去。我第一次用这个功能时特意验证了循环引用发现它比 JSON 序列化强大得多这不是一个字符串序列化而是真正的“对象复制”。但也有几个边界要牢记函数不能传传了直接抛DataCloneErrorDOM 节点不能传worker 里没有 DOM传了没意义Symbol 不能传Error 对象传过去之后stack会丢只剩 message 和一些自定义字段很多人忽略最后一点。你在主线程里console.log(err.stack)能看到完整堆栈在 worker 里通过postMessage传回一个 Error 对象堆栈就没了排查起来非常痛苦我后面专门写了解决办法。我整理了一个速查表方便你收藏参考数据能否传递注意事项字符串/数字/布尔能基本类型无特殊限制普通对象/数组能纯数据对象可以直接传循环引用对象能结构化克隆支持循环引用Date/Map/Set/RegExp能类型信息会保留ArrayBuffer/TypedArray能默认复制可用 Transferable 转移所有权Function不能抛 DataCloneErrorDOM 节点/Element不能worker 里没有 DOM传了没意义Symbol不能结构化克隆不支持另外要重视一个性能点每次postMessage都会把数据克隆一份。数据量越大拷贝开销越高。小消息无所谓大块二进制数据建议用 Transferable 对象转移所有权实现零拷贝这个在实操部分展开。2.3 Worker 的能力边界能做的事比你想的多不能做的也很明确Worker 里能做的事挺多可以fetch发起网络请求可以用XMLHttpRequest可以用importScripts加载第三方库可以用setTimeout、setInterval循环处理异步也没问题可以用crypto做加密哈希比如crypto.subtle.digest可以开 IndexedDB 做本地存储不能做的也很明确不能操作 DOM 和window不能用alert、confirm这类弹窗不能访问localStorage也拿不到document.cookie。如果遇到“有没有办法在 worker 里改 DOM”的需求别绕弯子。架构上就应该是worker 只负责算算完postMessage给主线程主线程拿到结果再改界面。这是边界不是临时能绕过的。2.4 别把 Worker 家族搞混Dedicated、Shared、Service浏览器里名字带 Worker 的 API 有三个作用差别挺大Dedicated Worker页面专属后台线程只能被创建它的页面用就是本文主角。Shared Worker可以被多个页面或 iframe 共享适合做跨页面的共享状态但生命周期和调试成本都更高。Service Worker实际上是网络代理层负责离线缓存、请求拦截本身不是“计算并行工具”。要解决主线程卡顿老实选 Dedicated Worker 就够了。Shared Worker 除非跨页面场景否则别在第一版引入。Service Worker 和它是两个维度混在一起只会把项目搞乱。3. 实战从一个卡到死的日志分析页开始3.1 场景与改造思路项目里有个日志分析页面用户点一次“开始分析”前端要读取一个可能到 5MB 的 JSON 文件然后按城市分组统计算出每个组的条目数、平均值再渲染成表格和图表。第一版在主线程直接跑分析过程中整个页面跟死了一样点其他按钮要等好几秒才能响应。改造思路很明确把“读取文本 JSON.parse 循环统计”这三个 CPU 密集环节全部搬进 worker主线程只负责发数据、收结果、渲染界面。有人会问 fetch 能不能留在主线程fetch 本身是异步 I/O不占 CPU留在主线程问题不大但既然要拆就拆干净我把解析和计算全部交给 worker。3.2 第一步写 worker 脚本下面这段是 worker 内部的完整代码// worker.js self.onmessage function (e) { const { rawText } e.data; // 大 JSON 解析放到 worker 里主线程不碰 const rows JSON.parse(rawText); const result { total: rows.length, cities: {} }; const total rows.length; for (let i 0; i rows.length; i) { const row rows[i]; const city row.city || 未知; if (!result.cities[city]) { result.cities[city] { count: 0, totalValue: 0 }; } result.cities[city].count; result.cities[city].totalValue row.value; // 每处理 5000 条回报一次进度 if (i % 5000 0) { self.postMessage({ type: progress, percent: Math.round((i / total) * 100) }); } } // 计算每个城市的平均值 Object.keys(result.cities).forEach((city) { const item result.cities[city]; item.avg item.totalValue / item.count; }); self.postMessage({ type: done, data: result }); };这里特意把JSON.parse放在 worker 里。很多人会在 worker 里只做业务统计但把 fetch 和 JSON.parse 留在主线程遇到 5MB 的 JSON光解析就能让页面卡几百毫秒等于白改。记住凡是 CPU 密集的环节连数据解析也要一起丢进去。3.3 第二步主线程创建 Worker 并接收结果主线程的代码相对简单但要注意几点worker.onmessage处理的是带 type 的消息根据 type 分支处理onerror一定要注册否则 worker 内部抛错你压根不知道。// main.js const worker new Worker(/js/worker.js); worker.onmessage function (e) { if (e.data.type progress) { updateProgressBar(e.data.percent); } else if (e.data.type done) { hideLoading(); renderTable(e.data.data.cities); renderChart(e.data.data.cities); console.log(分析完成总条目数, e.data.data.total); } }; worker.onerror function (e) { hideLoading(); showError(分析出错 e.message); console.error(Worker error:, e); }; document.getElementById(analyzeBtn).addEventListener(click, async () { showLoading(); const resp await fetch(/data/logs.json); const rawText await resp.text(); worker.postMessage({ rawText }); });这段跑起来之后效果对比非常明显。改造前点击按钮后页面直接卡住四五秒期间连“加载中”动画都是静止的。改造后主线程始终空闲进度条平滑推进用户还能正常操作其他按钮分析完成再渲染表格和图表。这里的updateProgressBar和renderTable就是普通的 DOM 操作放在主线程完全没问题。3.4 第三步用 Transferable 优化大二进制数据如果你的数据源是二进制格式比如 ArrayBuffer、TypedArray那么每次postMessage都会完整复制一份。5MB 复制一次不算便宜。这时候可以用 Transferable 对象把缓冲区的所有权直接转移给 worker实现零拷贝。const buffer new ArrayBuffer(5 * 1024 * 1024); // ... 往 buffer 里填充数据 worker.postMessage({ buffer }, [buffer]); // 转移之后主线程这边的 buffer 已经被 detached不能再访问postMessage的第二个参数是转移列表。一旦转移主线程的buffer立刻失效再访问会抛异常这是代价但换来的是数据不会复制对大数据场景效率提升非常明显。要注意两个细节第一如果传的是 TypedArray比如Uint8Array要转它的buffer属性才能转移直接传Uint8Array本身只会走结构化克隆复制一份。第二转移列表里的对象必须和消息对象里的引用一一对应否则会抛DataCloneError。这个语法比较反直觉写之前可以先在控制台验证一下。3.5 更多进阶写法内联 Worker 与打包器方案组件化开发时worker 脚本写成独立文件会多一次请求有些场景下你可能希望把 worker 代码直接内联在组件里。可以用 Blob URL.createObjectURL 实现const workerCode self.onmessage function (e) { const result e.data.a * e.data.b; self.postMessage(result); }; ; const blob new Blob([workerCode], { type: text/javascript }); const url URL.createObjectURL(blob); const worker new Worker(url); worker.onmessage (e) console.log(e.data); worker.postMessage({ a: 3, b: 4 });注意URL.revokeObjectURL(url)的执行时机。我踩过坑在new Worker之后立刻 revoke部分浏览器会因为 worker 脚本还没加载完而直接加载失败。稳妥的做法是等 worker 发来第一个“初始化完成”消息后再 revoke或者干脆在页面生命周期内不手动 revoke让浏览器在页面卸载时统一回收。实际工程里我更推荐使用打包器方案Webpack 的 worker-loader或者 Vite 里写new Worker(new URL(./worker.js, import.meta.url))。这种写法能让打包器处理脚本产物的路径和缓存 hash比手动拼 Blob 省心得多也方便做构建时压缩。Vite 的这个语法尤其好用它会自动把 worker 脚本作为独立 chunk 输出并且帮你处理好生产环境的 URL 路径。如果你的项目需要把 worker 代码内联成 base64 或者单独文件打包器也都提供了配置项按需调整即可。3.6 多 Worker 并行不是开得越多越快有些任务可以切割成多个独立分片理论上可以启动多个 worker 并行计算最后汇总。比如把 50 万条数据切成 5 份每个 worker 处理 10 万条。但 worker 数量不是无上限的每个 worker 都有自己的内存堆和事件循环线程间切换也有成本开多了性能反而下降。我自己用的原则是参考navigator.hardwareConcurrency的数值这是当前设备 CPU 核心数一般 worker 数量不超过它。通常一两个 worker 就能覆盖绝大多数场景。如果多个 worker 需要共享一份内存可以了解 SharedArrayBuffer但它要求页面明确启用跨源隔离COOP/COEP 响应头部署条件苛刻浏览器兼容也一般。日常场景老实走postMessage传消息就够用SharedArrayBuffer 属于进阶中的进阶没有硬需求别碰。4. 常见问题与排查技巧这些坑我基本都踩过4.1 worker 脚本一直加载失败页面还没有任何提示最常见没有之一。控制台直接 404后面的onmessage永远不触发。基本原因就三种路径写错、服务器没配静态目录、或者在file://协议下直接打开页面。Worker 脚本加载遵循浏览器同源策略不能在 file 协议下用必须通过 http/https 环境。本地开发用 dev server或者npx serve这类静态服务器路径也要注意写相对当前页面还是相对根目录。4.2 onmessage 永远不触发的三个排查方向第一worker 内部可能已经抛错了。worker 异常默认不会显示在主线程控制台只触发onerror事件所以第一件事是注册onerror看错误信息。第二事件监听方式不一致有人主线程用onmessage有人用addEventListener(message)两套混用容易出问题我建议统一使用onmessage。第三消息格式问题postMessage的数据在 worker 里被 if 分支挡住了或者被 return 提前终结。我的排查习惯是在 worker 第一行加console.log(worker loaded)然后onmessage里每一步都加日志。先把加载问题排除再逐步定位是数据问题还是逻辑问题。别一上来就对着算法 debug那是浪费时间。4.3 消息传过去了但返回结果丢了这多半是结构化克隆的边界问题。对象里带了函数、Symbol、DOM 节点postMessage直接抛DataCloneError。Error 对象传过去后stack丢失也是典型的“结果丢了”表现。我自己的习惯是消息体永远是纯数据不塞函数不依赖 Error 对象的 stack 字段错误统一通过 error 事件上报。如果你真的需要在 worker 里拿完整堆栈可以用 try/catch 捕获后把error.stack作为字符串手动拼进一个普通对象再postMessage这样至少你能看到执行到哪一行。4.4 用了 Worker 反而更慢这锅到底谁背大概率是任务本身太轻。创建一个 worker 也有几十毫秒的开销消息序列化和克隆也有成本。一万次循环级别的任务主线程算可能只要 10 毫秒拆到 worker 反而要“创建线程 发送数据 worker 执行 返回结果”累计可能超过几十毫秒当然更慢。判断标准很简单任务让页面产生了肉眼可见的卡顿也就是主线程连续运行超过 100 毫秒左右才值得拆。如果任务耗时在百毫秒以内用户基本无感知就别给自己找事。还有一种“更慢”出现在大数据传输上消息体里塞了几 MB 对象克隆开销巨大worker 算得再快也补不回来。解决办法是尽量让 worker 直接输出可展示的聚合结果而不是把原始大对象来回传。二进制数据用 Transferable 转移所有权。4.5 worker 报错定位难试试这个调试姿势Worker 代码报错传统方式只能在onerror里看到一个粗糙的 message没有完整调用栈定位问题全靠猜。我的做法是开发阶段在 worker 里包一层 try/catch出错时把error.stack塞进消息对象发回主线程try { doHeavyWork(); } catch (err) { self.postMessage({ type: error, message: err.message, stack: err.stack }); }主线程收到后打印出来。虽然 stack 过了postMessage会有部分信息丢失但配合自定义字段比裸onerror强很多。另外Chrome DevTools 的 Sources 面板可以直接给 worker 脚本打断点worker 里的代码会被单独列出调试体验比打日志好太多强烈建议用起来。4.6 频繁创建销毁 Worker 的成本比你想象的高Worker 实例一旦创建就一直存活直到调用terminate()或者页面关闭。如果你的逻辑是“每次点击都 new 一个 Worker用完就 terminate”那创建和销毁的开销可能与计算本身相当。更推荐维护一个常驻 worker 实例通过消息里的 type 字段区分不同任务。确实需要终止时worker.terminate()之后要把变量置空避免后续代码继续向已终止的 worker 发消息。那种错误只在特殊时机会出现但排查起来非常头疼我建议在封装的类里统一管理 worker 的生命周期。4.7 兼容性校验给老环境一个体面的降级方案现代浏览器对 Dedicated Worker 支持已经很全桌面和移动端都没问题。但如果你要兼容某些老安卓 WebView、老版本桌面内核建议做个能力检测if (typeof Worker ! undefined) { // 使用 worker } else { // 降级回到主线程计算功能还能用只是会卡一下 }优化手段不能变成功能门槛这是我做兼容的底线。没有 Worker 的环境至少保证页面能完成核心功能而不是直接白屏或者报错。降级逻辑很简单就是把原来的计算函数直接在主线程调用代码结构尽量复用。5. 避坑清单这些场景真不适合用 Web Worker5.1 小任务不值得线程开销会吃掉所有收益创建 worker、传消息、回收结果每一步都有固定成本。如果任务本身只有几毫秒拆到 worker 只会更慢。判断方法最好就是实测先让任务在主线程跑打开 Performance 面板看主线程的长任务时长如果没超过 100 毫秒完全不需要上 worker。5.2 高频小消息通信往返成本比计算还贵有些实时渲染场景你可能想让 worker 每帧计算然后推送结果给主线程。如果数据是简单坐标点比如 x、y、width、height 这几个数字每次往返都有消息序列化开销高频发送时这个开销可能超过计算本身得不偿失。我见过一个动画项目把每帧的变换矩阵计算放到 worker结果主线程每帧都要等消息回来延迟反而比直接算更大。我自己的判断标准是消息频率高于每秒几十次并且每条消息都在几 KB 以上就要非常谨慎。实时性能敏感的功能优先考虑在渲染循环里直接计算别为了“用到 worker”而强行拆。真正适合 worker 的是那种频率低、单次计算耗时长、结果数据浓缩的任务比如批量计算完成后再一次性回传。5.3 worker 不能操作 DOM也别指望它帮你管理状态worker 的边界决定了它不是万能的。有些人想把复杂应用的全部业务状态塞进 worker主线程只管渲染这个思路在简单场景可行但只要涉及路由、组件状态、交互同步你会发现维护“主线程状态和 worker 状态的一致性”本身就是个大坑。状态分散在两个线程里像弹窗开合、表单值、当前选中项这些东西同步起来非常别扭。我的经验是把 worker 定位成“无状态计算器”输入原始数据输出计算结果。复杂应用里状态管理留在主线程只把计算密集的部分丢给 worker架构才清晰。比如上文的日志分析worker 拿原始文本返回聚合结果完全不碰状态这才是它该干的活。5.4 内存压力与线程数量移动端尤其要小心每个 worker 都有自己的 JS 堆空载也可能占几 MB 到十几 MB 内存。页面本身如果已经接近内存上限多开几个 worker移动端低端机直接可能被系统回收页面。我看到过一些极端案例一屏启动十几个 worker结果 OOM 崩溃。控制线程数量的原则就四个字够用就好。一般一个页面维护一个专用 worker最多加一个备用 worker 处理偶发的大任务绝大多数场景都够用了。5.5 我的一些使用习惯分享给你最后说点自己这几年的使用习惯。判断一个任务该不该拆给 worker我就问三个问题第一这个任务会不会让用户明显感到“页面卡了”第二它是不是 CPU 密集而不是 I/O 等待第三把结果传回来的消息体能不能尽量小如果三个答案都是肯定的那就拆。拆的时候记住三句话worker 只算数不管界面消息传得越小越好大块二进制该转移就转移线程数控制在个位数。还有一点别等项目已经卡到不可收拾再去改。数据量是慢慢涨上来的你可以在功能设计阶段就想清楚哪些计算将来可能成为瓶颈给 worker 预留一个入口。结构上多一个 worker 文件不是负担真到遇到卡顿那天你会发现这层架构早备好了反而省事。