useRef + Web Worker:React 中的耗时计算不卡页面

今日重点

  • JS 单线程下,Event Loop 解决"等待"但解决不了"计算本身太重"
  • Web Worker 开辟独立线程,不能操作 DOM,但能计算、请求、读写存储
  • useRef 持久存放 Worker 实例,组件重渲染不会重置线程对象
  • useEffect 挂载后初始化,让渲染先行,Worker 不挡首屏
  • postMessage 发、onmessage 收,两边格式相同,方向不同
  • 解构 const { num } = e.data 从消息对象中提取需要的字段
  • 卸载时 terminate() 杀线程,再置 null 清引用

知识关系

useRef 创建盒子 → useEffect 挂载后 new Worker 装进去 → postMessage/onmessage 双向通信 → 卸载时 terminate + 置 null 清理。这条链路就是 useRef + Worker 的标准用法。


单线程之困

知识点

JS 单线程一次只能做一件事。网络请求这类"等待型"任务,Event Loop 异步机制可以应对 ------ 扔到一边等,主线程继续响应用户。但纯计算型任务不同:一个 for 循环跑几亿次,线程就被占满,页面冻结。

你笔记里总结了:LLM、游戏这类非界面的耗时业务逻辑,Event Loop 异步搞不定。

代码

javascript 复制代码
// 主线程直接跑大量循环 → 页面卡死
console.time('主线程')
for (let i = 0; i < 1000000; i++) {
  console.log(i)
}
console.timeEnd('主线程')
// 整个过程用户点不了任何东西

运行过程

复制代码
用户触发计算 → 主线程进入 for 循环
  → JS 引擎被独占,渲染引擎被堵塞
  → 页面冻结,点击、滚动全无响应
  → 循环结束才能恢复

拆解

  • console.timeconsole.timeEnd 之间的所有代码都在主线程执行
  • 这段期间浏览器没法处理用户交互事件,渲染也没法进行
  • 异步只能把任务放到后面执行,但不能让任务本身变快或变小

为什么这样设计

浏览器选单线程是为了避免多线程同时改 DOM 产生冲突。但代价就是重计算会卡页面。Web Worker 就是为了弥补这个缺陷 ------ 不碰 DOM 的活,全扔到另一条线程。

容易混淆

Event Loop 和 Web Worker 不是一回事。

Event Loop Web Worker
线程 还是主线程 独立新线程
解决什么 异步等待不卡主线程 重计算不占主线程
适用 fetch、定时器、事件回调 大量循环、图像处理、游戏逻辑
本质 单线程里的排队机制 真正的多线程

自测

  1. Event Loop 异步能解决 for 循环卡页面的问题吗?为什么?
  2. 浏览器为什么不让 Worker 操作 DOM?

参考答案

  1. 不能。异步只是把任务挪到后面执行,但 for 循环本身还是要在主线程跑完,跑的时候依然独占线程,页面照样卡。
  2. 避免多线程同时修改同一个 DOM 节点产生冲突。数据一致性优先于功能便利。

Worker 是什么

知识点

Web Worker 是浏览器提供的独立线程,有自己的内存空间。它不能操作 DOM(没有 document、没有 window),但能做的事远不止数学计算 ------ fetch 请求、IndexedDB 读写、Blob 处理、定时器都能用。

代码

ini 复制代码
// worker.js --- 独立线程中执行的脚本
self.onmessage = (e) => {
  const { num } = e.data
  let sum = 0
  for (let i = 0; i < 50000000; i++) {
    sum += num * i
  }
  self.postMessage({ result: sum })
}

运行过程

php 复制代码
Worker 线程被 new Worker() 创建 → 立即执行顶层代码
  → 注册 self.onmessage 监听
  → 等待主线程发消息
  → 收到消息后开始计算(主线程不受任何影响)
  → 计算完成 → self.postMessage 把结果发回主线程

拆解

  • self 在 Worker 里相当于主线程的 window,指向 Worker 自身的全局作用域
  • self.onmessage 注册消息监听,主线程每次 postMessage 都会触发这个回调
  • Worker 里的计算不占主线程,两个线程并行执行

为什么这样设计

Worker 只有纯 JS 环境,刻意去掉了 DOM API。原因和单线程设计的初衷一致:避免多线程操作页面产生竞态条件。

自测

  1. Worker 能调用 document.querySelector 吗?为什么?
  2. Worker 能发 fetch 请求吗?

参考答案

  1. 不能。Worker 里没有 document 对象,也没有 DOM 树,这是刻意的安全限制。
  2. 能。fetch、WebSocket、IndexedDB 等不依赖 DOM 的 API 在 Worker 里都可以用。

useRef 持 Worker

知识点

Worker 实例不需要显示在页面上,用 useRef 存而不是 useState。ref 返回的对象在每次渲染时都指向同一个地址,Worker 不会因为组件重渲染而丢失或重复创建。

useEffect 空依赖确保只在挂载后执行一次 ------ 让组件先渲染完、页面先出来,Worker 不挡首屏。

代码

javascript 复制代码
const workerRef = useRef(null)

