
写这一篇的时候我脑子里全是三年前在项目里被“卡死”支配的恐惧用户上传一个几十兆的文件页面直接白屏鼠标点了没反应滚动条拖不动甚至弹窗里的关闭按钮按下去要隔两秒才有反馈。排查到最后问题全在JS的单线程主线程上——文件分片、哈希计算、格式校验全堆在大循环里跑浏览器的主线程被堵死界面自然就“冻住”了。后来我把这部分计算全部挪进worker里页面立刻变得丝滑上传进度还能实时刷出来。这就是我想认真写一篇JS Worker详解的原因。它不是一道八股题而是一个能真正解决“主线程被长任务卡住”问题的核心工具。这篇文章适合两类人一是前端刚入门、被异步回调搞晕的新手二是已经写过几年业务代码、想过优化性能但没系统捋过Worker所有细节的进阶开发者。文章会覆盖Worker的类型选型、通信机制、结构化克隆与可转移对象、错误处理以及一个完整的大文件分片上传案例——从代码到原理再到底层踩坑全部摊开讲。1. Worker到底在解决什么问题为什么你逃不过它1.1 单线程主线程的宿命以及阻塞是怎么发生的浏览器里的JavaScript默认跑在渲染主线程上这个线程不仅要执行你的业务脚本还要负责解析HTML、计算样式、布局、绘制以及处理用户的点击、滚动、输入事件。也就是说所有让用户“感觉到流畅”的事情都依赖主线程保持空闲。一旦主线程上有一段同步的耗时计算——比如对一个20万行的数组做去重和排序、逐字节读取大文件、用Canvas处理超大图片的像素数据——主线程就被这个任务霸占了。在这段任务执行完之前浏览器没法处理新的绘制帧也没法响应用户事件。表现出来就是页面假死、动画卡顿、点击无反馈。有人会反驳那我用异步回调、Promise、async/await不就行了关键就在这里异步本质上也没有把任务移出主线程。setTimeout只是把回调排进任务队列回调执行时仍然占用主线程Promise.then里的微任务也一样跑起来的时候还是主线程在算。异步能改善的是“任务之间的编排顺序”但“CPU密集型计算本身”依然堵着渲染线程。你可以把主线程想象成一条单车道异步是让车辆排队有序但如果前面有一辆超长卡车后面的车还是过不去。1.2 Worker把计算搬出了主线程但它不是万能的Worker的本质是浏览器提供的一个独立的全局上下文运行在单独的操作系统线程里。主线程把数据交给WorkerWorker在线程里同步或者异步地执行计算算完再把结果通过消息机制传回主线程。计算过程完全不再占用主线程UI该渲染渲染事件该响应响应。但worker不是“多线程版的JavaScript”它是一个“受限的独立JS环境”。在Dedicated Worker里你没有window、document这些DOM相关的对象不能直接操作DOM不能调用alert不能访问localStorage和sessionStoragewindow.location也只有只读的一部分属性。能用的有fetch可以发网络请求XMLHttpRequest也可以用WebSocket可以建IndexedDB可以操作还有self.postMessage、importScripts、setTimeout、OffscreenCanvas这些。本质上Worker把自己定位成“计算模块”而不是“页面控制模块”。1.3 什么场景值得上Worker什么场景用了纯属浪费根据我实际项目的经验值得用Worker的典型场景包括大文件处理分片上传前的哈希计算、文件内容校验、格式解析这些动辄几百万字节的遍历放主线程必卡。图像与视频处理像素级的滤镜计算、颜色矩阵变换、Canvas离屏渲染结合OffscreenCanvas做后台合成。数据处理与转换CSV/Excel大数据量解析、大型JSON的序列化与反序列化、前端报表计算、复杂排序去重。加密与签名利用crypto.subtle做摘要、加密时计算量大的部分丢给worker。实时通信辅助WebSocket接收大量消息后在Worker里做过滤、聚合只把最终结果推给界面。反过来那种“跑一个定时器、隔几秒发一次请求、处理一下几十条数据”的轻量任务完全没必要开Worker。因为创建Worker本身有开销要单独加载一份脚本、初始化一个新的线程上下文、透传数据还有克隆成本。为了一点点轻量计算开工反而是负优化。2. 三类Worker怎么选Dedicated、Shared、还是Service Worker2.1 Dedicated Worker最常用一个页面对应一个专属线程我们平时说的“Web Worker”默认就是指Dedicated Worker。它的特点是一对一由主线程创建一个Worker实例这个Worker只能跟创建它的主线程通信不能被其他页面或者其他Worker复用。创建方式特别简单// main.js const worker new Worker(./heavy-task.js, { name: heavy-task-worker, type: classic }); worker.postMessage({ action: start, payload: 12345 }); worker.onmessage (event) { console.log(主线程收到结果, event.data); }; worker.onerror (event) { console.error(worker 抛错, event.message); };type字段默认是classic也就是普通脚本如果你的脚本是ES Module就要写成type: module这样Worker里可以直接用import语法。name只在调试时有用DevTools里能根据这个名字区分不同的Worker。对应地Worker内部通过self来访问自己的全局对象// heavy-task.js self.onmessage (event) { const { action, payload } event.data; if (action start) { const result doHeavyLoop(payload); self.postMessage({ action: done, result }); } }; function doHeavyLoop(n) { let sum 0; for (let i 1; i n; i) { sum i * i; } return sum; }2.2 SharedWorker多个页面共享同一个后台线程SharedWorker和Dedicated Worker最核心的区别是它可以被多个同源页面连接并共享。比如你有三个Tab都打开了同一个统计面板数据拉取和清洗逻辑如果各跑一遍浪费网络和CPU。此时可以建一个SharedWorker让三个Tab都往同一个Worker里发数据Worker统一汇总后通知所有已连接的页面这样状态是共享的计算也只有一份。使用方式上它和Dedicated Worker不是同一个API调用// main.js每个页面 const sharedWorker new SharedWorker(./shared-counter.js, shared-counter); sharedWorker.port.start(); sharedWorker.port.postMessage({ type: join, pageId: tab-1 }); sharedWorker.port.onmessage (event) { console.log(收到共享通知, event.data); };// shared-counter.js const connections new Set(); self.onconnect (event) { const port event.ports[0]; connections.add(port); port.onmessage (msgEvent) { const data msgEvent.data; // 广播给所有连接页面 connections.forEach((p) { if (p ! port) p.postMessage(data); }); }; };实际操作里SharedWorker有个很容易被忽略的问题只有在同一个浏览器、同一个源协议域名端口完全一致下的页面才能共享。直接访问本地文件时基本不会工作开发环境跨端口也会遇到坑。所以在多数移动端场景和不要求Tab间通信的项目里SharedWorker存在感不强。但如果你确实要做一个“多标签页协同工具”它比localStorage事件监听那套方案要优雅得多。2.3 Service Worker它是Worker但职责是“网络代理”严格讲Service Worker也属于Web Worker家族但它完全不一样它能拦截网络请求、管理缓存、支持离线访问有自己的生命周期install、activate、fetch。它不服务“计算”而是服务“网络”。Service Worker也不能直接操作DOM和Dedicated Worker的运行时限制很相似。但因为它在网络层面有拦截能力前端性能优化里经常用它做资源预缓存、接口请求本地缓存。要注意的是Service Worker只能在HTTPS或localhost环境下工作而且和页面生命周期解耦——页面关掉了它还可能继续运行。想从前端代码里new Worker()切换到Service Worker不是改个类名的事它是另一套概念体系需要单独掌握。如果你只是要做计算密集型的后台任务别用Service Worker如果你要做离线缓存和网络加速Dedicated Worker帮不了你。这两者边界一定要分清。3. 通信机制postMessage背后的深水区3.1 最基本的双向消息传递Worker和主线程之间的通信说穿了就是postMessage加message事件。主线程给Worker发消息Worker给主线程发消息两条通道互不干扰消息体可以是任意能被结构化克隆的值。// main.js const worker new Worker(./worker.js); worker.postMessage({ type: greet, data: { text: hello } }); worker.onmessage (e) { console.log(回传:, e.data); };// worker.js self.onmessage (e) { if (e.data.type greet) { self.postMessage({ type: echo, text: e.data.data.text from worker }); } };每次postMessage之后消息会异步到达对方线程。这种异步是有意为之避免两个线程之间产生同步锁。但异步也意味着你没法从返回值里直接拿到结果只能靠事件驱动。写久了你会发现当消息类型变多、消息来回变频繁时这种模式会很快变成“面条代码”。所以在实际项目中我强烈建议给消息体统一定义一个结构至少包含type和data两个字段有条件的再带上id或requestId这样主线程能知道某条回复是对应哪一次请求。3.2 结构化克隆表面“深拷贝”其实有一堆边界postMessage传递的数据用的是结构化克隆算法Structured Clone不是JSON序列化。它支持的对象比JSON多得多Date、RegExp、Map、Set、ArrayBuffer、Blob、ImageData、普通的类实例属性都会尽力复制。但有几个特别容易踩的坑函数不能被克隆。传一个函数给Worker消息发送直接抛DataCloneError。DOM节点不能被克隆。这个和Worker的定位一致因为Worker反正拿不到DOM。原型链不会完整保留。自定义类的实例传到另一端后通常变成只有自身属性、没有原方法可调用的普通对象。循环引用可以处理不会像JSON一样报错。绝大多数时候我们传的是普通对象和数组结构化克隆够用。但如果你传的是大体积的ArrayBuffer或者TypedArray克隆底层等于又复制了一份二进制数据内存开销直接翻倍。这时候就该上Transferable。3.3 Transferable传输大数据的真正解法**Transferable可转移对象**允许你把ArrayBuffer、MessagePort、ImageBitmap等对象的所有权从一个上下文“转移”到另一个上下文。数据不需要被复制底层内存块直接交给目标线程发送方的原引用会立即失效。// main.js const buffer new ArrayBuffer(64 * 1024 * 1024); // 64MB二进制 console.log(发送前 buffer.byteLength , buffer.byteLength); // 67108864 worker.postMessage(buffer, [buffer]); console.log(发送后 buffer.byteLength , buffer.byteLength); // 0关键就在postMessage的第二个参数——转移列表。把buffer放进去主线程这边的buffer就被“掏空”了长度归零。这个设计很反直觉我第一次用的时候也懵过明明刚才还在的变量怎么发完消息就废了因为所有权已经不在主线程了。转移的收益在传几十MB甚至上百MB的二进制数据时极其明显。之前我传一个120MB的ArrayBuffer给Worker做解码用默认克隆的方式内存瞬间多出一个120MB副本GC压力大不说传输本身也要花时间换成Transferable之后主线程只是交出指针几乎零拷贝就完成了交接。代价是数据一旦转走主线程就不能再碰这块内存需要在Worker那边处理完再转移回来。3.4 MessageChannel一对一的独立消息管道MessageChannel创建了一对互相绑定的MessagePort它最大的价值是可以建立不受父子关系限制的通道。比如你想让两个Worker之间直接通信而不是所有消息都绕道主线程中转或者你想让一个Worker中的所有子任务把结果汇总到同一个端口这时候MessageChannel就很有用。// main.js const channel new MessageChannel(); const worker1 new Worker(./worker1.js); const worker2 new Worker(./worker2.js); worker1.postMessage({ type: init, port: channel.port1 }, [channel.port1]); worker2.postMessage({ type: init, port: channel.port2 }, [channel.port2]); channel.port1.onmessage (e) { console.log(主线程也能监听 port1 的消息, e.data); };// worker1.js self.onmessage (e) { if (e.data.type init) { const port e.data.port; port.postMessage(worker1 直接告诉 worker2); } };注意把MessagePort传递给Worker时消息体里包含它同时也要把它写进转移列表因为端口也是转移对象。MessageChannel用起来比普通postMessage多一层心智负担但它的优势在于隔离如果把所有消息都从主线程转发一旦消息量变大主线程反而成了瓶颈用多个Channel把通信路径分开主线程只做轻量调度压力会小很多。3.5 错误定位与线程终止Worker内部抛出的异常默认不会打断主线程。但如果你不加错误监听控制台会报一个货真价实的错误线上却根本没人知道。所以每个Worker创建出来我都会强制挂上onerrorworker.onerror (event) { console.error(错误信息, event.message); console.error(出错文件, event.filename, 第, event.lineno, 行); };同时Worker内部可以用self.onerror捕获未处理错误再主动通知主线程从而形成闭环的容错机制。终止Worker有两条路一是主线程主动调用worker.terminate()这个动作会立即杀死Worker线程正在执行的任务会被硬中断不给你清理资源的机会。二是Worker内部调用self.close()属于主动退出。不管哪种方式终止之后消息通道就断了想再用只能重新new Worker()。所以我的习惯是长生命周期Worker做“池化”创建一次多次复用短任务型Worker用完了就terminate()避免后台残留一堆空闲线程拖慢浏览器。特别是移动端Worker线程同样占用内存太多会浪费资源。4. 实战前端大文件上传Worker如何扛住整个计算链路4.1 场景拆解大文件上传卡点到底在哪里假设一个用户上传一个500MB的视频文件。传统做法是选完文件直接放进input.files然后用一个大的FormData.append(file, file)一次性发出去。这种方案有几个致命问题服务端接口往往有大小限制网络中断后要重新上传浏览器内存里还要把整个文件相关数据组织一遍。所以成熟的上传方案必须拆成分片上传把文件切成若干块每块单独上传最后在服务端合并。但分片上传有一个绕不开的前置动作——计算文件指纹比如MD5/SHA-256。你要先算出整个文件的摘要用它做分片的唯一标识服务端才能把分片按顺序合并也才能做秒传、断点续传的判断。计算大文件的哈希意味着要逐块读取几十上百MB数据做二进制摘要计算。如果这段逻辑跑在主线程计算期间UI就是冻住的用户体验非常糟糕。把哈希计算丢给Worker主线程只负责接收进度百分比和最终哈希值是最典型不过的Worker场景。4.2 整体流程设计完整链路我通常分成五步主线程拿到File对象维护一个上传任务状态机等待计算哈希、分片传输中、合并中、完成/失败。将File实例通过postMessage发送给Worker。注意File是Blob的子类结构化克隆是支持的不需要也不能转移。Worker里读取文件内容分片读取并计算哈希每算完一个分片就把进度消息postMessage回主线程主线程更新进度条。哈希计算完毕Worker把最终摘要发回主线程。此时Worker任务结束可以选择复用或close()。主线程拿到哈希后再启动一个或N个上传分片的流程每片读取指定大小的Blob用FormData或直接二进制发出请求。把哈希和上传分片都拆到Worker里是最优雅的哈希在Worker里算上传时worker可以请求也可以把切好的Blob再发回主线程然后主线程用axios或fetch发。4.3 代码落地主线程与Worker的分工先看主线程侧的关键代码// main.js const fileInput document.querySelector(#uploadFile); const progressBar document.querySelector(#progressBar); fileInput.addEventListener(change, () { const file fileInput.files[0]; if (!file) return; const worker new Worker(./file-hash-worker.js, { name: file-hash-worker }); const CHUNK_SIZE 2 * 1024 * 1024; // 每个分片 2MB worker.postMessage({ file, chunkSize: CHUNK_SIZE }); worker.onmessage (event) { const { type, data } event.data; if (type progress) { const percent Math.round((data.done / data.total) * 100); progressBar.value percent; } else if (type hash) { console.log(最终文件指纹, data.hash); startUpload(file, data.hash); worker.terminate(); } }; worker.onerror (event) { console.error(哈希计算失败, event.message); progressBar.value 0; }; });Worker内部// file-hash-worker.js self.onmessage async (event) { const { file, chunkSize } event.data; try { const result await computeFileHash(file, chunkSize); self.postMessage({ type: hash, data: result }); } catch (err) { self.postMessage({ type: error, data: err.message }); } }; async function computeFileHash(file, chunkSize) { const totalChunks Math.ceil(file.size / chunkSize); // 用 crypto.subtle 做摘要 // 注意 crypto.subtle 只在 secure context 可用本地localhost和https没问题 const digestPromises []; for (let i 0; i totalChunks; i) { const start i * chunkSize; const end Math.min(start chunkSize, file.size); const chunk file.slice(start, end); const chunkBuffer await chunk.arrayBuffer(); const digest await crypto.subtle.digest(SHA-256, chunkBuffer); digestPromises.push(new Uint8Array(digest)); // 每完成一块回传一次进度 self.postMessage({ type: progress, data: { done: i 1, total: totalChunks } }); } // 把所有分片摘要拼起来再求一次哈希作为最终指纹 const totalLength digestPromises.reduce((acc, arr) acc arr.byteLength, 0); const merged new Uint8Array(totalLength); let offset 0; for (const arr of digestPromises) { merged.set(arr, offset); offset arr.byteLength; } const finalDigest await crypto.subtle.digest(SHA-256, merged); const hash Array.from(new Uint8Array(finalDigest)) .map((b) b.toString(16).padStart(2, 0)) .join(); return { hash, totalChunks }; }这里有两个细节需要说明。第一crypto.subtle.digest对每个分片单独计算最后再把各分片摘要拼起来算一次总摘要。这样做的意义是配合分片上传语义万一某个分片在传输中损坏服务端可以通过单独校验这个分片的摘要来判断是否重传。第二每个分片读取用的是chunk.arrayBuffer()它返回的是PromiseArrayBuffer在Worker里用async/await处理非常顺手不用再走FileReader那套烦人的回调。4.4 中间再聊聊Transferable和内存优化上面代码里分片摘要计算过程中会频繁产生ArrayBuffer如果每个分片都要从主线程转移过来会显得很奇怪。这里其实不需要手动转移ArrayBuffer因为file.slice返回的Blob本身已经通过结构化克隆在Worker里持有了文件数据引用后续的arrayBuffer()读取全程发生在Worker线程内部内存是可控的。但如果你在另一个场景里主线程已经持有大块二进制缓冲区必须传给Worker处理那一定记得用Transferable不要在第二个参数上偷懒。我以前见过一个同事传一个16MB的Buffer给Worker做压缩没写转移列表结果主线程和Worker各持一份16MB的拷贝页面内存直接飙到两百多MB。加上转移列表后内存瞬间降下来。这个参数看着不显眼却最容易出问题。4.5 分片上传到服务端结合Worker处理更彻底如果想让上传这件事也占用更少主线程时间可以再开一个“上传Worker”专门负责把已经切好的Blob分片逐一上传。思路是这样的// upload-worker.js self.onmessage async (event) { const { file, chunkSize, uploadUrl, headers } event.data; const totalChunks Math.ceil(file.size / chunkSize); for (let i 0; i totalChunks; i) { const start i * chunkSize; const end Math.min(start chunkSize, file.size); const blob file.slice(start, end); const formData new FormData(); formData.append(chunkIndex, String(i)); formData.append(fileHash, headers.hash); formData.append(file, blob, video.mp4); const response await fetch(uploadUrl, { method: POST, body: formData, headers: { X-File-Hash: headers.hash } }); if (!response.ok) { self.postMessage({ type: chunkError, data: { index: i, status: response.status } }); return; } self.postMessage({ type: chunkDone, data: { index: i, total: totalChunks } }); } self.postMessage({ type: allDone, data: { hash: headers.hash } }); };这在网络请求层面其实不依赖Worker为什么还要放进Worker因为当分片数量几十上百时循环里也有大量的数据组装、请求调度和错误处理逻辑这些虽然大多数是异步的但每个fetch的发起和释放都有开销放在Worker里能进一步减少主线程的处理负担。我在实际项目里用的方案是哈希Worker和上传Worker合成一个因为文件数据一次克隆进Worker尽量减少大对象的反复透传。4.6 兼容性判断与降级策略Worker不是“要不要用”的问题而是“能不能用”的问题。基本上Chrome、Firefox、Safari、Edge的现代版本都支持Dedicated Workercrypto.subtle则要求secure context也就是HTTPS或localhost。如果你的页面跑在HTTP的测试环境里crypto.subtle会是undefined必须降级到纯JS摘要算法比如引入spark-md5之类的库来算哈希。但注意spark-md5是同步循环计算放Worker里没问题放主线程照样会卡。所以降级策略是脚本层面降级线程模型不降级——没有crypto.subtle就用普通摘要库但一定要放进Worker跑。5. 老手也会踩的坑Worker实战排雷实录5.1 消息体里藏了函数或DOM对象我见过不少次开发者在主线程里把一个HTMLElement塞进postMessage或者在对象里放了个回调函数想传给Worker。结果是浏览器直接抛DataCloneError: The object could not be cloned。解决方式很简单任何要跨线程共享的数据必须是可序列化的普通对象。想传DOM不如把DOM相关的信息比如坐标、尺寸、属性提取成纯数据传过去。5.2 Worker脚本路径出错页面直接200/404错乱new Worker(./worker.js)的路径是相对于当前页面URL的不是相对于正在执行的JS文件的URL。如果页面在https://example.com/admin/index.html下那么./worker.js会解析成https://example.com/admin/worker.js。很多人把Worker脚本放在/static/js/下页面在/admin/下结果写./worker.js就404了Worker创建失败。还有一点如果Worker脚本的MIME类型不是text/javascript或application/javascript某些浏览器会拒绝加载。尤其是一些静态资源服务器配置不当把.js文件当text/plain返回Worker就悄悄挂了。调试时记得打开Network面板看脚本请求的响应头。5.3 忘掉importScripts的文件依赖顺序在type: classic的Worker里你想引入其他脚本最直接的方式是importScripts。它会在Worker上下文里同步加载并执行外部脚本。同步意味着如果你在importScripts后面立刻使用被引入脚本里定义的函数一定是可用的。但要注意加载顺序第二个脚本依赖第一个脚本必须放在后面。// worker.js importScripts(./hash-utils.js); importScripts(./upload-utils.js);在ES Module模式下不需要importScripts改用标准的import语句。但这时候两个模式对全局作用域的处理逻辑不一样混用容易出幺蛾子。我的建议是项目统一用一种模式要么全部classic importScripts要么全部module import。不要因为某个库只支持其中一个模式就把两种混在同一个项目里后面维护的同事会疯。5.4 Worker内报错却找不到堆栈默认情况下Worker里的错误会显示在Console里但source map的处理不一定友好。尤其是代码被构建工具压缩过之后报错堆栈一片乱码。我的做法是在构建配置里给Worker脚本单独开启sourcemap。在Worker内部实现兜底的self.onerror把错误消息和堆栈结构序列化后主动上报给主线程。// worker.js self.onerror (e) { self.postMessage({ type: error, data: { message: e.message, line: e.lineno, file: e.filename } }); };这样即使浏览器控制台展示不全我自己的错误监控系统也能抓到Worker里的问题。5.5 大量Worker实例导致内存爆炸一个Worker实例至少会占用数MB内存因为它是完整的JS运行时。如果每次点击都新建一个Worker用完又不关几十个Worker叠加起来内存和CPU都会爆。我的经验是能复用的Worker就复用不要把Worker当成一次性一次性函数调用。比如哈希Worker算完一个文件后清空内部状态继续接收下一个文件任务如果任务结束而且后续短时间内不会再有大任务立刻terminate()。操作Worker而不管理其生命周期就像在后台拉了一堆不关的开关前端性能优化的成果会被这些坑吃得干干净净。6. 常见问题速查表收藏这一张就够了问题原因分析解决办法创建Worker后页面报404脚本路径相对于页面URL算错了使用绝对路径或基于import.meta.url计算相对路径报DataCloneError传输了函数、DOM元素等不可克隆对象改为传输纯数据对象crypto.subtle为undefined页面不是HTTPS/localhost降级使用纯JS摘要库大块Buffer传输内存翻倍没使用TransferablepostMessage(data, [buffer])Worker执行完没结束缺少terminate()或close()任务结束主动关闭Worker里用不了window/document全局对象是self而不是window改用self访问全局API多个Tab状态不同步需要跨页面共享逻辑使用SharedWorker或BroadcastChannelWorker内fetch请求失败业务放在Worker但未正确处理跨域/凭证显式配置请求的credentials和CORS页面关闭Worker还在跑Worker生命周期与页面解耦在pagehide/unload里terminate这张表我建议贴在项目的README里至少能让后来的人少踩一半的坑。关于调试还有个小技巧想分享Chrome DevTools的Sources面板里有Workers子面板能看到当前页面创建的所有Worker点击可以单独打开某个Worker的调试窗口在Worker里打断点、看作用域、观察消息收发比在主线程里干瞅console.log强太多。在Worker的调试“调试器”窗口里console.log会输出到同一个Console但加上self.postMessage回传会让主线程更清晰地看到任务流转。最后说一个我个人的体会Worker不是“高端性能优化”的专有品它更像一把每当前端项目里出现“页面卡死”“交互掉帧”这些现象时应该立刻想到的常规工具。从我经手的项目看90%的前端体积大计算场景都能用Worker一句话拆掉瓶颈。难点从来不在“会不会new Worker”而在于你是否真的理解了消息传递的开销、数据生命周期的转移、以及线程之间协作边界。这些东西没有捷径多写几个真实的文件处理、图像处理案例慢慢就会形成肌肉记忆。希望你在下一个被主线程卡到怀疑人生的夜晚能想起这篇文章然后顺手把任务塞进Worker里——那种“瞬间从地狱回到人间”的感觉是真的很爽。