useRef 的三个段位:从 DOM 引用到 Web Worker,彻底搞懂 React 的"非响应式"利器
useRef是 React 中最容易被低估的 Hook。很多人只知道它能"获取 DOM",却不知道它还能存储可变对象、持有 Worker 线程、做性能优化。本文从 DOM 编程的性能困境出发,带你逐层深入 useRef 的三个段位------DOM 引用 → 可变对象 → Web Worker 管理,并对比 useState 揭示"响应式 vs 非响应式"的本质差异。全文代码可直接运行,建议收藏后动手实践。
一、为什么 React 要规避 DOM 编程?
1.1 JS 引擎与渲染引擎的分离
浏览器内部有两个核心引擎,它们运行在不同的世界中:
css
┌──────────────────────────────────────────┐
│ 浏览器进程 │
│ │
│ ┌──────────────┐ ┌──────────────┐ │
│ │ V8 引擎 │ │ 渲染引擎 │ │
│ │ │ │ │ │
│ │ 执行 JS 代码 │ │ 解析 HTML │ │
│ │ 运行业务逻辑 │ │ 构建 DOM 树 │ │
│ │ 计算和处理 │ │ CSSOM + 渲染│ │
│ └──────┬───────┘ └──────┬───────┘ │
│ │ │ │
│ └───────桥接────────┘ │
│ (跨引擎通信开销大) │
└──────────────────────────────────────────┘
JS 操作 DOM 为什么慢?
javascript
JS 引擎(V8) 渲染引擎
│ │
│ document.getElementById │
│ ──────────────────────▶ │ 跨引擎调用
│ │ 查找 DOM 节点
│ ◀────────────────────── │ 返回 DOM 对象
│ │
│ element.style.color │
│ ──────────────────────▶ │ 跨引擎修改
│ │ 触发重排/重绘
│ │
每次 JS 操作 DOM,都要跨越引擎边界通信------这是 DOM 编程耗性能的根本原因。
1.2 React 的解法:规避 DOM 编程
ini
传统 JS 开发(jQuery 时代):
直接操作 DOM
document.getElementById('app').innerHTML = '...'
性能瓶颈在跨引擎通信
React / Vue 开发:
不直接操作 DOM
┌──────────┐ ┌──────────────┐ ┌──────────┐
│ useState │───▶│ React 虚拟DOM │───▶│ 真实 DOM │
│ 数据状态 │ │ Diff 算法 │ │ 自动更新 │
└──────────┘ └──────────────┘ └──────────┘
你只管改数据,React 帮你改 DOM
前端开发方式的根本转变:从"命令式操作 DOM"到"声明式数据驱动"。
二、useRef 第一段位:DOM 引用
2.1 场景:自动聚焦输入框
有些场景必须直接操作 DOM,比如页面加载后自动 focus 输入框------把用户当小白,不用让他们手动点一下。
jsx
import { useRef, useEffect, useState } from 'react';
const App = () => {
const [count, setCount] = useState(0);
// ref 对象引用,初始值为 null
// 未来它会引用真实的 DOM 节点
const inputRef = useRef(null);
useEffect(() => {
// 组件挂载后,inputRef.current 已指向 input DOM 节点
inputRef.current.focus();
}, []);
return (
<>
<input
type="text"
placeholder="请输入用户名"
ref={inputRef} {/* 将 DOM 节点绑定到 ref */}
/>
{count}
<button onClick={() => setCount(count + 1)}>增加</button>
</>
);
};
2.2 useRef 操作 DOM 的三步法
ini
步骤一:声明 ref,初始值为 null
const inputRef = useRef(null);
│
▼
步骤二:在 JSX 中通过 ref 属性绑定
<input ref={inputRef} />
│
▼ React 挂载组件时,自动将 DOM 节点赋值给
│ inputRef.current
▼
步骤三:在 useEffect 中使用
useEffect(() => {
inputRef.current.focus(); // current 指向 DOM 节点
}, []);
2.3 为什么在 useEffect 中操作 DOM?
jsx
// ❌ 错误:在渲染阶段直接操作
const App = () => {
const inputRef = useRef(null);
inputRef.current.focus(); // 此时 DOM 可能还没挂载!
return <input ref={inputRef} />;
};
// ✅ 正确:在 useEffect 中操作
const App = () => {
const inputRef = useRef(null);
useEffect(() => {
inputRef.current.focus(); // DOM 已挂载,安全操作
}, []);
return <input ref={inputRef} />;
};
渲染时序:
ini
组件函数执行(渲染阶段) DOM 还不存在
│ inputRef.current = null
▼
React 创建 DOM 节点 inputRef.current = <input>
│
▼
useEffect 执行 此时可以安全操作 DOM
│
▼
inputRef.current.focus() ✅ DOM 已就绪
三、useRef 第二段位:可变对象(非响应式状态)
3.1 场景:计数器但不需要响应式
有时候你需要存储一个可变的值,但不希望它触发重新渲染:
jsx
import { useRef, useState } from 'react';
const App = () => {
const numRef = useRef(0); // 非响应式可变值
const [, forceRender] = useState(0); // 响应式触发器
console.log(numRef.current);
return (
<>
<div
onClick={() => {
numRef.current += 1; // 修改 ref 不会触发渲染
forceRender(); // 手动触发渲染来更新 UI
}}
>
{numRef.current}
</div>
</>
);
};
3.2 useRef vs useState:响应式 vs 非响应式
ini
useState(响应式):
setCount(1) ──▶ React 检测到变化 ──▶ 重新渲染组件 ──▶ UI 更新
✅ 数据变化自动反映到 UI
useRef(非响应式):
ref.current = 1 ──▶ React 不关心 ──▶ 不触发渲染 ──▶ UI 不变
✅ 数据变化但不触发渲染(性能优化)
| 维度 | useState | useRef |
|---|---|---|
| 响应式 | ✅ 修改触发重新渲染 | ❌ 修改不触发渲染 |
| 用途 | 业务数据状态 | DOM 引用、可变对象、Worker 等 |
| 渲染时保留 | ✅ 每次渲染保留最新值 | ✅ 每次渲染保留同一个对象 |
| 更新方式 | setState(newValue) |
ref.current = newValue |
| 触发渲染 | ✅ 是 | ❌ 否 |
| 适用场景 | 需要驱动 UI 更新的数据 | 不需要驱动 UI 的持久引用 |
3.3 什么时候用 useRef 存值?
jsx
// 场景一:定时器 ID(不需要触发渲染)
const timerRef = useRef(null);
timerRef.current = setInterval(() => { ... }, 1000);
clearInterval(timerRef.current);
// 场景二:上一次的状态值(用于比较)
const prevCountRef = useRef(0);
const [count, setCount] = useState(0);
useEffect(() => {
prevCountRef.current = count; // 记录上一次的值
}, [count]);
// 场景三:是否是首次渲染的标记
const isFirstRender = useRef(true);
useEffect(() => {
if (isFirstRender.current) {
isFirstRender.current = false;
return; // 首次渲染跳过
}
// 非首次渲染的逻辑
}, [someValue]);
3.4 useRef 的核心定义
useRef是 React 提供的一个持久可变对象 的 Hook 函数,经常用来引用 DOM 节点对象。它有一个current属性,可以指向任何值或对象,不会触发渲染。
csharp
useRef 返回的对象生命周期:
组件挂载 ──▶ 创建 ref 对象 { current: initialValue }
│
│ 每次重新渲染:返回同一个对象(不会重新创建)
│
组件更新 ──▶ ref.current 可以被修改,但不会触发渲染
│
组件卸载 ──▶ ref 对象被销毁
四、JS 单线程与 Event Loop 的局限
4.1 为什么 JS 是单线程?
javascript
// 如果 JS 是多线程:
// 线程 A:document.getElementById('app').innerHTML = 'Hello'
// 线程 B:document.getElementById('app').innerHTML = 'World'
// → 冲突!DOM 到底显示什么?
JS 设计为单线程的原因是保证一致性------浏览器中的交互(滚动、点击、输入)如果由多个线程同时操作 DOM,就会产生冲突。单线程保证了简单和安全。
4.2 Event Loop:单线程的异步魔法
vbnet
┌──────────────────────────────────────────────┐
│ Event Loop 机制 │
│ │
│ ┌─────────────────┐ │
│ │ Call Stack │ 主线程执行栈 │
│ │ (同步代码) │ │
│ └────────┬────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────┐ ┌─────────────────┐ │
│ │ Web APIs │ │ Callback Queue │ │
│ │ (异步任务) │ │ (回调队列) │ │
│ │ │ │ │ │
│ │ setTimeout │───▶│ 回调函数排队 │ │
│ │ fetch │───▶│ 等待执行 │ │
│ │ DOM Events │───▶│ │ │
│ └─────────────────┘ └────────┬────────┘ │
│ │ │
│ Call Stack 空闲时 │
│ ◀──取出回调执行 │
└──────────────────────────────────────────────┘
Event Loop 的问题:
vbnet
普通异步任务(fetch、setTimeout):
✅ 不阻塞主线程,Event Loop 能处理
耗时计算任务(大循环、LLM 推理、游戏物理引擎):
❌ 虽然是同步代码,但执行时间太长
❌ 占用主线程,阻塞所有交互
❌ 用户点击、滚动全部无响应
javascript
// 这段代码会冻结页面 3 秒
console.time('主线程');
for (let i = 0; i < 100000000; i++) {
// 耗时计算,主线程被完全阻塞
}
console.timeEnd('主线程');
// 在此期间,用户无法点击、滚动、输入
五、useRef 第三段位:管理 Web Worker
5.1 Web Worker:浏览器的多线程方案
scss
┌──────────────────────────────────────────────────┐
│ 浏览器 │
│ │
│ ┌────────────────┐ ┌──────────────────┐ │
│ │ 主线程 │ │ Worker 线程 │ │
│ │ (UI 渲染+交互) │ │ (复杂计算) │ │
│ │ │ │ │ │
│ │ React 渲染 │ │ LLM 推理 │ │
│ │ 用户交互 │ │ 游戏物理引擎 │ │
│ │ DOM 操作 │ │ 大量数据处理 │ │
│ │ │ │ │ │
│ │ postMessage │────▶│ onmessage │ │
│ │ │◀────│ postMessage │ │
│ └────────────────┘ └──────────────────┘ │
│ │
│ 独立内存空间,互不阻塞 │
└──────────────────────────────────────────────────┘
Web Worker 的特点:
| 特性 | 主线程 | Worker 线程 |
|---|---|---|
| 能操作 DOM | ✅ | ❌ 不能 |
| 能渲染 UI | ✅ | ❌ 不能 |
| 耗时计算 | ❌ 会阻塞 | ✅ 不阻塞主线程 |
| 内存空间 | 主线程内存 | 独立内存 |
| 通信方式 | - | postMessage 消息机制 |
5.2 用 useRef 持有 Worker 线程
jsx
import { useRef, useState, useEffect } from 'react';
function App() {
// Worker 线程的引用,持久化存储
const workerRef = useRef(null);
useEffect(() => {
// 开启一个 Worker 线程(开销较大,只创建一次)
workerRef.current = new Worker(
new URL('./worker.js', import.meta.url)
);
// 组件卸载时清理 Worker
return () => {
workerRef.current.terminate();
};
}, []);
return (
<>
<button onClick={() => {
// 向 Worker 发送消息
workerRef.current.postMessage('start');
}}>
开始计算
</button>
</>
);
}
export default App;
worker.js:
javascript
// Worker 线程中的代码
console.log('worker online');
// 接收主线程消息
self.onmessage = function(event) {
console.log('收到主线程消息:', event.data);
// 执行耗时计算
let result = 0;
for (let i = 0; i < 1000000000; i++) {
result += i;
}
// 将结果发送回主线程
self.postMessage({ result });
};
5.3 为什么用 useRef 持有 Worker?
scss
如果用 useState 存储 Worker:
const [worker, setWorker] = useState(null);
useEffect(() => {
setWorker(new Worker(...)); // 触发重新渲染
}, []);
❌ 问题:
1. Worker 创建后不需要触发渲染
2. 每次 setWorker 都会导致组件重新渲染
3. Worker 对象不是"状态",是"引用"
如果用 useRef 存储 Worker:
const workerRef = useRef(null);
useEffect(() => {
workerRef.current = new Worker(...); // 不触发渲染
}, []);
✅ 优势:
1. 不触发不必要的渲染
2. Worker 对象在整个组件生命周期内持久存在
3. 符合 useRef 的语义------引用非响应式对象
5.4 Worker 创建语法解析
javascript
workerRef.current = new Worker(
new URL('./worker.js', import.meta.url)
);
arduino
new URL('./worker.js', import.meta.url)
│ │
│ └── 基准路径:当前模块的 URL
│
└── 相对路径:Worker 脚本文件
最终解析为绝对路径:
http://localhost:5173/src/worker.js (Vite 开发环境)
https://example.com/assets/worker-abc123.js (生产构建)
new Worker(url)
→ 浏览器下载该脚本
→ 在独立线程中执行
→ 返回 Worker 实例
为什么用 new URL + import.meta.url?
这是 Vite/Webpack 等构建工具推荐的方式。构建工具会识别这种写法,将 Worker 脚本单独打包成一个文件,而不是内联到主 bundle 中。
5.5 主线程与 Worker 的完整通信
jsx
import { useRef, useState, useEffect } from 'react';
function App() {
const workerRef = useRef(null);
const [result, setResult] = useState(null);
const [loading, setLoading] = useState(false);
useEffect(() => {
workerRef.current = new Worker(
new URL('./worker.js', import.meta.url)
);
// 监听 Worker 返回的消息
workerRef.current.onmessage = function(event) {
setResult(event.data.result);
setLoading(false);
};
return () => {
workerRef.current.terminate();
};
}, []);
const handleCalculate = () => {
setLoading(true);
// 向 Worker 发送任务
workerRef.current.postMessage({ task: 'calculate' });
};
return (
<>
<button onClick={handleCalculate} disabled={loading}>
{loading ? '计算中...' : '开始计算'}
</button>
{result && <p>结果: {result}</p>}
</>
);
}
通信流程图:
scss
主线程 Worker 线程
│ │
│ postMessage({task:'calc'}) │
│───────────────────────────────▶│
│ │
│ │ 执行耗时计算
│ │ (不阻塞主线程)
│ 用户可以正常交互 │
│ 页面滚动/点击正常 │
│ │
│ postMessage({result: xxx}) │
│◀───────────────────────────────│
│ │
│ onmessage 接收结果 │
│ setResult(xxx) 更新 UI │
▼ ▼
六、useRef 的三个段位总结
ini
段位一:DOM 引用(最常用)
┌─────────────────────────────┐
│ const inputRef = useRef(null) │
│ <input ref={inputRef} /> │
│ inputRef.current.focus() │
└─────────────────────────────┘
场景:focus、滚动定位、测量元素尺寸、操作 canvas
段位二:可变对象(非响应式存储)
┌─────────────────────────────┐
│ const timerRef = useRef(null) │
│ timerRef.current = setInterval│
│ clearInterval(timerRef.current)│
└─────────────────────────────┘
场景:定时器 ID、上一次的值、首次渲染标记
段位三:Worker 线程管理(高级)
┌─────────────────────────────┐
│ const workerRef = useRef(null)│
│ workerRef.current = new Worker│
│ workerRef.current.postMessage │
└─────────────────────────────┘
场景:复杂计算、LLM 推理、游戏物理引擎
七、最佳实践
7.1 什么时候该用 useRef?
| 场景 | 用 useState | 用 useRef |
|---|---|---|
| 需要驱动 UI 更新的数据 | ✅ | ❌ |
| DOM 节点引用 | ❌ | ✅ |
| 定时器/订阅 ID | ❌ | ✅ |
| Worker 实例 | ❌ | ✅ |
| 不需要触发渲染的中间值 | ❌ | ✅ |
| 跨渲染周期保留的引用 | ❌ | ✅ |
7.2 useRef 使用规范
jsx
// ✅ 初始值要明确
const inputRef = useRef(null); // DOM 引用,初始 null
const countRef = useRef(0); // 数值,初始 0
const workerRef = useRef(null); // Worker,初始 null
// ✅ 修改 current 属性,不要重新赋值整个 ref
ref.current = newValue; // ✅
// ref = { current: newValue }; // ❌ 不要这样做
// ✅ 清理副作用
useEffect(() => {
workerRef.current = new Worker(...);
return () => {
workerRef.current.terminate(); // 卸载时清理
};
}, []);
// ❌ 不要在渲染阶段读写 ref.current
const App = () => {
const ref = useRef(0);
ref.current = 1; // ❌ 渲染阶段不要修改 ref
return <div />;
};
// ✅ 在事件处理或 useEffect 中修改
const App = () => {
const ref = useRef(0);
useEffect(() => {
ref.current = 1; // ✅ 在 useEffect 中修改
}, []);
return <div />;
};
八、总结
8.1 知识体系图
sql
useRef(持久可变对象 Hook)
├── 段位一:DOM 引用
│ ├── useRef(null) → ref 属性绑定 → current 操作 DOM
│ └── 必须在 useEffect 中操作(等 DOM 挂载完成)
├── 段位二:非响应式可变值
│ ├── 不触发渲染的数据存储
│ ├── 定时器 ID、上一次的值、首次渲染标记
│ └── vs useState:响应式 vs 非响应式
└── 段位三:Worker 线程管理
├── JS 单线程 → Event Loop → 耗时任务阻塞
├── Web Worker 独立线程,postMessage 通信
└── useRef 持有 Worker 实例,不触发渲染
8.2 核心概念速查
| 概念 | 要点 |
|---|---|
| DOM 编程为什么慢 | JS 引擎与渲染引擎跨引擎通信开销 |
| React 规避 DOM | useState 数据驱动,React 自动更新 DOM |
| useRef 定义 | 持久可变对象,有 current 属性,不触发渲染 |
| useRef vs useState | 非响应式 vs 响应式 |
| JS 单线程 | 保证 DOM 操作一致性 |
| Event Loop 局限 | 异步无阻塞,但耗时同步任务会卡死主线程 |
| Web Worker | 浏览器多线程方案,独立内存,postMessage 通信 |
| useRef + Worker | 用 ref 持有 Worker 实例,不触发渲染 |
8.3 React Hooks 的分工
useState → 响应式业务数据(触发渲染)
useEffect → 副作用管理(请求、订阅、DOM 操作)
useRef → 非响应式持久引用(DOM、Worker、可变值)
三个 Hook 各司其职:useState 管数据状态,useEffect 管副作用,useRef 管引用。理解它们的边界,才能写出高性能、可维护的 React 组件。
如果这篇文章对你有帮助,欢迎点赞 和收藏!