ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

Web Worker实战:让JavaScript多线程告别页面卡顿

Web Worker实战:让JavaScript多线程告别页面卡顿 1. 页面卡顿的病根主线程一次只能干一件事你有没有遇到过这种场面页面上一个几千行的大表格前端做筛选、排序、分组你拖一下滚动条整个页面直接变成幻灯片动画停住按钮点了没反应连 loading 转圈都一顿一顿的。这种卡成 PPT的体验我做过电商后台、做过数据中台大屏遇见的次数太多了。早年间我习惯到处打补丁——把长任务拆成 setTimeout 片段、加 loading 遮罩、给用户画饼说正在计算请稍候直到后来把Web Worker真正用熟才意识到之前的做法都是在主线程上硬扛。要理解 Web Worker 为什么能解决问题得先搞清楚页面为什么会卡。浏览器的 JavaScript 是单线程的这句话你肯定听过但它的真正含义是主线程既要执行 JS 代码又要处理 DOM 渲染、样式计算、事件响应、定时器回调。这就像一家小餐厅后厨只有一个灶台炒菜、切菜、洗碗全在这一个灶台上完成。你往锅里倒一大盆食材执行一个耗时的 for 循环其他所有菜用户点击、滚动、动画都得在一边排队等着。举个例子你在主线程里做这么一件事const arr []; for (let i 0; i 10000000; i) { arr.push(Math.random()); } arr.sort((a, b) a - b);一千万个随机数生成加排序在普通笔记本上大概要几百毫秒到一两秒。听起来不长但浏览器保持流畅的标准是每一帧不超过 16.7ms也就是说 200ms 的阻断已经让页面掉十几帧用户感知就是卡了一下。如果换成更重的数据处理、图片压缩、文件解析卡个四五秒都不奇怪。很多人第一反应是用 setTimeout 把任务拆碎比如分成多批循环每批之间让浏览器喘口气function processInChunks(arr, chunkSize, callback) { let index 0; function next() { const end Math.min(index chunkSize, arr.length); for (; index end; index) { // 处理数据 } if (index arr.length) { setTimeout(next, 0); } else { callback(); } } next(); }这种做法确实能减少单次阻塞时间让页面看起来不那么僵硬但计算总时长没有缩短甚至更慢。而且代码变得复杂还要维护进度状态一不小心就写出 bug。更关键的是它治标不治本——CPU 密集型的脏活累活本质上还是压在唯一的主线程上。所以在谈怎么优化之前我建议你先想明白一件事如果一个任务耗时超过 100ms且不需要操作 DOM那它就不该出现在主线程。这个判断标准特别简单但很多人没意识到它的分量。2. 一个 Worker 就是一个副厨JS 单线程之外另起灶台Web Worker 解决的就是这个问题浏览器允许你开一个真正的后台线程在这个线程里跑 JavaScript它有自己独立的 V8 引擎实例、独立的事件循环跟主线程互不阻塞。用餐厅的类比来说主线程是负责炒菜的总厨Web Worker 就是后厨里新来的副厨你把一堆备菜的活丢给他他干他的你继续炒你的菜最后他喊你一声菜备好了过去端过来就行。这里要分清一个概念Web Worker 不是给主线程加的一根补丁而是另一套完整的 JS 运行环境。在这个环境里没有window、document不能直接操作 DOMDOM 操作被明确禁止有独立的self对象代表 Worker 全局作用域可以用fetch、XMLHttpRequest发网络请求可以用IndexedDB可以做WebSocket可以通过postMessage和主线程双向通信在这套环境里照样可以用setTimeout、setInterval也有自己的事件循环为什么不能直接在 Worker 里操作 DOM因为如果多个线程同时改同一个 DOM 节点渲染引擎根本没法维护一致性。这个限制不是 Web Worker 的缺陷反而是它敢把线程模型交给前端的原因——既然不能碰 DOM就不会出现多线程操作 DOM 导致的数据竞争消息通信就成了唯一的桥梁。说一个很多新手会绕晕的点Worker 有好几种别搞混。最常见的Dedicated Worker专用 Worker就是我们一个主线程 new 出来的幕后伙计一对一的。还有Shared Worker共享 Worker可以被多个页面或同一个页面的多个模块共享。以及最容易被混淆的Service Worker——它本质上是浏览器帮你管理的网络代理脚本用来做离线缓存、后台同步、消息推送生命周期和通信方式跟 Web Worker 完全是两码事。我后面会单独讲 Service Worker 注册报错的问题因为这是个高频困扰。兼容性方面不用担心Web Worker 从 2012 年前后开始被主流浏览器支持到今天已经是基础能力。不管是 Chrome、Firefox、Safari 还是国内各种套壳浏览器new Worker()都能直接用。真正影响兼容性的是后面要说的 Module Worker 和 SharedArrayBuffer这两样是不同级别的东西。3. 从 0 跑通第一个 Worker创建、通信、销毁全流程现在动手。假设你有一个场景前端需要对一千万个随机数做排序然后展示其中最小的一个数。直接写个最笨但完整的例子先跑通了再谈优化。主线程文件main.jsconst worker new Worker(./heavy.js); worker.onmessage (e) { console.log(Worker 算完了结果, e.data.result); // 用完记得销毁释放线程资源 worker.terminate(); }; worker.onerror (e) { console.error(Worker 内部出错了, e.message); worker.terminate(); }; worker.postMessage({ type: sort, count: 10000000 });Worker 文件heavy.jsself.onmessage (e) { const { type, count } e.data; if (type sort) { const arr []; for (let i 0; i count; i) { arr.push(Math.random()); } arr.sort((a, b) a - b); self.postMessage({ type: result, result: arr[0] }); } };把这两个文件放到同一个目录下起一个本地静态服务webpack/vite 项目更简单直接放 src 里控制台会打印出排序结果。整个过程里你拖动页面、点按钮页面都不会卡因为排序这堆活已经不在主线程干了。拆解一下这段代码的要点创建 Worker 用的是相对路径或绝对路径指向一个 JS 文件。注意./heavy.js最后必须能通过 HTTP(S) 被正确加载。直接双击本地 HTML 文件用file://协议打开Worker 会因为跨域问题加载失败控制台报错这是最高频的我明明写了但没反应的原因之一。用本地静态服务或者直接在前端工程里跑就没这个问题。主线程和 Worker 之间唯一的通道是postMessage。你发什么数据过去Worker 那边的self.onmessage就收到什么Worker 通过self.postMessage回传主线程的worker.onmessage接收。消息内容可以是一个普通对象也可以是一个数组、字符串甚至可以是一个复杂的嵌套结构。这里走的是结构化克隆算法下面一节我会详细展开。Worker 出错不会让主线程崩溃。如果heavy.js里抛了异常主线程这边会触发onerror你能拿到文件、行号、错误信息。这个机制在产品里特别重要——我见过有人把数据解析逻辑丢 Worker 里结果某个字段格式异常直接在 Worker 里崩了主线程毫无察觉计算就变成永久 pending。所以生产代码里一定要在 Worker 内部做好 try/catch外部也要挂onerror回调。用完了记得terminate()。你不显式销毁Worker 会一直占着一个线程的资源。预算有限的移动端尤其明显开一个 Worker 就常驻一个线程多个不用销毁的 Worker 叠加内存和 CPU 占用都是肉眼可见的。如果你用的是 ES Module可以在创建 Worker 时指定类型const worker new Worker(./heavy.mjs, { type: module });Module Worker 内部的heavy.mjs可以直接用import引入其他模块对于想把数据处理逻辑拆成多个文件的项目来说很实用。注意type: module的 Worker 对浏览器版本有要求Chrome 80、Safari 15 才稳定支持。4. 跨线程传数据的三种姿势拷贝、转移、共享内存用postMessage传数据底层发生了什么很多人没搞清楚这里专门掰开讲。默认走的是结构化克隆Structured Clone Algorithm。简单理解就是主线程把要传的数据深度拷贝一份把拷贝后的副本序列化后传给 Worker 线程两者各有一份完全独立的数据。改任何一边的数据另一边完全不受影响。这种方式的优点是安全、简单缺点是大数据的拷贝开销不可忽略而且拷贝期间主线程要同步执行克隆操作数据特别大的时候拷贝本身可能就让页面卡一下这就很讽刺——你本来想用 Worker 解决卡顿结果传输大对象又把自己卡住了。第二种是 Transferable Objects可转移对象。某些类型支持转移而不是拷贝最典型的就是ArrayBuffer。转移的意思是把内存块的所有权直接交给对方主线程这边的原对象立刻变空变成已移走状态之后的访问会拿到空值。这就像你快递一个硬盘不用复制其中的数据直接把硬盘交给快递员你的桌上留下的只是一个空盒子。// 主线程 const buffer new ArrayBuffer(1024 * 1024 * 100); // 100MB worker.postMessage(buffer, [buffer]); // 注意postMessage 之后这里再访问 buffer.byteLength 已经是 0 了postMessage的第二个参数就是转移列表。转移过程是零拷贝的内存地址直接移交给 Worker速度极快是传大数据的最优解。但代价是主线程这边数据不可用了如果你还指望主线程继续读这块数据就得想别的方案。第三种是 SharedArrayBuffer共享内存。这是真正的同一块内存两边都能读写连拷贝和转移都省了。但共享内存也意味着两边可能同时读写同一个位置所以需要Atomics系列 API 做原子操作避免竞争。还有一个让人头疼的门槛SharedArrayBuffer 要求页面必须处于跨域隔离状态也就是服务端要返回Cross-Origin-Opener-Policy: same-origin和Cross-Origin-Embedder-Policy: require-corp两个响应头。传输方式机制是否拷贝数据量级友好使用门槛结构化克隆深度拷贝后传递副本是小数据1MB无Transferable所有权转移否大数据ArrayBuffer传后原对象无效SharedArrayBuffer共享同一块内存否超大频繁读写需跨域隔离响应头 Atomics给一个直观的体感数字传一个 100MB 的 ArrayBuffer直接postMessage时结构化克隆大概要几百毫秒期间主线程还会卡顿用 Transferable 转移几乎瞬间完成。所以我的经验法则是数据量超过几 MB 就别直接 postMessage 裸传把数据处理成 ArrayBuffer 再转移过去或者干脆在 Worker 里自己去拿数据比如让 Worker 自己发 fetch 请求避免主线程当二传手。如果你有大量数据要从服务器获取更推荐的模式是主线程只告诉 Worker 一个 URLWorker 自己用fetch去请求并解析解析完只把结果摘要传回来。这样主线程连数据的搬运工都不用当彻底隔离。需要补充一个容易忽略的细节Worker 内可以执行fetch但异步回调返回结果后数据还是在 Worker 线程里只有你主动postMessage给主线程主线程才拿得到。也就是说Worker 做的业务越完整主线程越轻松但反过来主线程要拿到最终结果通信这一环永远逃不掉。好的设计是把数据获取 脏活处理 结果结构化全部放进 Worker主线程只负责渲染结果。5. 高性价比实战场景排序、图片处理、大文件解析Web Worker 不是万能药它有自己的适用边界。判断标准很简单任务是不是 CPU 密集型的有没有 DOM 依赖数据交互是不是少而精满足这三个条件的任务丢给 Worker 做性价比最高。第一个典型场景是前端大数据表格的排序和筛选。以前端处理 10 万条记录为例按字段排序、多重条件过滤、逐字段分组统计这些操作在主线程做一次可能就是 500ms 以上用户每次点表头排序都要吃一次卡顿。把数据全量传给 Worker排序和筛选逻辑放 Worker 里主线程只接收最终结果——比如排序后的第一条记录 ID 列表然后渲染那一页的数据。这样用户交互过程始终流畅排序结果的反馈速度实际上没有任何损失。第二个高价值场景是图片处理。Canvas 的getImageData能拿到像素数组但像素运算灰度化、高斯模糊、边缘检测、缩略图生成是典型的 CPU 密集任务。更好的是用OffscreenCanvas——它把 Canvas 操作也搬进了 Worker可以把整个图片处理流水线都扔进后台线程主线程只负责传原始ImageBitmap或者ArrayBuffer。// 主线程 const worker new Worker(./image-worker.js); const img document.getElementById(source); const bitmap await createImageBitmap(img); worker.postMessage({ bitmap }, [bitmap]); // bitmap 被转移给 Worker主线程这边不能再用了 // image-worker.js self.onmessage async (e) { const { bitmap } e.data; const offscreen new OffscreenCanvas(bitmap.width, bitmap.height); const ctx offscreen.getContext(2d); ctx.drawImage(bitmap, 0, 0); const imageData ctx.getImageData(0, 0, offscreen.width, offscreen.height); // 灰度化处理每个像素 const data imageData.data; for (let i 0; i data.length; i 4) { const gray 0.299 * data[i] 0.587 * data[i 1] 0.114 * data[i 2]; data[i] gray; data[i 1] gray; data[i 2] gray; } ctx.putImageData(imageData, 0, 0); const blob await offscreen.convertToBlob({ type: image/jpeg }); self.postMessage({ blob }); };这个例子里的图片处理在后台线程跑页面滚动、按钮点击、loading 动画全部丝滑处理完再收到一个 Blob 展示结果体验跟原生 App 几乎一样。第三个场景是解析大文件。解析 CSV、JSON、Excel 这种纯计算活儿特别适合 Worker。我自己遇到过解析一个 50MB 的 CSV 文件主线程解析用时三秒多期间用户什么都点不了丢给 Worker 之后主线程还能同时渲染一个解析中 67%的进度条。进度反馈的实现方式就是在 Worker 里解析每几千行就postMessage推一次进度主线程收到后更新 UI——当然这么频繁的消息交互注意消息不要太重只传{ progress: 0.67 }这种轻量数据完全没问题。还有一类不能忽略的场景连续的高频计算。比如 WebSocket 推送实时行情后前端要算移动平均线、RSI、MACD 这些指标每一帧数据来都要重新算一遍。这些计算放在 Worker 里能保证计算过程中 UI 永远不会因为算不过来而掉帧。// 主线程只需要这样把每一条新数据推给 Worker socket.onmessage (e) { worker.postMessage({ type: update, data: JSON.parse(e.data) }); };Worker 收到后内部维护计算状态并回传指标结果。注意频繁通信的成本要控制好如果你每秒要更新几十次图表尽量合并消息批量发送而不是一条数据发一次。6. 踩坑实录service worker 注册报错与 Worker 的边界问题前面提到很多人会把 Web Worker 和 Service Worker 搞混。搜 Web Worker 的人经常会搜到一类报错比如最近讨论度很高的这段加载 web 视图时出错: error: could not register service worker: invalidstatee注意这是Service Worker的注册报错跟本文讲的 Dedicated Worker 不是一回事。但这个报错本身很有代表性值得展开说说因为踩的人太多了。could not register service worker里的InvalidStateError通常指向几个原因第一Service Worker 脚本的响应 Content-Type 不对。浏览器要求 service worker 的 JS 文件必须以text/javascript或类似的合法 JS MIME 类型返回。有些静态服务器或 CDN 配置不对把.js文件返回成text/plain注册直接失败。第二当前文档处于非安全上下文。Service Worker 只能在 HTTPS 或localhost环境下注册。你如果在内网 HTTP 地址、或者某个 webview 容器里用了一个没配证书的地址浏览器会直接拒绝。Web Worker 没有这个 HTTPS 限制但 Service Worker 有这一点非常容易让人踩坑。第三注册代码里作用域scope冲突。比如一个已经在example.com/foo/下注册了的 service worker另一个不同源或不同路径的脚本试图覆盖同一个 scope也会抛异常。排错方法也比较直接打开 DevTools 的 Application 面板看 Service Workers 那一栏有没有具体错误信息或者直接访问 service worker 脚本的 URL看浏览器能不能正常显示文件内容、Content-Type 是什么。如果给的报错信息只有一句含糊的英文优先怀疑这两个方向。说完 Service Worker 的坑回到 Web Worker 本身的边界问题。我总结了几个实战中反复出现的坑Worker 脚本加载失败是静默的。不同于普通 JS 报错会红屏Worker 脚本 404 或者语法错误主线程通常只是触发onerror但如果你没挂onerror监听就什么反应都没有代码看起来明明执行了却毫无输出。排查的时候第一个怀疑对象应该是Worker 文件路径是否正确、有没有被打包工具过滤掉。navigator.hardwareConcurrency是 CPU 逻辑核心数别当成无限开线程的许可证。一个 8 核机器上开 8 个 Worker每个 Worker 再跑一个密集计算会让整个系统响应变差因为线程切换和资源争抢的代价可能超过并行收益。数据拆分的线程数一般 2-4 个就足够再多只是在自欺欺人。消息频率过高反而会拖垮主线程。有人把实时渲染做成Worker 每次算完一个点就postMessage一次主线程收到就立刻更新 DOM。结果 DOM 更新频率远高于屏幕刷新率页面照样卡——这次的卡是因为渲染压力而不是计算压力。正确做法是Worker 端做批处理攒一批数据一次性回传或者主线程端做节流收到消息后合并到 requestAnimationFrame 里统一渲染。调试工具方面Chrome DevTools 的 Sources 面板里有个 Workers 子面板可以看到当前页面的所有 Worker选中之后可以打断点、看作用域、看 console 输出。Worker 内部的console.log不会直接出现在默认的 Console 里但会显示在 Worker 的独立调试上下文里。刚开始用 Worker 的人经常以为 Worker 里没打印是因为没执行其实是看错了调试面板。7. 进阶姿势Worker 池、量级评估与优雅降级把单个 Worker 跑得再熟也只是入门。真正到生产环境我一般会做三件事业务拆分彻底、Worker 池管理、兼容降级。先说业务拆分的量级评估。在决定开 Worker 之前先量化一下任务耗时。用performance.now()在主线程跑一次你要挪走的任务如果低于 50ms完全没必要丢 Worker通信开销可能比任务本身还大50ms 到 200ms 之间看场景选表格筛选这种用户高频操作优先考虑超过 200ms 的不用犹豫直接上 Worker。我见过有人把[1,2,3].map(x x*2)这种微秒级操作也丢 Worker纯属过度设计。做 Worker 池是为了处理持续不断的高频任务。单个 Worker 在同一时刻只能处理一条消息如果你短时间内丢给它海量任务它们会排队而且 Worker 内部还要自己做任务调度。更合理的模式是预创建 2-4 个 Worker用主线程做一个简单的任务队列空闲 Worker 自动领取下一个任务class WorkerPool { constructor(workerFactory, size 2) { this.workers []; this.idle []; this.queue []; this.taskId 0; for (let i 0; i size; i) { const worker workerFactory(); const item { worker, resolve: null }; worker.onmessage (e) { const done item.resolve; item.resolve null; this.idle.push(item); this._next(); done?.(e.data); }; this.workers.push(item); this.idle.push(item); } } run(data) { return new Promise((resolve) { const task { data, resolve }; this.queue.push(task); this._next(); }); } _next() { if (!this.queue.length || !this.idle.length) return; const task this.queue.shift(); const item this.idle.shift(); item.resolve task.resolve; item.worker.postMessage(task.data); } }这个池子的策略是每次往空闲 Worker 派发任务没有空闲就排队。由于每个 Worker 都是独立线程任务可以并行执行吞吐量比单个 Worker 串行处理高一大截。实际使用中图片批量压缩、大数据分批解析这种场景Worker 池几乎是标配。再提一下按需创建和销毁。Worker 的创建成本并不低——要加载脚本、初始化引擎实例大概几十毫秒到上百毫秒。所以不要在每次点击时都new Worker()用完就terminate()。高频使用的场景应该预创建并复用偶尔一次的耗时任务用完了再销毁也行。平衡点是看任务频率每秒多次的任务用池每天几次的任务随用随开。最后是优雅降级。虽然 Web Worker 兼容性已经很好了但架不住一些私有 webview 环境、老版本浏览器可能出问题。稳妥做法是先判断能力再决定用不用if (window.Worker) { // 正常用 Worker } else { // 降级主线程直接跑配合 requestIdleCallback 分片 }降级方案里requestIdleCallback配合数据分片处理能让旧环境的主线程间隙性喘息比直接用 setTimeout 拆任务体验好得多。这个 API 虽然看起来复杂实际用起来就是一个回调放在浏览器空闲时执行等有空了再继续下一片。还有个容易被忽视的经验如果 Worker 里要用到第三方库优先选择打包工具把库一起打进 Worker 文件。直接在里面importScripts(https://cdn.example.com/lib.js)虽然也能用但跨域、缓存、版本一致性都是麻烦。用 webpack/vite 的 worker 特性让构建工具自己处理依赖是最省心的路径。说了这么多最后讲一个我自己坚持的习惯我会把所有复杂计算的入口和出口都用 TypeScript 定义好主线程和 Worker 共用同一套类型。这样通信的数据结构一旦变化编译期就能报错不用等到线上哭。另外每次给 Worker 传大数据时我都会先估算数据量超过阈值自动切换成 Transferable 传输这个习惯救了我好几次——传一个 50MB 的内存块用默认方式拷贝主线程一样卡给你看。Web Worker 不是银弹它只是把你从主线程的单灶台困境里解放出来但怎么用好这口新灶台还需要你在真实项目里反复打磨。
返回列表