你一定写过这种代码:一个 setInterval 跑起来之后,想在另一个函数里 clearInterval——然后发现,那个 interval ID 找不到了。
放到 state 里?每次更新都会触发重新渲染,但你根本不需要 UI 变化。放到普通变量里?下一次渲染它就被重置成 null 了。
这正是 ref 要解决的问题:跨 render 保持稳定、但不驱动 UI 重绘的值。
一、先搞清楚一个问题:ref 不是"绕开 React"
很多人第一次接触 useRef,会把它理解成"React 管不到的后门"。但 React 官方文档的措辞是 escape hatch(逃脱舱口) ——这个词不是贬义,而是精确的工程术语。
宇宙飞船需要逃脱舱口,不是因为飞船设计有缺陷,而是因为有些场景天然不在主系统管辖范围内。
ref 也一样。它存在的意义不是"绕开 React 的数据流",而是为那些不属于 UI 描述的值提供一个安全的存放位置。
React 官方 ref 插图:ref 就像组件的一个"秘密口袋"
这张 React 官方插图特别形象——ref 就是组件身上的一个"秘密口袋",React 不会去翻它,但你需要的时候随时能掏出来用。
二、ref vs state:一张表说清楚
| 维度 | useRef | useState |
|---|---|---|
| 返回值 | { current: value } | [value, setter] |
| 修改后触发渲染 | ❌ 不触发 | ✅ 触发 |
| 可变性 | 直接赋值 ref.current = x | 必须走 setter |
| 更新时机 | 同步,立即生效 | 异步快照,下次渲染才生效 |
| 渲染期间能否读写 | ❌ 不应该 | ✅ 随时读取 |
这里有一个很实用的判断标准:
这个值变化后,UI 应不应该更新?
- 应该 → 用 state
- 不应该,但需要跨 render 保留 → 用 ref
- 不应该,也不需要跨 render → 用普通变量
state 是给用户看的,ref 是给你自己用的。
三、三个最常用的 ref 场景
| 场景 | 存什么 | 为什么不用 state |
|---|---|---|
| 定时器 ID | setTimeout / setInterval 返回值 | 只需要 clearInterval,UI 不关心这个数字 |
| DOM 引用 | <input ref={inputRef}> | 用于 focus、scroll、测量尺寸等命令式操作 |
| 第三方实例 | 图表对象、地图实例、WebSocket 连接 | 实例的内部状态由第三方库管理,React 不需要追踪 |
看一个经典示例——秒表:
import { useState, useRef } from 'react';
export default function Stopwatch() {
const [startTime, setStartTime] = useState(null); // 用于渲染 → state
const [now, setNow] = useState(null); // 用于渲染 → state
const intervalRef = useRef(null); // 不用于渲染 → ref
function handleStart() {
setStartTime(Date.now());
setNow(Date.now());
clearInterval(intervalRef.current);
intervalRef.current = setInterval(() => {
setNow(Date.now());
}, 10);
}
function handleStop() {
clearInterval(intervalRef.current);
}
let secondsPassed = 0;
if (startTime != null && now != null) {
secondsPassed = (now - startTime) / 1000;
}
return (
<>
<h1>Time passed: {secondsPassed.toFixed(3)}</h1>
<button onClick={handleStart}>Start</button>
<button onClick={handleStop}>Stop</button>
</>
);
}
注意 intervalRef 的用法:它在 handleStart 里被赋值,在 handleStop 里被读取——全程不参与渲染逻辑。如果你把 interval ID 放进 state,每次赋值都会触发一次无意义的重新渲染。
不需要被"看见"的值,就别让 React 去"追踪"它。
四、一个血泪教训:为什么普通变量不行?
很多人会想:既然 ref 就是存一个不触发渲染的值,我用普通 let 变量不行吗?
// ❌ 这样写,Undo 永远无法取消发送
function Chat() {
let timeoutID = null; // 每次渲染都会被重置为 null
function handleSend() {
timeoutID = setTimeout(() => alert('Sent!'), 3000);
}
function handleUndo() {
clearTimeout(timeoutID); // timeoutID 已经是 null 了!
}
// ...
}
问题出在哪?函数组件每次渲染都是一次新的函数调用。 let timeoutID = null 在每次调用时都会执行一遍,上一次保存的值直接丢了。
这就像认知科学里的工作记忆 vs 长期记忆——普通变量是草稿纸,写完这一轮就扔了;state 是你大声说出来的话,所有人都能听到(触发渲染);ref 则是你默默记在心里的事,不说出口,但下次需要时还能想起来。
ref 的"记住"不是为了展示,而是为了随时可用。
正确的写法:
// ✅ 用 ref 保存 timeout ID
function Chat() {
const timeoutRef = useRef(null);
function handleSend() {
timeoutRef.current = setTimeout(() => alert('Sent!'), 3000);
}
function handleUndo() {
clearTimeout(timeoutRef.current); // 跨渲染稳定,永远拿到最新值
}
// ...
}
五、渲染期间读写 ref——React 的红线
这是 useRef 最容易犯的错,也是最难调试的 bug:
// ❌ 在渲染期间修改 ref
function Bad({ value }) {
const ref = useRef();
ref.current = expensiveDerived(value); // 破坏渲染纯度
return <div>{ref.current}</div>;
}
React 的渲染模型建立在一个核心假设上:render 是纯函数。 给定相同的 props 和 state,应该返回相同的 JSX。
而 ref.current 的修改是 React 完全感知不到的——你在渲染期间偷偷改了一个 React 不追踪的值,就像在数学考试里偷偷改了公式里的常数,答案就全乱了。
尤其在 React 18 引入并发渲染后,一次渲染可能被中断、重启、甚至执行多次。如果你在渲染中写 ref,这些中间值可能互相覆盖。
唯一的例外是惰性初始化:
// ✅ 这种写法是安全的——只执行一次
if (!ref.current) {
ref.current = new ExpensiveThing();
}
因为它本质上是一次性赋值,不会在后续渲染中改变行为。
六、ref vs 单例——共享边界想清楚了吗?
用户提供了一个很有意思的视角:ref 和单例模式很像但又不一样。
| 维度 | 单例模式 | useRef |
|---|---|---|
| 生命周期 | 应用级,整个 app 共享一份 | 组件实例级,每个组件实例独立 |
| 共享范围 | 所有调用者看到同一个实例 | 仅当前组件实例可见 |
| 本质 | 全局共享资源 | 实例级稳定引用 |
| 类比 | 公司唯一的打印机 | 你工位抽屉里的笔 |
这个对比的价值在于:它帮你想清楚 "共享边界" 。
如果一个值需要全局共享(比如 WebSocket 连接、全局配置),单例更合适。如果一个值只属于当前组件实例(比如这个输入框的 DOM 引用、这个秒表的 interval ID),ref 才是正确选择。
如果你发现自己在用 Context 传递一个 ref 给多个子组件去修改——停一下,你可能在用 ref 冒充全局状态管理器了。
ref 的"稳定"不是"全局共享",而是"个体持久"。
七、useRef 的底层真相
React 官方文档透露了一个有趣的实现细节——useRef 在概念上可以用 useState 实现:
// useRef 的概念性实现
function useRef(initialValue) {
const [ref, unused] = useState({ current: initialValue });
return ref;
}
关键在于:返回的始终是同一个对象引用。useState 会在每次渲染返回相同的状态值,而 useRef 利用了这一点——它返回一个 { current } 对象,这个对象在组件的整个生命周期内引用不变。
所以 ref 不是什么魔法,它就是一个被 React 帮你保管的、引用稳定的普通 JavaScript 对象。你改的是对象的属性(.current),不是对象本身,所以 React 无感,也就不会重新渲染。
如果你写过类组件,这和 this.xxx 是一模一样的概念——只是换了个皮。
八、实战判断清单
最后给一份速查表,下次纠结"用 state 还是 ref"的时候,过一遍这个流程:
| 问题 | 答案 | 选择 |
|---|---|---|
| 这个值变化后,UI 需要更新吗? | 是 | useState |
| 这个值需要跨渲染保留吗? | 否 | 普通变量 |
| 这个值只在事件处理器里用? | 是 | useRef |
| 这个值是 DOM 节点? | 是 | useRef + JSX ref 属性 |
| 这个值是第三方库实例? | 是 | useRef |
| 这个值需要全局共享? | 是 | Context / 状态管理库,不是 ref |
如果你只想带走一句话,我建议记这个:
state 是 React 的台账,ref 是你自己的口袋——需要 React 帮你"广播"给 UI 的值放台账,只需要自己悄悄留着用的值放口袋。
参考原文:
• React 官方文档 — Referencing Values with Refs