今日重点
- 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.time到console.timeEnd之间的所有代码都在主线程执行- 这段期间浏览器没法处理用户交互事件,渲染也没法进行
- 异步只能把任务放到后面执行,但不能让任务本身变快或变小
为什么这样设计
浏览器选单线程是为了避免多线程同时改 DOM 产生冲突。但代价就是重计算会卡页面。Web Worker 就是为了弥补这个缺陷 ------ 不碰 DOM 的活,全扔到另一条线程。
容易混淆
Event Loop 和 Web Worker 不是一回事。
| Event Loop | Web Worker | |
|---|---|---|
| 线程 | 还是主线程 | 独立新线程 |
| 解决什么 | 异步等待不卡主线程 | 重计算不占主线程 |
| 适用 | fetch、定时器、事件回调 | 大量循环、图像处理、游戏逻辑 |
| 本质 | 单线程里的排队机制 | 真正的多线程 |
自测
- Event Loop 异步能解决 for 循环卡页面的问题吗?为什么?
- 浏览器为什么不让 Worker 操作 DOM?
参考答案
- 不能。异步只是把任务挪到后面执行,但 for 循环本身还是要在主线程跑完,跑的时候依然独占线程,页面照样卡。
- 避免多线程同时修改同一个 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。原因和单线程设计的初衷一致:避免多线程操作页面产生竞态条件。
自测
- Worker 能调用
document.querySelector吗?为什么? - Worker 能发 fetch 请求吗?
参考答案
- 不能。Worker 里没有
document对象,也没有 DOM 树,这是刻意的安全限制。 - 能。
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 开发模式兼容 |
自测
- 为什么 Worker 用 useRef 而不用 useState?
- 为什么
new Worker()要放在 useEffect 里? new URL('./worker.js', import.meta.url)中的./worker.js是相对谁解析的?
参考答案
- Worker 实例不需要触发渲染,useRef 存就够了;用 useState 每次修改都会触发无意义的重新渲染。
- 函数体每次渲染都执行,放在顶层会反复创建 Worker,内存泄漏;useEffect 空依赖只在挂载后执行一次。同时让渲染先完成,Worker 不挡首屏。
- 相对当前 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)
容易混淆
两边的 e 和 e.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 |
自测
e.data在 App.jsx 和 worker.js 中各代表什么?const { num } = e.data和const num = e.data.num是等价的吗?- 主线程用
workerRef.current.postMessage(),Worker 用什么?
参考答案
- App 中 e.data 是 Worker 发回来的计算结果
{ result: sum };Worker 中 e.data 是主线程发来的命令{ num: 88 }。 - 完全等价,解构
{ num }就是e.data.num的简写。 - 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 让错误尽早暴露。
自测
- 只调
terminate()不置 null 会有什么隐患? - 清理函数
return () => {}什么时候执行?
参考答案
- 引用还指向已销毁的 Worker 实例,后续误调用 postMessage 会静默失败,难以排查。
- 组件从 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是什么?