ARTICLE DETAIL

资讯详情

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

Web Worker 从原理到实战:一键解决 JavaScript 单线程卡顿问题

Web Worker 从原理到实战:一键解决 JavaScript 单线程卡顿问题 做前端的时间一长你大概率会遇到这样一个场景页面上某个按钮被用户点了一下随后要处理一个巨大的数据集合或者要给一个几百 MB 的文件计算指纹。你满怀信心地写下一段 for 循环结果页面直接白屏好几秒用户以为程序挂了实际上是你把主线程堵死了。这就是 JavaScript 单线程模型的宿命。浏览器里所有 UI 渲染、事件响应、脚本执行都挤在同一条线程上任何一个长时间运行的同步任务都会让整个页面进入“假死”状态。而 Worker 就是官方给出的解药它允许你把那些耗时的计算任务丢到后台线程去跑让主线程专心做渲染和响应。我最早接触 Worker 是因为要给一个大文件上传功能算 SHA-256 指纹。没上 Worker 之前2GB 的文件一算页面直接卡成 PPT上完 Worker 之后用户该干嘛干嘛进度条照样转体验完全不一样。这篇文章就把 Worker 从原理到实战掰开揉碎讲清楚不管你是刚入门的前端新手还是被性能问题折磨过一段时间的老手都能在这里找到能直接用的东西。1. 为什么需要 Worker先看懂单线程的困境1.1 浏览器的主线程到底在忙什么很多人以为“JavaScript 是单线程的”只是一个面试题其实它直接影响着你每天写的每一行代码。浏览器里有个概念叫主线程Main Thread它要同时负责三件大事执行 JS 脚本、解析和渲染页面、处理用户输入事件。你可以把主线程想象成一家餐厅里唯一一个既当厨师又当服务员的人。他既要炒菜跑 JS 逻辑又要上菜渲染页面还要招呼客人响应点击、滚动、输入。正常情况下他手脚麻利一切正常但一旦炒一道硬菜花了十分钟客人的需求全被晾在一边——这就是页面卡顿的本质。这个设计不是拍脑袋想出来的而是历史遗留问题。当年浏览器脚本语言需要考虑的问题是“别让两个脚本互相改数据”单线程天然能避免大部分竞态条件实现起来也简单可靠。但这个设计也意味着你没有“多开几个厨师”的原生手段只能靠事件循环Event Loop在同一个线程里排队干活。1.2 卡顿的根源长任务是怎么阻塞渲染的来点更直观的。你打开浏览器控制台跑一段这样的代码function heavyTask() { let sum 0; const start performance.now(); for (let i 0; i 100000000; i) { sum i; } const end performance.now(); console.log(耗时, end - start, ms); } heavyTask();一亿次循环在普通机器上大概要 500ms 到 1500ms。这段时间里主线程被这个同步任务完全占据。浏览器其实每一帧都要完成“执行任务 → 更新样式 → 布局 → 绘制”的流程一般是 16.7ms 一帧。可你的循环一口气跑了几百毫秒浏览器根本没机会执行下一帧的渲染于是用户看到的就是按钮按下去没反应、滚动条拖不动、连光标都不闪了。Chrome 的 Performance 面板里这种问题看得特别清楚。录制一段操作你会看到一条长长的红色长任务Long Task上面能看到heavyTask这个执行栈任务时长远超 50ms。浏览器有个不成文的建议单个任务最好不要超过 50ms否则用户就会感觉“卡了一下”。而现实是数据解析、加密、压缩、图片像素处理这些活儿动辄就是几百毫秒起步。1.3 Worker 能干什么不能干什么Worker 的思路不是优化那顿饭的做法而是再请一个厨师。你在 Worker 线程里可以随便跑循环、算哈希、处理二进制数据这些计算不再占用主线程也就不会阻塞渲染。但这里我要泼一盆冷水很多人以为 Worker 是万能的性能银弹其实不是。Worker 解决的核心问题只有一个——CPU 密集型任务的阻塞问题。至于网络请求、文件读取这类 I/O 任务它们本身是异步的不阻塞主线程你用不用 Worker 差别不大。还有一点要注意Worker 不是“零成本”的。每创建一个 Worker 都要初始化一个新的线程环境主线程和一个 Worker 之间传递数据也需要序列化和拷贝后面会详细讲。如果你只是跑一个 5ms 的小任务就 new 一个 Worker光线程创建的开销可能都比任务本身还大。这种场景老老实实在主线程跑就行。2. Worker 家族梳理三种类型的定位差异2.1 Dedicated Worker最常用的“专属打工人”Dedicated Worker 是大家平时说的“Worker”默认指向的东西。它的特点是一对一一个主线程实例对应一个 Worker 实例互相之间只能彼此通信别的线程插不上话。常规用法就是在主线程里new Worker(url)然后通过postMessage发消息、onmessage收消息。Dedicated Worker 适合绝大多数场景复杂计算、数据处理、大文件分片、图像处理、音视频解码之类的。以我的经验80% 的 Worker 需求用 Dedicated Worker 就够了。它概念简单、调试方便、兼容性最好。2.2 Shared Worker多页面共享的“公用计算员”Shared Worker 的特色是多个同源页面比如两个标签页打开了同一个站点可以共享同一个 Worker 实例。这意味着你花一次初始化成本就能在多页面之间共享计算状态和资源。它适合什么场景呢典型的是多标签页之间的状态同步或者让多个页面共用同一个耗时的数据预处理任务。比如你在一个页面里筛选了一份很大的数据另一个页面打开时不用重新算直接从 Shared Worker 里拿结果就行。不过 Shared Worker 的通信方式稍微绕一点主线程拿到的是port要通过port.postMessage发消息而且要先port.start()把端口打开。另外Shared Worker 在移动端 Safari 的兼容性一直不太好如果是做移动端项目建议先确认目标用户的浏览器范围再决定用不用。2.3 Service Worker管网络和缓存的“前台经理”Service Worker 虽然名字里带 Worker但它和上面两个定位完全不同。它不干计算而是专门拦截网络请求、管理缓存是 PWA渐进式 Web 应用的基建。你平时听到的“离线缓存”“消息推送”“后台同步”都是它在干活。Service Worker 的特点是没有 DOM、不直接和页面通信而是靠拦截 fetch 事件来介入网络流程也可以配合 Cache API 做资源预缓存。严格来说它更像是一个运行在浏览器里的网络代理层。2.4 三种 Worker 怎么选一张表看懂类型数量关系核心定位适合场景注意事项Dedicated Worker一对一后台计算复杂计算、文件处理、数据解析最简单最常用Shared Worker一对多同源多个页面跨页面共享状态多标签页同步、共享预处理结果移动端兼容性一般Service Worker一对多代理网络网络拦截与缓存离线缓存、PWA、请求转发需要 HTTPS无法直接拿页面数据如果你是想解决“页面卡死”第一反应永远是 Dedicated Worker。Shared Worker 是在“多个页面想要共同协作”时才值得引出场的角色。Service Worker 则属于另一个技术栈通常单独成文去聊。3. 手把手跑通一个 Dedicated Worker3.1 最简单可用的 Worker 代码先来一个最小可运行示例。假设你的项目根目录下有两个文件// main.js const worker new Worker(./worker.js); worker.onmessage (e) { console.log(收到 Worker 的结果, e.data); }; worker.postMessage({ type: calc, payload: [1, 2, 3, 4, 5] });// worker.js self.onmessage (e) { const { type, payload } e.data; if (type calc) { const result payload.reduce((acc, cur) acc cur, 0); self.postMessage({ type: result, data: result }); } };在 Worker 内部self指代 Worker 自身的全局对象。这里要注意不要在 Worker 里用window因为根本没有这个东西。Worker 有它自己的一套全局环境叫WorkerGlobalScope提供了self、postMessage、onmessage、fetch等能力但没有 DOM。主线程里worker.postMessage(...)往 Worker 发消息Worker 的self.onmessage收到后处理完再用self.postMessage(...)把结果发回主线程主线程的worker.onmessage接住。这套“你来我往”的消息模式就是 Worker 通信的全部秘密。用完记得要清理如果不再需要这个 Worker 了调用worker.terminate()立即终止它的生命周期。Worker 不会自动销毁哪怕你不再引用它它也可能在后台继续挂着。这算是我见过最常见的 Worker 内存泄漏来源之一。3.2 用 ES Module 的 Worker代码组织更舒服如果项目里用了 ES Module你可以在创建 Worker 的时候指定type: moduleconst worker new Worker(./worker-module.js, { type: module });这样 Worker 文件内部就能正常使用import、export语法了。比如你有一个现成的加密库、解析库直接在 Worker 里import进来用不需要像以前那样靠importScripts去加载脚本。importScripts是老式的同步加载方式它的问题是只能加载传统脚本不能加载 ES Module而且同步加载会阻塞 Worker 内部后续代码执行。所以如果你的代码已经全面 Module 化尽量直接用{ type: module }。提醒一下Module Worker 的加载遵循同源策略file://协议下经常会报跨域错误。本地测试请起一个本地服务比如npx serve或 Vite 的开发服务器别直接双击 HTML 文件。3.3 内联 Worker不想多出一个文件怎么办有些场景下Worker 的代码量很少单独建一个文件显得很浪费。这时候可以用 Blob 加 URL 的方式把 Worker 代码“内联”在 HTML 或主脚本里const workerCode self.onmessage (e) { self.postMessage(e.data * 2); }; ; const blob new Blob([workerCode], { type: application/javascript }); const workerUrl URL.createObjectURL(blob); const worker new Worker(workerUrl); URL.revokeObjectURL(workerUrl); worker.onmessage (e) { console.log(翻倍后的结果, e.data); }; worker.postMessage(21);这里有个容易踩的坑很多人习惯在new Worker()之后立刻调用URL.revokeObjectURL结果发现 Worker 不工作了。原因是浏览器可能还没来得及用这个 URL 加载脚本你把 URL 撤销了加载自然失败。稳妥的做法是等到 Worker 第一次onmessage回调触发之后再撤销或者干脆不撤销——反正内联 Worker 通常是短生命周期的多一个临时 URL 影响不大。这种内联方式适合代码量小的场景比如一个简单的防抖处理、一段滤波计算。代码量一多还是老老实实拆文件维护起来清爽得多。4. 主线程和 Worker 之间的通信细节4.1 postMessage 背后的“结构化克隆”Worker 通信的核心就是postMessage。它和普通的函数传参完全不一样数据不是简单引用传递而是先被序列化再到另一个线程反序列化。这个序列化算法叫结构化克隆Structured Clone Algorithm。结构化克隆比传统的JSON.stringify强得多。它支持 Date、RegExp、Map、Set、ArrayBuffer、Blob、File、ImageData 这些复杂类型还能处理循环引用。比如你可以直接把一个File对象从主线程传给 Workerconst fileInput document.getElementById(fileInput); fileInput.addEventListener(change, (e) { const file e.target.files[0]; worker.postMessage({ type: processFile, file: file }); });Worker 里拿到的file是一个独立的克隆副本可以正常读取大小、类型、调用切片方法。这正是后面大文件上传场景能成立的基础。但克隆也有代价数据越大序列化和拷贝的开销就越大。往主线程和 Worker 之间来回传一个几十 MB 的大对象本身就要花掉几十毫秒。所以通信的直觉应该是尽量传“任务”和“结果”而不是传“过程数据”能传索引就不传整表能传摘要就不传原文。4.2 可转移对象ArrayBuffer 的零拷贝通道结构化克隆是把数据复制一份给你如果你传的是 ArrayBuffer 这种动辄几十上百 MB 的二进制数据复制开销是很肉疼的。这时候需要用“可转移对象Transferable Objects”。postMessage其实接收两个参数第一个是消息本体第二个是转移列表// 主线程 const buffer new ArrayBuffer(1024 * 1024 * 200); // 200MB 的二进制数据 const worker new Worker(./buffer-worker.js); worker.postMessage({ type: process, data: buffer }, [buffer]);注意第二个参数[buffer]就是转移列表。一旦你把一个 ArrayBuffer 放进转移列表这个 buffer 的所有权就被移交给 Worker主线程这边的原对象会被“掏空”变成 detach 状态。你再去buffer.byteLength得到的会是 0。转移的意义在于数据不需要复制内存所有权直接换手传输耗时接近零。200MB 的二进制转移方式比克隆方式快一个数量级以上。代价是你不能再访问原对象了有点“东西送出去了就别想再要回来”的意思。什么数据是可转移的ArrayBuffer、MessagePort、ImageBitmap、OffscreenCanvas等。普通对象不能转移只能克隆。所以常见的姿势是把二进制数据放进一个 ArrayBuffer用第二个参数转移同时再用第一个参数传一个描述性的普通对象比如{ type: process }。4.3 SharedArrayBuffer 与 Atomics共享内存的进阶玩法除了消息传递还有一条更“硬核”的通信路线SharedArrayBuffer。它和 ArrayBuffer 的区别是它本身就是共享内存不参与转移也不复制主线程和 Worker 都能同时读写同一块内存。// 主线程 const sharedBuffer new SharedArrayBuffer(1024 * 1024); worker.postMessage({ type: init, shared: sharedBuffer });之后两边就能直接操作这个sharedBuffer对应的视图。但共享内存意味着并发读写你可能读到写到一半的脏数据所以必须搭配 Atomics 对象来保证原子操作// 用 Atomics 做原子递增避免两个线程同时写坏数据 Atomics.add(new Int32Array(sharedBuffer), 0, 1);SharedArrayBuffer 适合高频、大批量数据交换的场景比如视频帧处理、音频波形分析数据量太大、回传太频繁用消息机制会打满带宽。但它有一个比较疼的准入门槛需要页面处于“跨源隔离cross-origin isolated”状态也就是服务器要设置Cross-Origin-Opener-Policy和Cross-Origin-Embedder-Policy这两个响应头。如果你不控制服务器的响应头这基本用不了。以我的经验项目里能用消息机制扛住就别轻易上 SharedArrayBuffer。它调试成本高、心智负担大真到了万不得已再考虑。4.4 双向通信的时序问题和消息风暴消息通信是事件机制消息到达的顺序一般是发送顺序FIFO但有一种情况会坑到你Worker 初始化是需要时间的。主线程脚本往下执行new Worker()立刻返回但你紧接着postMessage这时候 Worker 的脚本可能还没加载完onmessage还没注册上消息就发过去了。好消息是浏览器其实会把消息排队等 Worker 脚本执行完、事件监听器准备好之后再依次投递。这在绝大多数情况下不会丢消息。但如果你在 Worker 里异步加载资源、或者依赖 Worker 内部完成某些初始化后才能处理任务直接发消息可能会遇到“消息到了但 Worker 还没准备好”的尴尬。我推荐的稳妥做法是做一个“握手协议”Worker 内部初始化完成后主动向主线程发一条{ type: ready }消息主线程收到之后再正式抛任务。这样也能顺便确认 Worker 没报错。消息风暴是另一个实际问题。Worker 处理完一个任务就postMessage一次如果任务是高频小批量比如每秒几百次消息序列化和投递本身会变成新的瓶颈。这时候要么把结果攒成一堆一次性发回来要么只回传聚合后的摘要。多想想“我到底需要实时知道每一步还是只要最终结果”能省掉很多不必要的通信开销。5. 实战大文件上传时的 Worker 用法5.1 为什么要用 Worker 算文件哈希大文件上传可以说是 Worker 最经典的应用场景之一。现在的上传功能普遍要做这几件事对大文件进行分片、计算每个分片的哈希用于断点续传、最后合并文件时校验完整性。问题出在哈希计算上。以 SHA-256 为例一个 2GB 的文件在普通笔记本上用 JS 算一遍整体摘要轻轻松松就是好几秒钟的 CPU 密集型任务。如果在主线程算页面这段时间就是“白屏期”。而文件上传本身就是异步的算哈希完全可以在 Worker 里进行主线程该做什么做什么。而且分片上传还有个隐藏需求计算分片哈希的过程本身可以切得更细边算边回报进度。这种事放在 Worker 里做主线程只需要被动接收进度事件然后更新一个进度条就行。5.2 分片哈希的完整实现下面我给一个可以直接改来用的完整实现。主线程部分负责接收文件、创建 Worker、汇报进度// main.js async function computeFileHash(file) { const CHUNK_SIZE 2 * 1024 * 1024; // 2MB 一个分片 const chunkCount Math.ceil(file.size / CHUNK_SIZE); const worker new Worker(./hash-worker.js); return new Promise((resolve, reject) { worker.onmessage (e) { const { type, percent, hash } e.data; if (type progress) { updateProgressUI(percent); } else if (type done) { resolve(hash); worker.terminate(); } }; worker.onerror (err) { reject(err); worker.terminate(); }; worker.postMessage({ type: start, file: file, chunkSize: CHUNK_SIZE, chunkCount: chunkCount, }); }); }Worker 内部代码// hash-worker.js self.onmessage async (e) { const { type, file, chunkSize, chunkCount } e.data; if (type ! start) return; const crypto self.crypto.subtle; const chunks []; let processed 0; for (let i 0; i chunkCount; i) { const start i * chunkSize; const end Math.min(start chunkSize, file.size); const slice file.slice(start, end); const buffer await slice.arrayBuffer(); chunks.push(buffer); processed; self.postMessage({ type: progress, percent: Math.round((processed / chunkCount) * 100), }); } // 把所有分片拼起来做一次整体摘要也可以改为逐块增量哈希 const merged new Blob(chunks, { type: application/octet-stream }); const hashBuffer await crypto.digest(SHA-256, await merged.arrayBuffer()); const hashArray Array.from(new Uint8Array(hashBuffer)); const hashHex hashArray.map((b) b.toString(16).padStart(2, 0)).join(); self.postMessage({ type: done, hash: hashHex }); };这段代码有一个关键细节self.crypto.subtle.digest返回的是 Promise它是异步的不会阻塞 Worker 内部的消息循环所以你在 await 的同时还能正常接收主线程新发来的消息。更讲究的做法是对每个分片算独立的哈希形成一个哈希列表这样服务端可以逐个校验分片是否上传正确也方便断点续传时跳过已经传好的分片。我这里为了演示简洁把所有分片合并后算了一个整体摘要实际项目中你可以把digest调用挪到循环里去每个分片算一个哈希再 push 进数组。还有一点值得注意这里用的是file.slice().arrayBuffer()这就意味着每读一个分片内存里就多一份该分片的 ArrayBuffer。2MB 的分片不算什么但如果你把分片调得很大比如 64MB连续读几个分片后内存压力会暴涨。真遇到超大文件建议分片小一点或者及时处理完一个分片就释放引用。5.3 上传分片与进度反馈哈希算完接下来就是上传。我见过两种做法一种是在 Worker 里算完哈希后把结果发回主线程主线程负责用 fetch 上传各个分片另一种更激进把整个上传逻辑也搬进 WorkerWorker 里直接 fetch 上传。我个人建议大部分项目用第一种原因有二上传进度需要用到XMLHttpRequest的upload.onprogress或者 fetch 加 ReadableStream 的方式回报进度在主线程操作这些 API 更顺手另外主线程能随时根据用户操作比如点击取消中断上传控制逻辑更集中。不过有一点实践的体会如果你已经用 Worker 算了哈希分片上传本身就不要再用主线程同步处理了。每个分片用一个 fetch 上传本身是异步的主线程不会被阻塞但如果你一次性把所有分片都并发发出去几十个请求同时打向服务端反而容易撑爆连接数。建议用一个小型“并发池”一次只跑 3 到 5 个上传任务完成一个再补一个上传速度既稳定又不会把浏览器拖垮。Worker 在这里的价值就是把最吃 CPU 的哈希计算剥离出主线程同时把进度和结果通过消息机制清爽地抛给 UI 层。这才是真正符合前端工程实践的做法。6. 踩坑汇总Worker 实践中的高频问题6.1 Worker 里拿不到 window、document、localStorage这是新手一定会踩的坑。Worker 的全局环境是WorkerGlobalScope只有self。你在 Worker 里写window.addEventListener直接报ReferenceError: window is not defined。Worker 里没有的东西包括但不限于window、document、DOM元素、alert、confirm、localStorage、sessionStorage。有替代方案的是本地存储可以用 IndexedDB网络请求可以用fetch和XMLHttpRequest定时器可以用setTimeout和setInterval这些在 Worker 里都是可以用的。我见过有人在 Worker 里试图读取localStorage做鉴权折腾半天无果最后改成在主线程读取后通过postMessage传进去问题立刻解决。记住一条铁律Worker 把它需要的所有上文信息都显式地从主线程传进来不要指望它自己能“看到”页面上的任何东西。6.2 消息丢失、消息乱序与重复处理消息队列理论上是有序的但“有序到达”不代表你的逻辑不会出乱子。最容易翻车的点是主线程发了一个任务Worker 处理到一半抛异常了结果异常没被捕获主线程那边onmessage永远等不到结果形成“假死”。所以我在写 Worker 通信的时候习惯性做三件事一是 Worker 内部任何可能出错的逻辑都用try/catch包住出错时发一条{ type: error, message }回来二是主线程一定要挂worker.onerror它能捕获 Worker 内未处理的运行时错误三是给每条消息加一个递增的requestId主线程收到结果后可以根据requestId匹配是哪个任务的回执避免多个并发任务结果串台。// 主线程里给每次发送的任务编号 let requestCounter 0; function sendTask(worker, payload) { const requestId requestCounter; worker.postMessage({ requestId, payload }); return requestId; }6.3 用 DevTools 调试 Worker 的姿势不少人在主线程代码里用console.log用得很顺一到 Worker 就抓瞎。其实 Chrome DevTools 是能直接看到 Worker 的Console 面板左上角有个上下文切换下拉框里面会出现 Worker 的条目切过去就能直接在那个全局作用域里执行代码、看变量。Sources 面板里可以打开 Worker 脚本文件打断点调试方式和普通 JS 完全一样。Performance 面板录制时能看到 Worker 线程单独占一条泳道方便分析它到底花了多少时间。我自己调 Worker 最快的方式其实是在代码里随手打console.log。Worker 里的console.log会输出到 DevTools 的 Console 里并且会带有脚本名和行号前缀比如hash-worker.js:12。看消息顺序、看耗时不一定要打断点。还有一个调试上的小坑开发环境下如果用了打包工具的 HMR热更新Worker 的缓存经常导致你怎么改代码都不生效。遇到这种玄学问题先用无痕窗口开一次大概率能确认是缓存问题。6.4 什么时候不该用 Worker最后分享一点价值观层面的经验。Worker 不是万金油以下情况我劝你三思一是任务本身极短。几十毫秒以内的计算直接在主线程做创建 Worker 的初始化成本可能就要几十毫秒反而倒亏。二是一天跑不了几次的低频任务。页面初始化时跑一次的数据预处理如果耗时在半秒以内用户感知不强没必要引入多线程的复杂度。三是你的代码里大量生命周期是短命的临时 Worker。每 new 一次就有一份线程启动开销你要做的是复用而不是每个任务都开新 Worker。如果我需要频繁向 Worker 发任务会选择维护一个“常驻 Worker”它内部用一个任务队列管理请求主线程只管往里塞任务Worker 处理完一个回执一个。这样线程只启动一次通信模式稳定后续加新任务也只是扩展 Worker 内部逻辑主线程代码反而更简单。我自己在实际项目中踩过的最大的一次坑是同时在页面上开了多个 Worker 分别处理不同功能结果移动端低端机的内存直接吃紧反而出现卡顿。后来把几个计算逻辑合并进同一个 Worker用不同的type分发内存和性能都恢复正常。Worker 虽好适量才是关键。如果你现在正被某个长任务卡得头疼别犹豫把计算丢进 Worker 里试试。先跑通最小回路再加进度消息最后再考虑要不要上 SharedArrayBuffer 这些进阶方案。这套路我在好几个项目里都验证过稳。
返回列表