useEffect(() => {
  workerRef.current = new Worker(
    new URL('./worker.js', import.meta.url)
  )
  // 监听、通信等逻辑
}, [])

运行过程

sql 复制代码
组件函数执行 → useRef(null) 创建 { current: null }
  → 返回 JSX → React 渲染 DOM → 页面出来了
  → useEffect 回调执行 → new Worker() 创建线程
  → workerRef.current 从 null 变成 Worker 实例
  → 后续渲染中,workerRef.current 始终是同一个 Worker 实例

拆解

  • useRef(null) 初始 null:此时 Worker 还没创建,组件还没挂载
  • useEffect(..., []):挂载后才执行,此时 DOM 已就绪
  • workerRef.current = new Worker(...):手动把 Worker 实例塞进 ref 的 .current
  • 和 DOM ref 的区别:DOM ref 是 React 自动帮你填 .current,Worker ref 是你手动填

为什么这样设计

为什么不用 useState? Worker 实例改来改去不需要触发渲染,用 state 会浪费一次无意义的更新。

为什么不在函数体顶层直接 new Worker() 函数体每次渲染都执行,会反复创建新 Worker 实例,旧的不销毁,内存泄漏。

为什么要放在 useEffect? 渲染先行 ------ new Worker() 有开销,如果放在渲染路径上,会拖慢首屏。放 useEffect 里等于"渲染完页面的第一眼之后,再慢慢创建 Worker"。

容易混淆

两个加载路径的写法:

arduino 复制代码
// 方式一:文件放 public/ 目录,直接写路径
new Worker('/worker.js')

// 方式二:文件放 src/ 目录,用 URL 构造
new Worker(new URL('./worker.js', import.meta.url))
直接路径 /worker.js URL 构造 ./worker.js
文件位置 public/ 目录 src/ 目录
Vite 处理 原样加载,不经编译 经过 Vite 编译打包
{ type: 'module' } 不需要(经典脚本) 可加可不加,Vite 开发模式兼容

自测

  1. 为什么 Worker 用 useRef 而不用 useState?
  2. 为什么 new Worker() 要放在 useEffect 里?
  3. new URL('./worker.js', import.meta.url) 中的 ./worker.js 是相对谁解析的?

参考答案

  1. Worker 实例不需要触发渲染,useRef 存就够了;用 useState 每次修改都会触发无意义的重新渲染。
  2. 函数体每次渲染都执行,放在顶层会反复创建 Worker,内存泄漏;useEffect 空依赖只在挂载后执行一次。同时让渲染先完成,Worker 不挡首屏。
  3. 相对当前 JS 文件(App.jsx)的位置解析。import.meta.url 是当前模块的完整 URL,./worker.js 拼上去得到同目录下的 worker.js。

消息机制

知识点

主线程和 Worker 没有共享内存,唯一通信方式就是消息 ------ postMessage 发,onmessage 收。message 就是"消息/信息"的意思。你可以理解为信封:postMessage 是把数据装进信封寄出去,onmessage 是收到信后打开,e.data 是信里的内容。

两边格式完全一样,区别只是调用对象不同:主线程用 workerRef.current.postMessage,Worker 用 self.postMessage

代码

scss 复制代码
// App.jsx --- 发命令和收结果
const startHeavyCalc = () => {
  setLoading(true)
  workerRef.current.postMessage({ num: 88 })
}

// useEffect 中注册监听
workerRef.current.onmessage = (e) => {
  const { result } = e.data
  setResult(result)
  setLoading(false)
}
ini 复制代码
// worker.js --- 收命令和发结果
self.onmessage = (e) => {
  const { num } = e.data
  let sum = 0
  for (let i = 0; i < 50000000; i++) {
    sum += num * i
  }
  self.postMessage({ result: sum })
}

运行过程

markdown 复制代码
1. 用户点击按钮
2. startHeavyCalc → setLoading(true),按钮变灰
3. postMessage({ num: 88 }) → 消息发出
4. Worker: onmessage 触发 → e.data = { num: 88 }
5. 解构取出 num → 开始五千万次循环
6. 主线程空闲,页面流畅,用户可正常交互
7. Worker 算完 → self.postMessage({ result: sum })
8. App: onmessage 触发 → e.data = { result: sum }
9. 解构取出 result → setResult + setLoading(false) → 页面显示结果

