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;
- 文件类 API(FileReader 等)与部分 IndexedDB 操作;
- Canvas 相关的 OffscreenCanvas 离屏渲染能力;
- 数学库、加密 API(SubtleCrypto)、TypedArray 等高性能数据结构。
所以,Worker 最适合做的事情是那些开销大、计算密集、又不依赖 DOM 的逻辑。这类任务统称为"CPU密集型任务"。比如:大批量数据的过滤、排序、去重、规则引擎匹配;图像像素级处理(灰度化、卷积滤波、缩放裁剪);音视频的编解码转封装;加密解密、哈希计算;复杂路径规划、游戏物理运算;Excel/JSON 大文件解析(配合流式读取)等。
简单总结一下设计原则:凡是需要在主线程上跑超过 50ms 的同步计算,都应该考虑搬到 Worker 里去。50ms 大约是一帧动画渲染时间的 3 倍,超过这个值浏览器就会出现可见卡顿,可以说是主线程任务拆分的一条经验红线。
2. 三类 Worker 怎么选:Dedicated、Shared 和 Service
2.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 来做。
直接上方案:主线程接收用户输入,把全部数据发给一个 Worker;Worker 在内部做分片过滤,每次搜索都返回过滤结果集。但这个方案有个隐患:每次搜索都要重新把全部数据发给 Worker,即使数据本身不变化,通信成本也很高。
更合理的做法是:页面首次加载时把数据一次性发给 Worker,Worker 内部保存一份数据副本;后续用户输入搜索时,主线程只发一个关键字,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 都生成一个 taskId,Worker 返回结果时带上同样的 taskId,主线程再通过 Map 找到对应的回调。这样多个任务并发发出时,结果不会串。否则你只能用一个全局 onmessage 去分发消息,一旦同时发出多个请求,根本分不清哪个结果属于哪个任务。
你的 Worker 文件里也需要对应维护 taskId:
let 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,可以在页面卸载时统一 terminate:
window.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 并行,会更稳妥一些。