在函数式组件的世界里,每一次状态的更新(State Update)都意味着组件函数的重新执行。这与类组件中实例属性(Instance Properties)持久存在的特性截然不同。
为了在函数组件中模拟“类实例属性”的能力,或者为了直接操作 DOM,React 引入了 useRef。它不仅仅是一个用来“获取 DOM 节点”的工具,更是一个跨渲染周期持久化存储数据的容器。
如果你把 useState 比作家里的“智能灯光系统”(状态变了,全屋的氛围灯都要跟着变),那么 useRef 就是你家里的“记事本”(你在上面写东西,不会影响家里的灯光,但你下次回来还能看到之前记的东西)。
一、 核心概念:Ref 与 State 的本质区别
在深入代码之前,我们必须厘清 useRef 和 useState 的核心差异。很多初学者的错误,都源于混淆了这两个概念。
1. 响应式 vs. 普通对象
useState(响应式) :它是 React 的“神经中枢”。一旦你调用setState,React 会立即安排一次重新渲染(Re-render)。组件内的所有逻辑都会重新跑一遍。useRef(普通对象) :它是一个普通的 JavaScript 对象。它的current属性发生变化时,完全不会触发组件的重新渲染。它就像是一个被 React “遗忘”在内存角落里的盒子,只有你主动去拿东西时,它才存在。
2. 生命周期 vs. 渲染周期
useState:它的生命与 UI 紧密绑定。UI 变,State 变;State 变,UI 变。useRef:它的生命是持久化的。从组件挂载(Mount)到卸载(Unmount),useRef里的值始终保持同一个引用地址。你可以在任何一次渲染中修改它,它都能记住。
核心差异对比表:
| 特性 | useState | useRef |
|---|---|---|
| 触发渲染 | 是 (调用 setter 时) | 否 (修改 current 不触发) |
| 数据类型 | 任何 (原始值/对象) | 对象 (包含 current 属性) |
| 主要用途 | 管理 UI 状态 | 存储可变值、DOM 引用 |
| 渲染影响 | 改变值会导致组件重新渲染 | 改变值对渲染无影响 |
| 初始化时机 | 每次渲染都会检查初始值 | 仅在首次渲染时创建 |
二、 实战演练:DOM 节点的引用与自动聚焦
useRef 最直观的用途就是获取 DOM 节点。在类组件时代,我们使用 React.createRef(),在函数组件中,我们使用 useRef。
场景:输入框自动聚焦
当页面加载完成后,输入框自动获得光标。
代码实现:
import { useState, useRef, useEffect } from 'react';
export default function App() {
const [count, setCount] = useState(0);
// 1. 创建一个 ref 对象,初始值为 null
const inputRef = useRef(null);
// 2. 利用 useEffect 在 DOM 渲染完成后操作节点
useEffect(() => {
// inputRef.current 指向真实的 DOM 节点
if (inputRef.current) {
inputRef.current.focus();
}
}, []); // 依赖为空,仅在挂载时执行
return (
<>
{/* 3. 将 ref 绑定到 input 元素上 */}
<input ref={inputRef} />
<button onClick={() => setCount(count + 1)}>
Count: {count}
</button>
</>
);
}
代码解析:
- 创建 (
useRef(null)) :我们在函数体内部创建了一个inputRef对象。此时inputRef.current的值是null。 - 绑定 (
ref={inputRef}) :React 在渲染时,会将这个inputRef对象的current属性指向真实的<input>DOM 节点。 - 使用 (
useEffect) :在useEffect中,我们通过inputRef.current获取到了 DOM 节点,并调用了原生的focus()方法。
易错点 1:在渲染阶段直接操作 Ref
千万不要试图在组件函数的主体(渲染逻辑)中直接操作 ref 的值或调用 DOM 方法。
-
错误写法:
// 错误!这会在每次渲染时尝试调用 focus,但此时 DOM 可能还没挂载 inputRef.current?.focus(); -
原因:组件函数的执行(JS 逻辑)早于 DOM 的渲染(浏览器绘制)。在函数体内部,
inputRef.current此时可能还是null。
易错点 2:在事件处理函数中忘记检查 null
虽然在 React 的严格模式下,挂载后 current 通常不为 null,但为了代码的健壮性,始终建议在调用 DOM 方法前进行空值检查(Nullish Check)。
💡 答疑解惑环节
Q: 为什么 useRef 可以获取到 DOM 节点,而普通对象不行?
A: 这是 React 的底层机制决定的。当你将一个 ref 对象(由 useRef 创建)传递给 JSX 元素的 ref 属性时,React 会识别这是一个特殊的“Ref 对象”,并在渲染阶段自动将该对象的 current 属性更新为对应的 DOM 节点引用。普通对象没有这个“特权”。
Q: useRef 里的值变了,为什么控制台打印的还是旧值?
A: 这是一个经典的闭包(Closure)陷阱。如果你在 useEffect 或事件处理函数中打印 ref.current,你看到的是那一刻的快照。如果你在异步操作(如 setTimeout)中打印,可能会看到旧值,除非你在异步回调中再次访问 ref.current。Ref 对象的 current 属性是可变的,但对象本身的引用是不变的。
三、 进阶应用:可变对象的存储(替代全局变量)
这是 useRef 最容易被忽视,但在复杂业务中最有用的功能。当需要在函数组件中存储一些数据,但又不希望这些数据的变化触发页面重新渲染时,useRef 是唯一的选择。
场景:计数器与定时器
我们需要一个按钮来启动和停止一个每隔 1 秒打印 "tick" 的定时器。同时,页面上还有一个独立的计数器按钮。
错误的实现(使用普通变量):
function BadExample() {
let intervalId = null; // 普通变量
const [count, setCount] = useState(0);
function start() {
// 每次 start 被调用,intervalId 都是 null
intervalId = setInterval(() => console.log('tick'), 1000);
}
function stop() {
// 这里的 intervalId 永远是 null,无法清除定时器
clearInterval(intervalId);
}
// ... JSX
}
- 问题:每次点击计数器按钮,组件重新渲染,
intervalId变量被重新声明为null。stop函数永远拿不到start函数中设置的 ID。
正确的实现(使用 useRef):
import { useState, useRef } from 'react';
export default function App() {
// 1. 使用 useRef 存储定时器 ID
const intervalId = useRef(null);
const [count, setCount] = useState(0);
function start() {
// 2. 修改 current 属性,不会触发渲染
intervalId.current = setInterval(() => {
console.log('tick~~~~');
}, 1000);
}
function stop() {
// 3. 在 stop 函数中,依然能访问到 start 存储的 ID
if (intervalId.current) {
clearInterval(intervalId.current);
intervalId.current = null; // 清理
}
}
return (
<>
<button onClick={start}>开始</button>
<button onClick={stop}>停止</button>
<button onClick={() => setCount(count + 1)}>
Count: {count}
</button>
</>
);
}
代码解析:
- 持久化存储:
intervalId是一个 Ref 对象。无论组件因为count变化重新渲染多少次,intervalId对象本身始终是同一个,它的current属性保留了上一次设置的定时器 ID。 - 解耦逻辑:
start和stop函数可以独立于 UI 状态运行。点击“Count”按钮只会增加数字,完全不会影响定时器的逻辑,因为useRef的变化不触发渲染。
易错点 3:误以为 useRef 是“响应式”的
很多开发者会尝试这样做:
// 错误!
const dataRef = useRef(0);
dataRef.current = 1; // 修改了值
// 期望页面自动更新?不会!
如果你希望值变了页面也变,请用 useState。useRef 仅用于存储那些“变了也不需要 UI 刷新”的数据(如定时器 ID、上一次的 props、DOM 节点、外部库的实例等)。
💡 答疑解惑环节
Q: 既然 useRef 不触发渲染,那它和写在组件外面的全局变量有什么区别?
A: 区别巨大:
- 作用域隔离:全局变量是整个应用共享的。如果你有多个该组件的实例,它们会共享同一个全局变量,造成数据污染。
useRef是每个组件实例独有的,每个实例都有自己独立的“百宝箱”。 - 生命周期管理:全局变量除非手动清理,否则一直存在。
useRef随着组件的卸载,其引用的对象在没有其他引用时会被垃圾回收(GC)。
Q: useRef 的初始值是每次渲染都执行吗?
A: 不是。useRef 的初始化逻辑(即传入的参数)仅在组件的首次渲染(Mount 阶段)执行一次。在后续的更新渲染中,React 会直接返回之前创建的那个 Ref 对象,忽略你传入的新值。这与 useState 的初始值不同(虽然 useState 的初始值函数也只执行一次,但这是 Hooks 的通用规则)。
四、 牛刀小试一下
1. useRef 和 useState 有什么区别?
-
思考方向:
- 触发渲染:
useState的更新会触发 Re-render,useRef的更新不会。 - 数据获取:
useState读取的是当前渲染的快照,useRef读取的是实时的最新值。 - 引用地址:
useState的对象如果替换,引用地址会变;useRef对象本身的引用地址永远不变(变的是current里面的值)。 - 使用场景:UI 状态用 State,非 UI 数据(计时器、DOM、外部实例)用 Ref。
- 触发渲染:
2. 如何在 useEffect 外部访问最新的 State 值?
-
思考方向:这是一个进阶问题。通常
useEffect内部闭包捕获的是旧的 State。解决方案是使用useRef来同步状态。const latestCount = useRef(count); useEffect(() => { latestCount.current = count; // 每次 count 变,更新 ref }, [count]); // 现在在任何地方(包括 setTimeout 中),latestCount.current 都是最新值注:现代 React 更推荐使用
useEffectEvent(React 18+) 来解决此类问题,但在 18 以下版本,Ref 是标准解法。
3. 为什么有时候 useRef 打印出来的值是旧的?
-
思考方向:这通常涉及闭包陷阱。
useEffect(() => { const id = setInterval(() => { console.log(myRef.current); // 这里是正确的,实时值 console.log(someState); // 这里是闭包捕获的旧值 }, 1000); return () => clearInterval(id); }, []); // 注意依赖项为空如果你在
setInterval回调中直接使用someState,它会永远是组件挂载时的值。解决办法是:要么把someState放进依赖项(导致定时器频繁重置),要么使用useRef来存储someState并在useEffect中同步它,或者使用函数式更新。
4. useRef 可以用来做性能优化吗?
-
思考方向:可以。
- 避免对象/函数重建:在
useMemo或useCallback无法满足需求时,可以用useRef存储复杂的计算结果或对象实例,避免在每次渲染时都重新创建,从而减少子组件的不必要渲染。 - 避免 Effect 重复执行:如果
useEffect的依赖项是一个对象,对象属性的改变会导致 Effect 重新执行。如果用useRef存储这个对象并手动管理变化,可以控制 Effect 的执行时机。
- 避免对象/函数重建:在
通过这篇博客,希望你不再把 useRef 仅仅看作是操作 DOM 的工具,而是将其视为 React 函数组件中维持状态与逻辑分离的瑞士军刀。