一、先理解一个根本问题:组件里"普通变量"为什么存不住东西?
React 的函数组件,本质是一个普通函数 。每次渲染,React 都会重新调用这个函数一次。这意味着:
function Timer() {
let intervalId = null; // ← 每次渲染都被重新创建
// ...
}
函数体里声明的普通局部变量,每次渲染都从零开始------上一轮赋值进去的东西,下一轮就丢了。
那用 state(useState) 存行不行?可以存住,但有个代价:state 一变化就会触发整个组件重新渲染 。而我们想存的定时器 ID、前一次的值,往往根本不需要重新渲染 UI------存它是为了"记住",不是为了"显示"。
于是就有了第三种选择:useRef。
二、useRef 的本质:一个"不会丢、也不会触发渲染"的盒子
先看它的形态:
const myRef = useRef(初始值);
useRef 返回一个对象 ,这个对象里只有一个属性 current,初始值就是传给它的参数:
myRef = { current: 初始值 }
它的两个关键特性,正好同时解决上面两个问题:
| 特性 | 说明 |
|---|---|
| 跨渲染存活 | React 在第一次渲染 创建这个对象,之后每次渲染返回给你的都是同一个对象 ,里面的 current 值不会被重置 |
| 改值不触发渲染 | 你直接改 myRef.current = xxx,这只是普通的属性赋值,React 不会因此重新渲染组件 |
一句话:useRef 是"能跨渲染存活、但又不会引发重渲染"的可变数据容器。 这正是"保存可变数据"这个用途的全部基础。
补充:
useRef参数里的初始值只在第一次渲染时用一次,之后 React 每次都给你同一个对象。
三、场景一:保存定时器 ID(setInterval 的句柄)
这个场景的痛点
setInterval 会返回一个 ID 号 ,你想停掉定时器,必须用这个 ID 调用 clearInterval(id)。所以这个 ID 必须在多次渲染之间活下来。
试想三种存法:
- 普通局部变量 → 每次渲染被重置,停止时 ID 已经丢了,不行;
- useState → 能存住,但每赋一次值就多触发一次渲染,浪费;
- useRef → 存得住、又不触发渲染,正合适。
完整代码 + 逐步讲解
TypeScript
import { useRef } from 'react';
function Timer() {
// ① 创建一个 ref 盒子,初始值为 null,专门存定时器 ID
const timerRef = useRef(null);
const start = () => {
// ② 启动定时器,把返回的 ID 放进盒子里
timerRef.current = setInterval(() => {
console.log('Tick');
}, 1000);
};
const stop = () => {
// ③ 从盒子里取出 ID,用它停止定时器
clearInterval(timerRef.current);
timerRef.current = null; // 清空,表示已停止
};
return (
<>
<button onClick={start}>开始</button>
<button onClick={stop}>停止</button>
</>
);
}
逐步拆解:
- 第 ② 步:
timerRef.current = setInterval(...)------ 把定时器 ID 放进盒子 。之后无论组件渲染多少次,这个 ID 都稳稳地待在timerRef.current里; - 第 ③ 步:
clearInterval(timerRef.current)------ 从盒子里取出 ID 来停止定时器; - 因为改
timerRef.current不会触发重渲染,所以这个组件里点"开始/停止"不会白渲染。
进阶提醒(必看) :真实的定时器组件,一定要在组件卸载时清理定时器 ,否则会内存泄漏。通常配合 useEffect 的清理函数做:
useEffect(() => {
return () => clearInterval(timerRef.current); // 组件卸载时停掉
}, []);
四、场景二:保存"前一次的值"(usePrevious)
这个场景的痛点
useState 只给你当前 的值。一旦触发重渲染,旧值就被 React 丢掉了------你拿不到"上一次是多少"。
但很多需求需要做前后对比,比如:
- 检测某个值"从旧值变成了新值",从而触发动画;
- 判断数据是否真的变化了,再决定要不要发请求。
这时就需要用 ref 把"上一次的值"额外记一份。
完整代码 + 逐步讲解(封装成自定义 Hook)
import { useRef, useEffect } from 'react';
// 自定义 Hook:记住"上一次的值"
function usePrevious(value) {
const ref = useRef(); // ① 盒子,用来存旧值
useEffect(() => {
ref.current = value; // ② 每次 value 变化后,把新值存进盒子
}, [value]);
return ref.current; // ③ 返回的,是"上一次"的值
}
使用:
function Counter() {
const [count, setCount] = useState(0);
const prevCount = usePrevious(count); // 上一次的 count
return (
<div>
当前:{count}
上一次:{prevCount ?? '无'}
<button onClick={() => setCount(c => c + 1)}>+1</button>
</div>
);
}
这里最关键的是时序,请务必理解这个"先读后写"的顺序:
- 假设当前
count = 1,组件开始渲染; - 渲染时执行
usePrevious(1),返回ref.current------ 但此刻ref.current里存的还是上一次的值 0(因为 useEffect 还没跑); - 渲染完成之后 ,
useEffect才执行,把ref.current = 1(这次的新值)存进去; - 于是下一次渲染 时,
usePrevious返回的ref.current就正好是上一次的 0。
所以效果是:界面永远能同时看到"当前值"和"上一次的值" 。这就是标准的 usePrevious 模式,很多教程都这么写。
注意:
useEffect是渲染之后才跑的(React 提交 DOM 后执行副作用),这正是这个模式能"延迟一步记住旧值"的原因。
五、总结:什么时候用 ref 存可变数据?
用一个判断标准就能记牢:
这个数据需不需要"跨渲染存活",但又不需要"引起重渲染"?
- 需要 → 用 useRef
- 不需要(改它就该刷界面)→ 用 useState
两个场景对号入座:
| 场景 | 存什么 | 为什么要 ref(而不是变量/state) |
|---|---|---|
| 定时器 | setInterval 返回的 ID |
变量存不住;state 会白触发渲染 |
| 前一次值 | 上一次的 state/props | 借 useEffect 延迟一步,把旧值"留在盒子里" |
共同本质 :useRef 就是给 React 函数组件用的"实例变量 "------相当于类组件里的 this.xxx 字段,存那些"要记住、但不上屏"的可变数据。
以上就是 8.2 这一节的完整文字讲解。如果你愿意,我还可以进一步:
- 把两个场景合并到同一个真实小组件里演示;
- 或者专门讲一下"为什么 usePrevious 一定要用 useEffect 而不是直接赋值"这个更深的点。
你想继续深入哪一块?