☰
Web Worker 从原理到实战:一键解决 JavaScript 单线程卡顿问题
2026/10/7 12:06:04 网站建设 项目流程

做前端的时间一长,你大概率会遇到这样一个场景:页面上某个按钮被用户点了一下,随后要处理一个巨大的数据集合,或者要给一个几百 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 Worker

3.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: 'module':

const 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对象从主线程传给 Worker:

const 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 上传各个分片;另一种更激进,把整个上传逻辑也搬进 Worker,Worker 里直接 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 这些进阶方案。这套路我在好几个项目里都验证过,稳。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询