ARTICLE DETAIL

资讯详情

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

React useRef 与 Web Worker 实战:让 5000 万次计算不再阻塞主线程

React useRef 与 Web Worker 实战:让 5000 万次计算不再阻塞主线程 React useRef 与 Web Worker 实战让 5000 万次计算不再阻塞主线程前言1. 先理解浏览器主线程与 Web Worker1.1 JavaScript 单主线程为什么会卡顿1.2 Web Worker 到底改变了什么2. React 耗时计算案例的设计2.1 业务目标与职责拆分2.2 为什么同时需要 useRef、useState 和 useEffect3. 主线程代码分块详解3.1 用 current 持久保存 Worker 实例3.2 new Worker、new URL 与 import.meta.url3.3 onmessage接住后台线程返回的结果3.4 postMessage把任务交给 Worker3.5 loading 与 result 如何驱动页面4. Worker 线程代码分块详解4.1 self.onmessage 从哪里来4.2 执行计算并通过 postMessage 回传5. 把分块逻辑串成完整业务链5.1 从组件挂载到结果渲染的执行顺序5.2 一轮任务中的状态变化6. 完整实现与工程化改进6.1 带关键注释的 React 实现6.2 Worker 实现总结前言在浏览器里执行点击、输入、滚动等普通交互时JavaScript 的单主线程模型简单而可靠业务逻辑按既定顺序运行页面状态也更容易保持一致。问题在于随着应用开始处理大规模数据、图像、加密、游戏运算乃至浏览器端 AI 推理某些任务可能连续占用 CPU 数百毫秒甚至数秒。此时即便使用了 React页面仍然会出现按钮无响应、动画停顿和滚动卡住等现象。造成卡顿的关键并不是 React “性能不够”而是耗时同步任务长时间占据浏览器主线程。Promise、setTimeout和async/await可以重新安排任务执行的时机却不会自动把一段纯计算搬到另一条线程。真正需要并行计算时浏览器提供的Web Worker才是合适工具。本文通过一个 React 耗时运算案例系统讲清楚useRef为什么适合保存 Worker 实例new Worker与new URL分别来自哪里主线程和 Worker 如何借助postMessage、onmessage通信以及current、result在 React 数据流中扮演什么角色。最后还会把分散的代码重新串联成一套完整、可复用的任务处理流程。1. 先理解浏览器主线程与 Web Worker1.1 JavaScript 单主线程为什么会卡顿浏览器页面中的 JavaScript 通常运行在主线程上。这条线程不仅要执行脚本还要参与处理用户事件、样式计算、布局和绘制等工作。只要一个同步函数还没有退出调用栈主线程就不能及时处理后续交互和渲染任务。假设直接在按钮事件中运行 5000 万次循环functionheavyCalc(num){letsum0;// 同步循环会持续占用主线程for(leti0;i50_000_000;i){sumnum*i;}returnsum;}heavyCalc一旦开始执行就会一直占据当前调用栈直到循环结束并返回结果。循环期间发生的点击、滚动和定时器回调只能继续排队无法立即执行。浏览器也很难按时完成下一帧绘制所以用户看到的不是“计算很忙”而是整个页面像卡死了一样。如果再把console.log(i)放入循环控制台输出会产生巨量额外开销性能问题会被进一步放大。异步不等于并行。Event Loop 能协调任务何时进入主线程却不能让一段 CPU 密集型 JavaScript 自动在另一条线程上运行。把循环放进setTimeout只会推迟卡顿setTimeout((){heavyCalc(88);// 回调最终仍在主线程执行},0);Promise.then、queueMicrotask和async/await也遵循相同原则。它们适合组织异步 I/O 和任务依赖却不能解决长循环、复杂数学计算这类 CPU 密集型工作。1.2 Web Worker 到底改变了什么Web Worker 是浏览器提供的后台线程能力。页面可以创建一个独立的 JavaScript 执行环境把纯计算任务交给它处理主线程继续响应交互后台计算完成后再通过消息把结果送回来。Web Worker 的核心思想是线程隔离加消息通信主线程和 Worker 各自拥有调用栈、事件循环与全局作用域默认不直接共享普通 JavaScript 对象。Web Worker 并没有把 DOM 变成线程安全对象也没有让 React 在后台线程渲染组件。它只是在浏览器内部额外创建了一个 JavaScript 执行环境把适合隔离的工作从主线程移开。对比维度主线程Worker 线程主要职责组件更新、DOM、事件响应、页面渲染CPU 密集型、可独立执行的纯计算全局对象window专用 Worker 中通常使用selfDOM 能力可以访问不能访问document、DOM 节点通信方式worker.postMessage(...)self.postMessage(...)数据关系发送数据通常通过结构化克隆获得副本生命周期随页面存在可由主线程terminate()立即终止适合 Worker 的任务包括图像像素处理、大型数组计算、数据压缩、加解密、游戏物理计算和部分浏览器端模型推理。不适合的情况也很明确任务非常轻量、强依赖 DOM或者执行时间短到 Worker 初始化与通信成本反而更高。2. React 耗时计算案例的设计2.1 业务目标与职责拆分这个案例的交互目标很简单用户点击“启动繁重计算任务”后页面立刻进入加载状态Worker 在后台执行 5000 万次累加运算结束后结果回到 React 并触发视图更新。为了让职责清晰整个过程被拆成两部分React 主线程负责创建 Worker、响应按钮点击、发送参数、接收结果和更新页面状态。Worker 线程负责读取参数、执行循环和返回计算结果不接触 React 与 DOM。消息协议使用普通对象传递任务和结果输入结构为{ num: 88 }输出结构为{ result: sum }。整体数据流可以压缩成下面这条链路用户点击按钮 → React 设置 loading → 主线程 postMessage({ num: 88 }) → Worker 的 onmessage 收到任务 → Worker 完成 5000 万次循环 → Worker postMessage({ result: sum }) → 主线程的 onmessage 收到结果 → setResult 与 setLoading 触发重新渲染2.2 为什么同时需要 useRef、useState 和 useEffectWorker 实例、计算结果和生命周期属于三种不同类型的数据不能全部塞进同一个 Hook。React 能力保存内容修改后是否触发渲染在案例中的职责useRefWorker 实例否跨渲染持久保存外部可变对象useStateresult、loading是驱动结果区域、按钮文字和禁用状态useEffect创建与清理逻辑不直接决定让 Worker 生命周期与组件生命周期对齐useState适合“页面要显示什么”useRef适合“组件内部需要长期持有、但变化不必刷新页面的对象”useEffect则负责“组件挂载后连接外部系统卸载时断开连接”。这也是 React 管理 WebSocket、定时器、媒体对象与 Worker 时常见的组合方式。3. 主线程代码分块详解3.1 用 current 持久保存 Worker 实例constworkerRefuseRef(null);const[result,setResult]useState(null);const[loading,setLoading]useState(false);useRef(null)返回一个形如{ current: null }的稳定对象。组件重新渲染时这个容器不会被重新创建因此可以持续保存同一个 Worker 实例。current是 React Ref 对象约定的可变属性。它不是 Web Worker API也不是 JavaScript 关键字写入workerRef.current不会触发组件重新渲染。Worker 属于带有生命周期和内部状态的外部对象。如果直接写成let worker函数组件每次执行都会产生新的局部绑定难以保证事件处理函数拿到的始终是当前实例。Worker 不适合保存在useState中因为更换它通常不需要更新页面而且状态更新还会引起额外渲染。result是需要展示的业务结果调用setResult后 React 必须重新渲染所以它应当使用useState。loading控制按钮禁用状态和提示文字也会影响视图因此同样属于 state。这里要区分两个名字相近但完全不同的概念workerRef.current中的current表示 Ref 当前保存的值result则是 Worker 回传对象中的业务字段随后被写入 React 状态。3.2 new Worker、new URL 与 import.meta.urluseEffect((){// 创建后台线程并把实例放入稳定的 Ref 容器workerRef.currentnewWorker(newURL(./worker.js,import.meta.url));return(){// 组件卸载时终止后台任务避免资源泄漏workerRef.current?.terminate();workerRef.currentnull;};},[]);Worker是浏览器Web Workers API暴露的构造函数不属于 React。new Worker(url)会请求对应脚本并创建一个专用 Worker 及其独立执行环境。URL是浏览器提供的标准 URL API。new URL(relative, base)会使用第二个参数作为基准把相对地址解析成完整地址。import.meta是 ES Module 提供的元信息对象import.meta.url表示当前模块自身的绝对 URL。它解决了./worker.js到底应该相对谁解析的问题。new URL(./worker.js, import.meta.url)这种写法也方便 Vite 在开发和构建阶段识别 Worker 依赖。即使生产资源经过重命名或加上哈希构建工具仍能生成正确地址。useEffect(..., [])在组件提交到页面后执行初始化逻辑。空依赖数组表示这段 Effect 不依赖会变化的 props 或 state正常生命周期内只需建立一次连接。清理函数中的terminate()来自 Worker 实例会立即终止线程尚未完成的任务也会被丢弃。随后把current设回null表示组件已经不再持有该实例。React 开发环境启用StrictMode时Effect 可能额外经历一次“建立、清理、再建立”用来发现清理缺失问题。只要创建和terminate()成对出现逻辑就是可重复且安全的。如果 Worker 脚本本身使用import可以显式创建模块 WorkerconstworkernewWorker(newURL(./worker.js,import.meta.url),{type:module});当前计算逻辑没有模块导入使用构造函数默认行为即可。3.3 onmessage接住后台线程返回的结果workerRef.current.onmessage(event){// event 是 MessageEvent真正的数据位于 data 属性const{result}event.data;setResult(result);setLoading(false);};onmessage是Worker实例的消息事件处理属性来自 Web Workers API。Worker 每调用一次self.postMessage(...)主线程就会收到一个message事件。回调参数不是业务结果本身而是一个MessageEvent对象。发送的对象位于event.data因此需要通过const { result } event.data解构出结果。result只是双方约定的字段名并非浏览器保留字。字段名可以改成value、payload等但发送端和接收端必须一致。setResult(result)把后台结果交给 React 状态系统React 随后重新执行组件并更新结果区域。setLoading(false)表示本轮任务结束按钮恢复可点击状态。React 通常会批处理同一事件回调中的多次状态更新减少不必要的渲染。还可以使用worker.addEventListener(message, handler)注册监听。onmessage适合只有一个处理器的简洁场景addEventListener更适合多个监听器或需要精确移除监听的场景。3.4 postMessage把任务交给 WorkerconststartHeavyCalc(){constworkerworkerRef.current;// Effect 尚未完成初始化时不发送任务if(!worker)return;setLoading(true);// 异步发送任务描述和计算参数worker.postMessage({num:88,});};workerRef.current取出当前 Worker 实例。先保存到局部常量能让后续代码更易读也便于做空值守卫。postMessage同样来自 Web Workers API。它不是 HTTP 请求也不是直接调用 Worker 内部函数而是把一条消息放入通信通道。发送动作是异步的。主线程调用后会继续向下执行Worker 在自己的事件循环中接收任务因此按钮和其他交互仍能获得响应。{ num: 88 }是任务协议。对象通常会通过结构化克隆算法传递Worker 收到的是可独立使用的数据而不是主线程中同一对象的共享引用。函数、DOM 节点等内容不能直接结构化克隆强行发送会出现DataCloneError。大体积二进制数据可使用ArrayBuffer等 Transferable 对象转移所有权减少复制成本。先执行setLoading(true)可以立即把按钮切换到工作状态并阻止重复点击。更复杂的并发任务则应给消息附加taskId用来匹配请求与响应。案例中直接写workerRef.current.postMessage(...)通常能够运行但点击发生在 Effect 初始化完成之前时current仍可能是null。空值守卫让生命周期边界更加稳妥。3.5 loading 与 result 如何驱动页面button onClick{startHeavyCalc}disabled{loading}{loading?正在后台计算……:启动繁重计算任务}/button{result!nullh3计算结果{result}/h3}onClick把用户操作连接到startHeavyCalc点击后只发送任务不在事件处理函数里执行长循环。disabled{loading}在计算期间禁用按钮避免同一个 Worker 被连续塞入多个耗时任务。条件表达式依据loading切换提示文字让用户明确知道任务仍在后台运行。判断结果时使用result ! null比result ...更准确。后者会把合法的0、空字符串等假值误判成“没有结果”。result更新会重新渲染组件workerRef.current变化不会重新渲染。两者各自承担正确职责避免把外部实例与 UI 状态混在一起。4. Worker 线程代码分块详解4.1 self.onmessage 从哪里来// Worker 独立执行环境中的全局对象是 selfself.onmessage(event){const{num}event.data;console.log(Worker 收到任务参数为,event.data);};Worker 中没有页面的window和document。专用 Worker 的全局作用域是DedicatedWorkerGlobalScope通常通过self引用。self.onmessage监听主线程发来的消息。主线程调用worker.postMessage({ num: 88 })后Worker 会收到对应的MessageEvent。event.data保存发送过来的数据const { num } event.data取出计算因子88。Worker 不能操作 DOM但可以使用不少独立 API例如console、定时器、fetch、crypto和 IndexedDB。能力边界的判断标准不是“后台线程什么都不能做”而是它不能直接碰页面视图。主线程和 Worker 两边都叫onmessage但监听方向相反Worker 侧接收任务主线程侧接收结果。两端 API 的对应关系如下通信方向发送 API接收 API数据入口主线程 → Workerworker.postMessage(data)self.onmessageevent.dataWorker → 主线程self.postMessage(data)worker.onmessageevent.data4.2 执行计算并通过 postMessage 回传self.onmessage(event){const{num}event.data;letsum0;// 0 到 49,999,999共执行 5000 万次for(leti0;i50_000_000;i){sumnum*i;}// 将结果异步发送回主线程self.postMessage({result:sum,});};循环只读取num、i和sum不依赖 DOM 或 React 状态因此非常适合放入 Worker。50_000_000中的下划线是 JavaScript 数字分隔符只用于提升可读性实际数值仍是五千万。循环条件是i 50_000_000所以i从0递增到49_999_999总计执行 5000 万次并不是页面旧提示中的 5 亿次。Worker 内部的循环依然是同步的也会占满 Worker 自己的调用栈。区别在于它不会堵住页面主线程如果还要让该 Worker 同时处理其他消息就需要拆分任务或创建 Worker 池。self.postMessage({ result: sum })把结果放回通信通道。主线程不能通过返回值获取它因为两端不在同一个调用栈也没有普通函数调用关系。Worker 完成一次消息处理后仍然存活可以继续接收下一项任务直到主线程调用terminate()或 Worker 自己调用self.close()。还有一个容易被忽略的数值问题。数学上的结果为88*(012...49_999_999)// 精确整数109999997800000000这个整数大于Number.MAX_SAFE_INTEGER9007199254740991使用普通Number不能保证所有整数位都精确。该循环适合演示线程分工若业务必须保证大整数精度应使用BigInt、任意精度库或者调整算法与数据范围。把计算移入 Worker 只能解决主线程阻塞不能自动解决数值精度与算法复杂度。5. 把分块逻辑串成完整业务链5.1 从组件挂载到结果渲染的执行顺序理解单个 API 之后还需要把两条线程放回同一条时间线上观察第一步React 首次渲染。workerRef.current初始为nullresult为nullloading为false页面先展示可交互结构。第二步Effect 建立 Worker。组件提交后new URL解析 Worker 地址new Worker创建后台执行环境实例被写入稳定的current属性。第三步注册结果监听。主线程为 Worker 的onmessage赋值准备接收后台返回的MessageEvent。第四步用户发起任务。点击按钮后React 把loading设为true主线程通过postMessage({ num: 88 })发送任务。第五步Worker 独立计算。self.onmessage从event.data读取num在后台线程完成 5000 万次循环。此时主线程仍可处理绘制与用户操作。第六步Worker 返回结果。self.postMessage({ result: sum })把响应放入消息通道主线程的onmessage随后被调度执行。第七步React 更新视图。setResult(result)保存结果setLoading(false)恢复按钮组件重新渲染并展示数值。第八步组件卸载。Effect 清理函数调用terminate()终止可能仍在运行的后台任务再把current清空。这一链路中没有跨线程的同步函数调用也没有 Worker 直接修改 React 状态。消息是两端唯一的业务边界正因为边界清晰复杂计算才不会干扰页面渲染。5.2 一轮任务中的状态变化阶段workerRef.currentloadingresult页面表现首次渲染nullfalsenull展示启动按钮Effect 完成Worker 实例falsenull已具备发送任务能力点击按钮Worker 实例true旧值或null按钮禁用显示计算中收到结果Worker 实例false新结果按钮恢复并展示结果组件卸载null无需展示无需展示Worker 被终止从表中可以看出Ref 与 state 的分工贯穿整个流程Ref 维持通信对象的身份state 描述用户能看到的业务状态。6. 完整实现与工程化改进6.1 带关键注释的 React 实现下面给出一版更稳妥的完整实现。它保留核心流程同时补上 Worker 就绪状态、空值守卫、错误处理和对0结果的正确渲染。import{useEffect,useRef,useState}fromreact;functionApp(){// Ref 用于保存 Worker 实例修改 current 不会触发渲染constworkerRefuseRef(null);// state 负责所有需要反映到页面上的状态const[result,setResult]useState(null);const[loading,setLoading]useState(false);const[ready,setReady]useState(false);useEffect((){// import.meta.url 是当前 ES 模块地址URL API 据此解析相对路径constworkernewWorker(newURL(./worker.js,import.meta.url));workerRef.currentworker;setReady(true);// Worker 返回结果时在主线程更新 React 状态worker.onmessage(event){const{result}event.data;setResult(result);setLoading(false);};// 加载失败或运行异常时结束本轮 loading 状态worker.onerror(error){console.error(Worker 运行失败,error);setLoading(false);};return(){// 卸载时立即终止线程防止后台任务继续占用资源worker.terminate();workerRef.currentnull;};},[]);conststartHeavyCalc(){constworkerworkerRef.current;// Worker 尚未创建或已有任务运行时不重复发送if(!worker||loading)return;setLoading(true);// postMessage 使用结构化克隆传递任务参数worker.postMessage({num:88,});};return(div style{{padding:30px}}h2useRefWeb Worker 耗时运算/h2p后台执行5000万次循环结束后通知主线程。/pbutton onClick{startHeavyCalc}disabled{!ready||loading}{loading?正在后台计算……:启动繁重计算任务}/button{/* result 可能为 0因此不能只用 result 做真假判断 */}{result!nullh3计算结果{result}/h3}/div);}exportdefaultApp;局部常量worker与workerRef.current指向同一实例。前者便于当前 Effect 内绑定事件和清理后者允许点击处理函数跨渲染访问它。ready让按钮在 Worker 初始化完成之前保持禁用消除极短时间窗口内访问null的可能。worker.onerror不仅输出异常也恢复loading避免失败后按钮永久停留在“正在计算”。实际业务还可以增加errorstate把失败原因展示给用户。页面描述改为“5000 万次”与循环上限保持一致。JSX 使用result ! null判断是否已有结果即使结果是0也能正常展示。6.2 Worker 实现// self 指向 Worker 自己的全局作用域而不是页面 windowself.onmessage(event){// 接收主线程通过 postMessage 发送的参数const{num}event.data;letsum0;// CPU 密集型循环在后台线程运行不阻塞页面主线程for(leti0;i50_000_000;i){sumnum*i;}// 结果字段是双方约定的消息协议self.postMessage({result:sum,});};这段 Worker 只承担计算没有任何 DOM 或 React 依赖因此可测试性和可迁移性都更好。输入和输出都使用对象为以后添加taskId、进度、错误码等字段预留了扩展空间。线程隔离并不代表任务自动变快。单个循环的执行时间仍取决于 CPU 与算法只是页面主线程不再被它占用。如果需要持续上报进度可在循环分段后发送{ type: progress, value: 0.5 }如果需要并行处理大量任务可以设计 Worker 池但线程数量仍应受控。生产环境还应重点考虑以下问题问题当前策略可扩展方案重复提交计算期间禁用按钮任务队列或多个 Worker请求与响应匹配同一时间只运行一项任务为消息增加taskId错误处理使用onerror恢复状态统一{ type, data, error }协议任务取消卸载时terminate()为单任务创建 Worker或实现协作式取消大数据传输结构化克隆使用 Transferable 降低复制成本大整数精度普通Number演示使用BigInt或任意精度方案多次创建开销组件挂载时创建一次共享 Worker、Worker 池或按需懒加载总结React 页面发生卡顿的本质是 CPU 密集型同步代码长时间占据主线程而不是缺少Promise或async/await。Web Worker 通过独立执行环境承担纯计算任务再以消息机制和主线程交换数据让页面渲染与复杂运算各司其职。new Worker负责创建后台线程new URL(..., import.meta.url)为构建工具提供可靠的资源定位双方使用postMessage发送消息、使用onmessage接收MessageEvent业务数据统一从event.data读取。在 React 中useRef返回的稳定容器适合持有 Worker 实例current让事件处理函数跨渲染访问同一个对象useState保存loading与result负责触发页面更新useEffect则把 Worker 的创建、监听和销毁与组件生命周期对齐。掌握这套分工之后面对图像处理、加密计算、游戏运算和浏览器端 AI 等重任务就能先划清 UI 与计算边界再设计清晰、可扩展的消息协议。同时也要记住Worker 解决的是主线程响应问题数值精度、算法效率、错误恢复和并发控制仍需要单独设计。
返回列表