拆解

  • workerRef.current.postMessage({ num: 88 }):主线程发,{ num: 88 } 是传的数据
  • self.onmessage = (e) => {}:Worker 收,e 是 MessageEvent,e.data 就是发来的数据
  • self.postMessage({ result: sum }):Worker 发回去,{ result: sum } 会在主线程的 e.data
  • 你传什么,对方 e.data 就是什么,两边各说各的 content
  • const { num } = e.data:对象解构,等价于 const num = e.data.num,只取需要的属性,忽略其他(比如 type

容易混淆

两边的 ee.data

ini 复制代码
主线程 onmessage:
  e 是 Worker 发来的 MessageEvent
  e.data = { result: sum }        ← Worker self.postMessage 传的

Worker onmessage:
  e 是主线程发来的 MessageEvent
  e.data = { num: 88 }            ← App workerRef.current.postMessage 传的
App 的 e.data Worker 的 e.data
谁发的 Worker 的 self.postMessage App 的 postMessage
内容 计算结果 计算命令
方向 Worker → 主线程 主线程 → Worker

自测

  1. e.data 在 App.jsx 和 worker.js 中各代表什么?
  2. const { num } = e.dataconst num = e.data.num 是等价的吗?
  3. 主线程用 workerRef.current.postMessage(),Worker 用什么?

参考答案

  1. App 中 e.data 是 Worker 发回来的计算结果 { result: sum };Worker 中 e.data 是主线程发来的命令 { num: 88 }
  2. 完全等价,解构 { num } 就是 e.data.num 的简写。
  3. Worker 用 self.postMessage()self 指向 Worker 自身的全局作用域。

销毁 Worker

知识点

组件卸载时必须销毁 Worker,否则线程会一直存在占用内存。先调 terminate() 立即终止线程,再把引用置 null 防止后续代码误用已销毁的实例。

代码

scss 复制代码
useEffect(() => {
  workerRef.current = new Worker(...)

  return () => {
    workerRef.current.terminate()   // ① 杀线程,释放内存
    workerRef.current = null        // ② 清引用,防止野指针
  }
}, [])

拆解

  • terminate():立即终止 Worker 线程,浏览器回收内存
  • = null:清掉 JS 层面的引用,后续代码如果误调 .postMessage() 会立即报错而非静默失败
  • 两步顺序不能反:先杀线程,再清引用

为什么这样设计

terminate 不清 null,后续如果有代码访问 workerRef.current,拿到的还是已销毁的 Worker,调用 postMessage 就静默失败,排查困难。置 null 让错误尽早暴露。

自测

  1. 只调 terminate() 不置 null 会有什么隐患?
  2. 清理函数 return () => {} 什么时候执行?

参考答案

  1. 引用还指向已销毁的 Worker 实例,后续误调用 postMessage 会静默失败,难以排查。
  2. 组件从 DOM 中卸载时(比如路由跳转、条件渲染隐藏),React 会执行 useEffect 返回的清理函数。

代码串起来

scss 复制代码
用户点击按钮
  → setLoading(true) 按钮变灰
  → postMessage({ num: 88 }) 发给 Worker
  → Worker onmessage 触发,e.data = { num: 88 }
  → 解构 { num } 取值
  → for 五千万次(主线程不卡,页面流畅)
  → self.postMessage({ result: sum }) 发结果
  → App onmessage 触发,e.data = { result: sum }
  → 解构 { result } 取值
  → setResult(result) + setLoading(false) 显示结果

组件卸载
  → terminate() 杀线程
  → = null 清引用

最后回顾

useRef + Web Worker 就三条:useRef 创建盒子持久化 → useEffect 挂载后 new Worker 初始化 → postMessage / onmessage 双向通信。卸载时 terminate + 置 null。

核心记住:Worker 不碰 DOM 但能干很多事,消息机制是唯一通信通道,useRef 不怕重渲染,useEffect 让渲染先行。


自查清单

  • Event Loop 能解决 for 循环卡页面吗?为什么?
  • Worker 能做什么、不能做什么?
  • 为什么 Worker 用 useRef 而不是 useState?
  • 为什么 new Worker() 要放 useEffect 而不是函数顶层?
  • 主线程和 Worker 的 e.data 各是什么内容?分别包含哪些字段?
  • const { num } = e.data 等价于什么写法?
  • new Worker('/worker.js')new URL('./worker.js', import.meta.url) 的区别?
  • 卸载时为什么先 terminate 再置 null?
  • Worker 里的 self 是什么?
相关推荐
mONESY1 小时前
React Router v7 完全实战指南:从入门到鉴权,一套代码全搞定
javascript
万敏1 小时前
Vue3 全栈实战第五周:Vitest 单元测试从零搭建实战记录
vue.js·node.js·全栈
To_OC1 小时前
LC 74 搜索二维矩阵:换皮的二分查找,我居然一开始没看出来
javascript·算法·leetcode
橘子星1 小时前
一篇文章搞懂 useRef:聚焦 DOM、模拟 forceRender、管理 Worker 都靠它
前端·javascript
前端Hardy1 小时前
Vue 终于杀进终端界!这个开源项目让 CLI 开发像写网页一样简单
前端·javascript·后端
一次旅行1 小时前
多智能体编排实战:拆解Plan-and-Execute范式+三层记忆架构,手写无依赖轻量Agent调度引擎
前端·javascript·架构
sugar__salt4 小时前
跟着 Demo 学 Pinia:两种仓库写法 + 完整 TodoList 复现
前端·javascript·vue.js·前端框架·vue
大家的林语冰5 小时前
🎉 Vercel 官宣 Next 16.3 正式发布,GitHub 第一全栈框架再次进化!
前端·javascript·前端框架
张元清7 小时前
React useEvent Hook:告别过期闭包的稳定回调 (2026)
javascript·react.js