useState 存个计数器,页面怎么不动?彻底搞懂 useRef
你写了一个计数器:
jsx
function Counter() {
const [count, setCount] = useState(0);
const add = () => {
count += 1; // 改了 count
console.log(count); // 打印出来确实是 1
};
return <div onClick={add}>{count}</div>;
}
点一下,控制台打印 1。但页面上------纹丝不动,还是 0。
你愣了一下,改成 setCount(count + 1)。好了,页面动了。
但过两天你又遇到一个新场景:需要在组件里持有一个 Web Worker 实例,要给 worker 发消息。你用 useState 存了它:
jsx
const [worker, setWorker] = useState(null);
useEffect(() => {
const w = new Worker(new URL('./worker.js', import.meta.url));
setWorker(w); // ⚠️ 触发了一次不必要的渲染
}, []);
页面正常工作。但你觉得哪里不太对------我就存个 worker 引用,为什么还要让 React 重新跑一遍渲染流程?
这篇文章就是解决这个困惑的:useRef 到底是什么,什么时候该用它而不是 useState。
一句话,useRef 到底是什么
useRef 是 React 提供的「非响应式可变盒子」------它能在多次渲染之间持久保存一个值,但修改它不会触发组件重新渲染。
拆开来看:
- 持久:值从组件挂载到卸载一直存在,不会因为渲染而被重置
- 可变 :你可以直接修改
ref.current,不需要 setter 函数 - 非响应式 :改了不会触发渲染------这是它和
useState最根本的区别
用一个生活场景来理解:
csharp
useState 就像你的银行账户余额------每次变动银行都会给你发短信通知(触发渲染),
你能在 App 上看到最新数字。
useRef 就像你口袋里的一张便签纸------你可以随时在上面改数字,
但没人会主动通知你更新了。只有你自己低头看(手动读 ref.current)才知道。
这个区别决定了两种完全不同的使用场景。
场景一:绑定 DOM 节点(你最熟悉的用法)
页面加载后,输入框自动获得焦点------这是 useRef 最经典的场景:
jsx
import { useRef, useEffect } from 'react';
function App() {
const inputRef = useRef(null); // 🔑 初始为 null,后面会指向真实 DOM
useEffect(() => {
console.log(inputRef.current); // 挂载后,current 已经指向 input 元素
inputRef.current.focus(); // 直接调用原生 DOM API
}, []);
return <input type="text" ref={inputRef} placeholder="请输入用户名" />;
}
运行结果:
lua
<input type="text" placeholder="请输入用户名"> // console.log 输出
(页面加载后输入框自动聚焦)
这里 ref={inputRef} 做的事情非常简单:React 在创建这个 <input> 的真实 DOM 节点后,把它的引用塞进 inputRef.current。
为什么不能用 useState 存 DOM?
jsx
// ❌ 错误思路
const [inputEl, setInputEl] = useState(null);
// 你打算怎么拿到 DOM 节点?用 ref 回调?
// <input ref={(el) => setInputEl(el)} />
// 这确实能拿到------但会引起双重渲染:
// 第一次渲染:inputEl = null
// setInputEl(domNode) 触发第二次渲染:inputEl = domNode ← 完全没必要
DOM 节点是一个"外部对象",它不属于 React 的数据流。 用 useState 存它,等于强加了一层 React 的响应式开销,而这份开销毫无意义------DOM 节点变了不需要重新渲染,因为 React 已经知道了。
场景二:存储不触发渲染的可变值
看这个计数器:
jsx
function App() {
const numRef = useRef(0); // 🔑 可变对象,初始值 0
const [, forceRender] = useState(0); // ⚠️ 只是一个"手动触发渲染"的手段
console.log(numRef.current); // 每次渲染都会打印当前值
return (
<div onClick={() => {
numRef.current += 1; // 直接改 current,不触发渲染
forceRender(x => x + 1); // 手动强制渲染,让你看到变化
}}>
{numRef.current}
</div>
);
}
等等------既然改了 numRef.current 不触发渲染,那我怎么让用户看到变化?上面用了一个取巧的办法:forceRender。但这只是 Demo 用的展示技巧。
真正的区别在这里
把 useRef 和 useState 放在一起对比,差异一目了然:
| useState | useRef | |
|---|---|---|
| 读取方式 | count |
ref.current |
| 修改方式 | setCount(newValue) |
ref.current = newValue |
| 修改后触发渲染? | ✅ 是 | ❌ 否 |
| 渲染期间值是否稳定? | ✅ 是(同一轮渲染内不变) | ⚠️ 否(随时可变) |
| 典型用途 | UI 数据、表单输入、开关状态 | DOM 引用、定时器 ID、Worker 实例 |
关键认知:useRef 不是为了"省掉一次渲染"而存在的。 它的设计目的是让你持有那些本质上不属于 UI 状态的东西------改了不需要更新界面,但需要在多次渲染间保持同一个引用。
那什么东西"本质上不属于 UI 状态"?看第三个场景。
场景三:持有「外部对象」------最被低估的用法
你的页面需要做一个复杂计算(比如 LLM 推理、图像处理、大量数据排序),放在主线程会卡死界面。于是你打算开一个 Web Worker:
最简单的实现:
jsx
import { useRef, useEffect } from 'react';
function App() {
const workerRef = useRef(null); // 🔑 持久化 Worker 引用,但不触发渲染
useEffect(() => {
// 开启 Worker 线程------开销较大,只需要做一次
workerRef.current = new Worker(
new URL('./worker.js', import.meta.url)
);
// ⚠️ 易错:组件卸载时必须清理 Worker,否则内存泄漏
return () => {
workerRef.current?.terminate();
};
}, []);
return <>{/* Worker 在后台运行,不占用 UI 状态 */}</>;
}
为什么 Worker 必须用 useRef 而不能用 useState?
原因有三层,一层比一层深:
第一层------语义层:Worker 不属于 UI 状态。你的界面不需要根据 "Worker 是哪个实例" 来重新渲染。Worker 是基础设施,不是数据。
第二层------性能层 :用 useState 存 Worker 会触发一次无意义的渲染。虽然一次无所谓,但这是代码腐化的开始------每个"无所谓"最终堆成一座屎山。
第三层------正确性层 :这是最容易被忽略的关键点。假设你用 useState 存 Worker,然后在某个事件处理函数里发消息:
jsx
// ❌ 用 useState 存 Worker 的隐患
const [worker, setWorker] = useState(null);
useEffect(() => {
const w = new Worker(new URL('./worker.js', import.meta.url));
setWorker(w);
}, []);
const handleClick = () => {
worker.postMessage('hello'); // ⚠️ 如果这是组件首次渲染时的闭包,
// worker 还是 null!
};
React 的每次渲染都有自己的 props、state 和事件处理函数。如果你在首次渲染的闭包里捕获了 worker(当时是 null),后面 setWorker 更新了,但那个旧的闭包里的 worker 仍然是 null。
而 useRef 解决了这个问题:ref 对象的引用在组件整个生命周期中不变 ,你始终通过 workerRef.current 访问最新值,不会出现闭包陷阱。
useRef 给你的不是一个"快照",而是一个"指针"。快照会过时,指针永远指向最新值。
回头再看一眼:那行被注释掉的代码
在 Demo 代码里,有一段被注释掉的 for 循环:
javascript
// 被注释掉的主线程阻塞代码
// for(let i = 0; i < 100000000; i++) {
// console.log(i)
// }
这段代码如果取消注释,会发生什么?浏览器卡死------1 亿次循环把主线程占满,在这期间所有用户交互(点击、滚动、输入)全部无响应。
这就是为什么要用 Worker + useRef 的组合:
- Worker 解决了「主线程阻塞」的问题------耗时计算移到后台
- useRef 解决了「如何持有 Worker 引用」的问题------一个稳定的、不自作主张的指针
两者配合,React 组件专注于 UI 渲染,Worker 专注于计算,useRef 负责在两者之间建一座桥梁。
总结:一张表理清 useState vs useRef
csharp
你有一个值,需要在组件里存着。该用什么?
值变了,界面要更新吗?
├── 要 → useState
└── 不要
├── 是 DOM 节点引用? → useRef + ref 属性
├── 是 Worker / 定时器 ID / WebSocket / AbortController? → useRef
└── 是普通变量,但不属于 UI 状态? → useRef
用一句话记住:useRef 是 React 响应式世界里的一块 "非响应式飞地"------你可以在里面存任何东西,改了 React 不知道,React 也不应该知道。
下次写代码,你可以这样做
- 创建新 state 之前,问自己一句:「这个值变了,真的需要重新渲染吗?」
- 如果答案是「不用」,别犹豫------上
useRef - 遇到
useState(null)后面用ref回调或useEffect往里塞 DOM 节点/外部对象的写法,立即意识到这是反模式
开放问题 :你在项目中遇到过因为误用 useState 存不该响应式的值而导致的 bug 吗?比如定时器 ID、WebSocket 连接、或者其他场景?评论区聊聊。