ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Web Worker实战:解决主线程卡死与前端并行计算

Web Worker实战:解决主线程卡死与前端并行计算 两三年前我接了个需求用户上传一份十万多行的CSV前端要做清洗、去重、按日期分组统计最后把结果画成报表。第一版代码跑起来页面直接白屏十几秒内滚动、点击、动画全部冻结用户的反馈只有四个字网页崩了。其实网页没崩是浏览器主线程被一个长任务彻底堵死连渲染一帧的空闲都挤不出来。那是我第一次认真琢磨 JavaScript 单线程模型的边界也是从那时开始系统接触 Web Worker。之后我在数据解析、图片处理、哈希计算这些场景里反复使用 Worker踩了不少坑也沉淀出一套自己的使用范式。这篇文章就把我对 Web Worker 的理解从 API 到原理完整梳理一遍适合正在被页面卡死困扰、或者想把并行计算能力引入前端项目的同学参考。1. 一次真实的页面卡死单线程模型的代价1.1 事件循环不是魔法排队是本质很多前端同学刚学 JavaScript 时都背过JS 是单线程的但理解这句话的真正含义得先看清浏览器主线程到底在做什么。你在页面上看到的一个标签页背后是渲染进程里的主线程在处理绝大多数事情执行 JavaScript、解析样式、计算布局、绘制像素、处理鼠标键盘事件、触发定时器回调、把网络响应交还给 Promise。这些都是排队共用同一条线程的不是并行发生的。事件循环本质上就是一个任务队列调度器每轮从队列里取一个任务执行完再处理渲染、再取下一个任务。任务越重排在后面的所有事情都会被压住。这个模型平时够用是因为大多数任务的执行时间都很短。可一旦有一个计算量大到需要好几秒的任务插进来整条流水线就堵住了。我当时的场景是这样的用户上传的 CSV 文件有 13 万行我在主线程里用 map 和 filter 做字段清洗再跑一遍去重和按日期分组统计累计耗时大概 9 秒。这 9 秒不是卡了 9 秒而是渲染进程在这 9 秒里几乎无法处理新输入事件用户点哪里都没反应滚动条拖不动连光标闪烁都停了。浏览器进程其实还活着但它表现得像一个死掉的页面。1.2 长任务Long Task是如何扼杀渲染的浏览器渲染有一个硬指标要想视觉流畅每一帧必须在 16.6 毫秒内准备好也就是 60fps。如果主线程上一个任务执行了 100 毫秒这一帧就大概率丢掉了如果执行几秒期间的所有帧都会全丢。Chrome DevTools 的 Performance 面板会把任何超过 50ms 的任务标记成红色 Long Task你可以在火焰图里一目了然地找到是哪个函数吃掉了主线程时间。这块有个容易被忽视的连锁反应长任务不仅阻塞渲染和交互还会连累所有兄弟任务。定时器回调、Promise.then、输入事件的监听都会延后到长任务结束之后才执行。所以一个 9 秒的长任务结束后瞬间会有一大堆积压的回调同时涌入主线程形成一个卡顿尾巴。我后来排查前端性能问题时会习惯性开车三样东西Network 面板里的接口耗时、Performance 面板里的 Long Task、Console 里的警告。三者对照着看才能判断卡顿是网络层导致的还是纯计算导致的。1.3 为什么 setTimeout 救不了你遇到卡顿第一反应很可能是把任务拆成几段每段用 setTimeout 塞进任务队列。这个思路确实可以让长任务碎片化但它改变的只是执行时间分布并没有改变一个事实这些计算依然在主线程排队执行。每次定时器回调跑起来时主线程照样在忙渲染还是会被打断。拆得太细片段切换本身有额外开销拆得太粗卡顿依然明显。requestIdleCallback 也是类似的逻辑它只是在浏览器空闲时执行回调一旦你的任务重到超过空闲窗口它依然会把主线程拖垮。所以这类分片方案只能处理毫秒级的小任务对于真正需要几秒的重活治标不治本。真正需要的是一条与主线程平行的执行路径。浏览器为此提供的标准方案就是 Web Worker创建另一个线程来运行独立脚本主线程与 Worker 之间只通过消息通信。这里有一个关键区别——主线程和 Worker 线程各自拥有独立的全局作用域和事件循环主线程再忙也不会阻塞 Worker 里正在执行的算法反过来Worker 里跑再久的循环也不会直接阻塞页面的渲染。理解到这一层才算摸到了 Worker 的起点。2. 三种 Worker 形态专用、共享与服务型2.1 Dedicated Worker大多数场景的默认选择平时大家说用 Web Worker默认指的就是 Dedicated Worker也就是专用 Worker。它和创建它的页面是一对一关系页面通过new Worker(url)创建它之后用postMessage双向通信。每个页面创建的专用 Worker 相互独立没有直接的数据通道只能通过各自的页面中转消息。这种形态的使用范围最广也是我日常的主力选择。比如你需要做数据清洗、图像像素操作、加密计算都可以丢给专用 Worker。它创建简单、通信模型清晰、生命周期完全由页面掌控没有其他类型的额外约束。如果你不确定该用哪种 Worker就先从专用 Worker 开始。2.2 Shared Worker多标签页间的廉价通信桥Shared Worker 是可以被同源多个标签页共享的 Worker。假设用户同时开着三个页面三个页面都能向同一个 Shared Worker 发消息Worker 内部可以做简单的消息聚合、状态缓存或者跨页面的消息广播。这在某些协作类工具里挺有用比如多标签页同时打开同一份文档可以用 Shared Worker 做本地状态同步的中间层。但实际使用有两道坎。第一它的调试入口比专用 Worker 隐蔽得多Chrome 里要去chrome://inspect面板里找从 DevTools 的 Sources 面板不一定能直接看到第二浏览器兼容差异比专用 Worker 多一些尤其历史版本的 Safari 对 Shared Worker 的支持不够稳定。所以除非你真的需要多标签共享同一份执行环境否则我不建议为了省一点内存就去选它。2.3 Service Worker它根本不是并行计算工具Service Worker 虽然名字里带 Worker但定位完全不同。它像浏览器和网络之间的一个中间层可以拦截 fetch 请求、命中缓存、做离线资源管理还有自己的生命周期install、activate、fetch。它适合做 PWA 的离线缓存、资源预加载、请求的离线兜底但它不适合承担纯 CPU 计算任务也没有和当前页面绑定的直接通信关系。很多同学容易把 Service Worker 和 Dedicated Worker 搞混在项目里想做并行计算却去研究要不要配一个 Service Worker。这里先把目标分清楚你想要并行计算用 Dedicated Worker你想要缓存和离线用 Service Worker。两者可以并存但不要替用。2.4 选型对照表类型创建方式绑定关系通信对象主要用途典型兼容Dedicated Workernew Worker(url)单页面单实例创建它的页面计算型任务、数据处理所有现代浏览器Shared Workernew SharedWorker(url)可跨多个同源标签页多个页面通过 MessagePort多标签页共享状态、消息总线桌面端基本可用旧版 Safari 有坑Service Workernavigator.serviceWorker.register(url)一个源下多个页面共用通过 postMessage 与页面通信离线缓存、请求拦截、推送现代浏览器HTTPS 或 localhost 环境选型时我一般只问两个问题任务是不是纯计算、需不需要跨页面共享。纯计算选专用 Worker需要跨页面共享选 Shared Worker跟网络缓存相关才考虑 Service Worker。3. 从 new Worker 到 terminate()API 与消息协议设计3.1 最小可用示例跑通第一个 Worker先看一段最简单的代码。主线程初始化一个 Worker发一个数组过去Worker 把数组求和的结果传回来。// main.js const worker new Worker(new URL(./sum-worker.js, import.meta.url)); worker.onmessage (event) { console.log(计算结果, event.data); }; const bigArray Array.from({ length: 1000000 }, (_, i) i); worker.postMessage(bigArray);Worker 内部脚本长这样// sum-worker.js self.onmessage (event) { const data event.data; let sum 0; for (let i 0; i data.length; i) { sum data[i]; } self.postMessage(sum); };注意几个细节。第一我用的是new URL(./sum-worker.js, import.meta.url)而不是直接写./sum-worker.js。这样写是为了适配打包器webpack、Vite、Rollup 都能正确解析相对路径还能帮你做文件名 hash如果直接写相对路径在配置了 publicPath 或者部署到子路径时很容易 404。第二type参数我在示例里没写表示这是一个经典脚本 Worker。如果你要用 ES Module 语法比如在 Worker 里import其他模块就要创建模块 Workernew Worker(new URL(./worker.js, import.meta.url), { type: module })。模块 Worker 的import会被 CORS 限制需要同源或者静态服务支持但功能更强。3.2 Worker 内部的作用域与可用能力Worker 内部没有window没有document不能直接操作 DOM。它运行在自己的全局对象self上这个self具备的能力包括postMessage、onmessage、fetch、IndexedDB、crypto、WebAssembly以及OffscreenCanvas等。有一个很实用的点是Worker 里有location但它记录的是创建 Worker 时的脚本 URL不是页面 URL。所以你在 Worker 里通过 location 取不到页面当前的查询参数需要主线程显式把参数发过去。还有一点容易被忽略经典 Worker 里可以用importScripts加载其他脚本模块 Worker 里不行模块 Worker 要像正常 ES Module 一样使用import。Worker 内的代码和主线程之间是文件级隔离的。你不能直接在 Worker 里使用主线程定义好的闭包变量和函数所有需要的数据都得通过消息传过去。开始会觉得麻烦但反过来看也是一种好的约束它逼着你把数据处理逻辑写成独立、可测试的纯函数。3.3 消息协议设计别让数据裸奔很多初学者在主线程和 Worker 之间传数据时直接传裸数组或者裸字符串比如上面的求和例子。但项目复杂了以后裸数据通信会很快失控你分不清一条消息是请求还是响应分不清它属于哪个任务也没法处理 Worker 同时执行多个任务的情况。我的建议是设计一个轻量的消息协议每条消息至少包含三样东西消息类型type、任务标识id、实际负载payload。// 主线程发送请求 worker.postMessage({ type: validateData, id: 1, payload: { rawRows, ruleVersion: v2 } }); // Worker 内部判断 self.onmessage ({ data }) { if (data.type validateData) { const result doValidate(data.payload); self.postMessage({ type: validateDataResult, id: data.id, payload: result }); } };有了id你可以在主线程维护一个 Map 把任务 id 映射到对应的 Promise 的 resolve 函数实现类似 RPC 的调用方式。这样即使 Worker 同时收到十几个不同任务也能正确对号入座。3.4 销毁 Worker 的时机与方式Worker 创建后会一直存活直到页面销毁、你调用worker.terminate()或者 Worker 内部执行self.close()主动关闭。不要小看这个生命周期问题。有些项目里每次任务来了就new Worker任务结束也不 terminate短时间内看不出问题但长时间运行后内存和线程数量都会悄悄上涨。尤其移动端线程调度更敏感Worker 一直挂着会浪费系统资源。我的实践是高频小任务用常驻 Worker处理完任务不销毁留在那里复用低频大任务用一次性 Worker做完就 terminate 或者让 Workerself.close()。要小心一个细节terminate()会立即停止 Worker 正在执行的代码如果 Worker 正在处理一批数据终止后这些计算就丢了所以要在确定不需要 Worker 再提供服务时再调而不是随手一关。4. 通信机制的深水区结构化克隆、可转移对象与共享内存4.1 postMessage 默认是深拷贝结构化克隆的来龙去脉很多人以为postMessage只是发了个引用事实上默认行为是结构化克隆Structured Clone。浏览器会把消息内容序列化在接收线程重新构造一份。这个算法比JSON.stringify强大得多它支持循环引用、Date、Map、Set、TypedArray、Blob、正则表达式等类型而不是像 JSON 那样丢函数、丢 undefined。为什么默认要做深拷贝而不是共享引用因为线程隔离和安全。如果主线程和 Worker 共享同一个对象引用两边修改起来就会互相踩踏数据竞争问题会让你调试到怀疑人生。深拷贝意味着消息发出后原对象可以继续被修改接收方拿到的是独立副本互不干扰。但深拷贝是有代价的数据越大序列化和复制时间越长。你在主线程把一个 50MB 的 ArrayBuffer 通过postMessage发出去主线程会卡顿一段时间这个时间就是拷贝开销。这也是很多项目用了 Worker 还是很卡的原因之一——他们没意识到瓶颈已经转移到了消息传递上。4.2 Transferable Objects移交所爱避免复制如果你要传递的对象是支持转移的可以在postMessage的第二个参数里声明 Transferable 列表。转移不是复制数据而是把一块内存的所有权从当前线程移交到另一个线程接收线程直接拿到原始内存块传输本身接近零拷贝。支持的转移类型包括ArrayBuffer、MessagePort、ImageBitmap、OffscreenCanvas等。举个例子// 主线程 const buffer new ArrayBuffer(5 * 1024 * 1024); // 转移 buffer第二个参数是关键 worker.postMessage({ buffer }, [buffer]); // 转移之后原 buffer 已经被 detached console.log(buffer.byteLength); // 0转移后原线程就不能再访问这块内存了再读写会直接抛错。这让很多不熟悉 Transferable 的人觉得不方便但它带来的性能收益非常大。尤其是图片数据和文件数据这两个场景下转移几乎是必选项。典型的使用姿势是主线程从 input 文件里读取 ArrayBuffer立刻转移给 Worker 解析Worker 处理完毕再把结果缓冲区转移回主线程。整个过程没有一次全量数据拷贝只剩计算本身的开销。4.3 SharedArrayBuffer Atomics真正的零拷贝协作如果你嫌转移不够强还想要主线程和多个 Worker 同时读写同一块内存那就进入了 SharedArrayBuffer 的领域。SharedArrayBuffer 是共享内存它通过postMessage发出去后双方看到的是同一块物理内存任何一方的修改对其他线程都是立刻可见的。但共享内存随之而来的是数据竞争问题两个线程同时写一个变量互相覆盖结果不可预测。所以标准方案是配合Atomics对象使用。Atomics.add、Atomics.store、Atomics.load、Atomics.wait、Atomics.notify这些方法提供了原子操作和线程间等待/唤醒机制。典型模式是生产者消费者// 主线程创建共享内存 const sab new SharedArrayBuffer(4); const arr new Int32Array(sab); // 分发到 worker worker.postMessage(sab);Worker 侧可以这样等待信号// worker.js const arr new Int32Array(event.data); const result Atomics.wait(arr, 0, 0, 3000); // 等待主线程写入 1超时 3000ms if (result ok) { const value Atomics.load(arr, 0); // 继续处理 }这块有一个大前提SharedArrayBuffer 要求页面开启跨源隔离Cross-Origin Isolation也就是需要在响应头里配置COOP: same-origin和COEP: require-corp。原因是当年 Spectre 漏洞利用共享内存做侧信道攻击Chrome 一度禁用了 SharedArrayBuffer后来在隔离条件下重新开放。部署这两层头会带来一系列资源加载限制不是所有团队都愿意接受。说点实际经验SharedArrayBuffer 把复杂性从数据传输转移到了数据同步它要求你写更严谨的并发代码排错难度直线上升。我做过一个小型共享队列后沉重地意识到如果没有极端的性能需求还是优先用 Transferable 转移数据或者用分片 消息的设计来减少大块数据传递。共享内存是最终手段不是第一手段。4.4 通信开销的量化测试与行动准则我在项目里做数据分片时顺手测了几组通信耗时。把一段 2MB 的数据从主线程发到 Worker用结构化克隆默认发送大约耗时 30ms 到 80ms取决于机器和浏览器。用 Transferable 转移发送耗时不到 1ms几乎可以忽略。用 SharedArrayBuffer 共享发送本身 0ms但同步逻辑和并发设计要花额外的开发成本。这个结果基本决定了我的行动准则如果单次消息低于 100KB直接用默认 postMessage简单可靠。如果单次消息是 MB 级别且一次性处理用 Transferable。如果需要频繁双向读写一块数据才考虑 SharedArrayBuffer 加 Atomics。永远不要在主线程里为了传数据先做一次大 JSON.stringify那等于把拷贝和解析的开销全压回主线程。5. 实战把三大类耗时任务真正搬进 Worker5.1 大数据集解析CSV/JSON 的增量处理回到我最初那个 CSV 需求。第一次实现是在主线程一次性解析9 秒卡死。第二次我改成 Worker 解析但把 13 万行一次性postMessage过去主线程一样卡因为序列化 13 万行字符串本身就要一两秒。正确的做法是分片。把文件切成多个块一个块一个块地转移给 WorkerWorker 解析完一块就发回一块结果主线程边收边渲染。这样主线程永远不会一次性被大对象卡住配合进度条还可以让用户看到正在解析 35%的反馈体验完全不一样。// 主线程分片读取 const file fileInput.files[0]; const CHUNK_SIZE 1024 * 1024; // 1MB let offset 0; function sendNextChunk() { if (offset file.size) { worker.postMessage({ type: parseEnd }); return; } const blob file.slice(offset, offset CHUNK_SIZE); const reader new FileReader(); reader.onload () { worker.postMessage({ type: parseChunk, payload: reader.result }, [reader.result]); offset CHUNK_SIZE; sendNextChunk(); }; reader.readAsArrayBuffer(blob); }这里reader.result是 ArrayBuffer直接放进转移列表避免拷贝。Worker 侧接收后按块累积字符串、切割行、逐行清洗去重最后汇总返回统计结果。同样是 13 万行总耗时还在 5 秒左右但主线程全程有响应用户感觉就是稍等一下而不是网页死了。5.2 图像处理像素级操作与 OffscreenCanvas图像处理是 Web Worker 最能体现价值的地方之一因为像素数据天然是大块二进制。比如你要做一个图片滤镜功能拿到原始图片后先在主线程用createImageBitmap生成位图然后直接把ImageBitmap转移给 WorkerWorker 里用OffscreenCanvas做像素级处理。// 主线程 const bitmap await createImageBitmap(file); const worker new Worker(new URL(./filter-worker.js, import.meta.url)); worker.postMessage({ type: applyFilter, bitmap }, [bitmap]);Worker 内部// filter-worker.js self.onmessage async (event) { if (event.data.type applyFilter) { const bitmap event.data.bitmap; const canvas new OffscreenCanvas(bitmap.width, bitmap.height); const ctx canvas.getContext(2d); ctx.drawImage(bitmap, 0, 0); const imageData ctx.getImageData(0, 0, canvas.width, canvas.height); const data imageData.data; for (let i 0; i data.length; i 4) { // 一个简单的灰度滤镜 const gray data[i] * 0.299 data[i 1] * 0.587 data[i 2] * 0.114; data[i] data[i 1] data[i 2] gray; } ctx.putImageData(imageData, 0, 0); const outBitmap await canvas.transferToImageBitmap(); self.postMessage({ type: filterResult, bitmap: outBitmap }, [outBitmap]); } };这一套组合拳下来主线程全程没有触碰像素数据只在最后接收一个处理完的ImageBitmap渲染直接把它放进 canvas 或者img。用户拖拽图片、调整参数的过程中页面甚至能保持 60fps。在我做过的编辑器类项目里这个模式是图像增强、裁剪压缩、滤镜调试的标配。5.3 高频计算哈希、加密与数据校验还有一类任务是哈希计算、加密摘要、文件校验。比如大文件上传前需要计算 SHA-256文件是几百 MB如果放主线程算页面必卡。这种情况下 Worker 配合crypto.subtle或者纯 JS 哈希库都行在 Worker 里逐块读取文件、更新哈希上下文最后返回摘要。WebAssembly 也有用武之地如果你把压缩、解压、图像编码这些 C/Rust 库编译成 wasm加载进 Worker就可以让 Auditor 在主线程完全无感的情况下完成高密度计算。我试过在 Worker 里跑一个 wasm 压缩库处理 20MB 文本页面始终流畅只有进度条在走。5.4 什么场景不值得用 Worker听我讲了这么多也要泼盆冷水并不是所有耗时的代码都应该丢进 Worker。一些小任务比如数组排序、字符串替换、几百毫秒的循环放入 Worker 反而更慢因为线程创建、上下文切换、消息序列化带来的开销可能超过任务本身的计算时间。另外如果你需要频繁和主线程同步 UI 状态比如每 10ms 就要从 Worker 拿一次进度除非用 SharedArrayBuffer否则消息一来一回的延迟会让你的交互比原来更卡。我见过一个项目把每次按键的补全计算全部交给 Worker结果因为通信延迟输入框明显变笨。最后我们把阈值计算放回主线程把重量级索引构建留在 Worker两边都舒服了。有一个经验可以参考单个任务如果预计耗时小于 50ms先别考虑 Worker耗时长但可以分块的任务考虑分块 消息反馈而不是一次性丢进去整个流程超过 200ms 又完全不需要操作 DOM 的纯计算才比较适合完整迁移进 Worker。6. 调试、错误处理与性能细节6.1 DevTools 里的 Worker 调试入口Worker 的特性让它天然比主线程难调试但现代浏览器的工具链已经挺完善了。Chrome 的 DevTools 里打开 Sources 面板左侧能看到 Worker 列表点击对应 Worker 的脚本就可以在里面打断点、单步执行、查看作用域变量。Console 面板左上角有一个上下文切换下拉框可以从主页面上下文切换到某个 Worker 的全局上下文这样你可以直接在 Worker 的 console 里执行表达式、查看self上的变量。有个坑我得提一下如果你每次临时new Worker(blobUrl)创建 WorkerDevTools 里可能不会保留清晰的脚本名排查问题会比较费劲。所以生产环境可以优化 Worker 的加载方式但调试时尽量用独立文件让 Source Map 正确映射。打包器一般会生成.worker.js和对应的 sourcemap你在 Sources 面板里能看到原始 TypeScript 源码再打断点这个体验很关键。6.2 错误传播链路error、unhandledrejection 与超时Worker 里抛出的错误不会自动弹到主线程的 try/catch 里。你需要监听error事件worker.onerror (event) { console.error(Worker 错误, event.message, event.filename, event.lineno); };一个容易漏的点是Worker 内部的 Promise 如果有未捕获的 rejection主线程的unhandledrejection监听器不一定能收到。你需要同时在 Worker 的内部作用域里监听自己的unhandledrejection再把它通过postMessage报告给主线程。我的做法是在 Worker 脚本入口统一包装异常上报self.onerror (e) { self.postMessage({ type: workerError, message: e.message || String(e) }); }; self.addEventListener(unhandledrejection, (e) { self.postMessage({ type: workerError, message: e.reason?.stack || String(e.reason) }); });另一个实践是给 RPC 式消息加超时控制。主线程发任务给 Worker 后如果 Worker 因为逻辑错误进入死循环主线程会永远等下去。用Promise.race给每个任务 id 设定超时时间超时后先提示错误再根据业务决定是否调用terminate()和重建 Worker。6.3 线程池思想用块状调度而不是无限并发Worker 虽好但线程不是免费的。浏览器对同一页面可创建的 Worker 数量有限制具体取决于设备 CPU 核心数和浏览器策略但通常你不需要真的去顶上限。我建议的方式是做一个小型线程池固定创建 N 个 WorkerN 取navigator.hardwareConcurrency - 1和 4 之间的较小值然后由一个任务队列统一分配工作。这样做的原因是如果同时丢给系统太多线程上下文切换和内存开销反而会让总吞吐量下降。比如四核设备上开 8 个 Worker 跑纯计算每个 Worker 都在互相抢 CPU 时间片最后算下来可能比开 4 个更慢。线程池还有一个附带好处任务优先级更好控制。你可以让紧急任务插队而不是坐等当前任务跑完。6.4 不要在生产环境忽视的低版本浏览器问题Web Worker 本身在现代浏览器里支持得很好了但仍有三类环境要额外注意。第一IE 已经退出历史舞台但如果你的存量项目还在服务 IE 用户Worker 相关代码要做好 capability detecttypeof Worker undefined时降级为主线程执行配合 loading 提示。第二Worker 脚本的加载受 CORS 规则限制跨域资源必须用支持 CORS 的方式加载离线场景下直接file://打开本地 HTML 永远无法加载 Worker。第三CSP 的worker-src指令会限制 Worker 脚本来源项目启用了严格 CSP 时要确认已经放行了对应的域名或者使用 blob。模块 Worker 的兼容问题也比较微妙。虽然现代浏览器都支持{ type: module }但有些打包产物在低版本浏览器上降级时未必能正确解析模块 Worker。如果你需要兼容老环境优先用经典 Worker或者使用打包器提供的 worker 插件自动处理降级。7. 一些我觉得值得单独说的经验最后分享几条我在实际项目里积累下来的经验不按技术难度排按踩坑频率排。第一条是别用 Worker 干所有的事情。我见过不少团队一旦学了 Worker恨不得把每个函数都丢进去跑一遍。但 Worker 的通信模型决定了它适合大任务、低频、消息简单的场景不适合小计算、高频、状态同步的场景。写代码前先问自己这个任务是不是真的不依赖 DOM是不是真的需要好几秒答案都是肯定的再用 Worker 才合理。第二条是创建 Worker 的位置要慎重。不要让 Worker 在模块加载的最顶层就立刻创建有些页面根本不执行重计算白白浪费线程资源。我习惯在用户触发了某个耗时操作时才创建 Worker操作结束且长时间不会再用时及时 terminate。第三条是善用 blob URL 做内联 Worker。某些场景下你要把 Worker 脚本作为字符串内联进主 bundle减少额外的网络请求。比如开发环境热更新频繁或者 Worker 脚本很小都可以用这个方案const workerCode self.onmessage (e) { const result e.data.reduce((a, b) a b, 0); self.postMessage(result); }; ; const blob new Blob([workerCode], { type: application/javascript }); const url URL.createObjectURL(blob); const worker new Worker(url);但注意blob URL 创建 Worker 时Worker 内部是拿不到 Source Map 的调试体验会差一些。所以我只在实际构建产物需要极致优化时才用开发阶段还是保留独立文件。第四条是给 Worker 脚本起好名字。打包之后所有文件可能都被打成一个 hash 文件线上排查问题很难受。用new Worker(new URL(./worker.js, import.meta.url))这种写法打包器会单独输出一个带清晰命名的 worker 文件线上看到的是类似worker.abc123.js尚可辨认是 worker 脚本。回到最开始那个 CSV 项目。改造完 Worker 分片解析后用户上传同样大小的文件页面能保持响应还能实时看进度条反馈从网页崩了变成虽然有点慢但能用。后来我又把图像压缩、批量哈希也搬进了 Worker那套模块现在还在线上稳定跑着。如果你正在被主线程卡顿折磨我的建议很直接先打开 Performance 面板确认给你长任务的是哪段代码确认它是纯计算后再照着这篇文章里的方案去建一个 Worker。先从分片传数据开始别急着上 SharedArrayBuffer绝大多数场景下 Transferable 消息协议已经够用。跑通之后你会有一种很直观的感觉浏览器不是只能在一个线程里吭哧吭哧干活它其实早就给你准备好了另一条路。
返回列表