useRef
访问 DOM 和保持可变值的利器 - 跨越渲染周期的"记忆口袋"
核心概述
在 React 函数组件中,状态(props 和 state)是渲染的"快照"——每次渲染都有独立的值, 更新状态会触发重新渲染。但在实际开发中,你需要一种方式来存储不会触发重新渲染的可变值, 或者直接访问 DOM 元素。这就是 useRef 的用武之地。
useRef 返回一个可变的 ref 对象,其 .current 属性可以被赋值和读取。 与 state 不同,修改 ref.current 不会触发重新渲染,这使得 ref 非常适合存储: 定时器 ID、DOM 元素引用、之前的 props/state 值、第三方库实例等需要在渲染之间持久化的数据。
核心特性:ref 对象在组件的整个生命周期内保持稳定—— React 会在每次渲染时返回同一个 ref 对象,这意味着你可以安全地在闭包中存储和读取值, 而不用担心闭包陷阱问题。
适用场景:当需要存储不触发渲染的值、访问 DOM 元素、与第三方库集成时使用 useRef。 但如果你需要值的变化触发 UI 更新,应该使用 useState 而非 ref。
💡 心智模型
将 useRef 想象成一个组件的"记忆盒子":
- • 盒子本身(ref 对象): 在组件生命周期内永远不变,始终是同一个盒子
- • 盒子里的内容(current): 可以随时替换,但替换不会触发任何通知
- • 与 state 的区别: state 是"保险箱"(换内容会报警触发渲染),ref 是"普通盒子"(换内容静默无息)
关键:ref 适用于"存储值但不通知变化"的场景,如计时器 ID、DOM 引用、上次的值等。
技术规格
类型签名
function useRef<T>(initialValue: T): MutableRefObject<T> // 泛型 T 是 ref.current 的类型 // 返回值类型是 MutableRefObject<T>,包含可变的 current 属性
参数说明
| 参数 | 类型 | 说明 |
|---|---|---|
initialValue | T | ref.current 的初始值。可以是任何类型的值,包括 null、undefined、对象、函数等。 |
返回值
| 返回值 | 类型 | 说明 |
|---|---|---|
| ref 对象 | MutableRefObject<T> | 包含 current: T 属性的对象。在组件整个生命周期内保持稳定引用。 |
运行机制
初始化:React 在组件首次渲染时创建 ref 对象,并将 initialValue 赋值给 ref.current。 这个 ref 对象会被存储在 Fiber 节点的 ref 链表中。
稳定性:与普通变量不同,ref 对象在组件的多次渲染之间保持稳定。 React 会在每次渲染时返回同一个 ref 对象引用,这使得 ref 非常适合存储需要在闭包中访问的值。
非响应式:修改 ref.current 不会触发组件重新渲染, React 也不会在渲染过程中读取或使用 ref.current 的值。 这使得 ref 成为存储不需要触发渲染的数据的理想选择。
实战演练
1. 基础用法:DOM 访问
ref 最常见的用途是直接访问 DOM 元素,实现焦点管理、文本选择、动画触发等:
import { useRef, useEffect } from 'react';
export function LoginForm() {
// 1. 创建 ref,初始值为 null
const inputRef = useRef<HTMLInputElement>(null);
// 2. 组件挂载后自动聚焦到输入框
useEffect(() => {
// ✅ 在 effect 中访问 DOM 是安全的(DOM 已渲染)
inputRef.current?.focus();
}, []);
const handleSubmit = () => {
// 3. 在事件处理函数中读取 DOM 属性
const value = inputRef.current?.value;
console.log('提交的值:', value);
};
return (
<form>
{/* 4. 将 ref 附加到 JSX 元素 */}
<input ref={inputRef} type="text" placeholder="输入用户名" />
<button type="button" onClick={handleSubmit}>
提交
</button>
</form>
);
}2. 生产级案例:自定义 Hook - usePrevious
封装一个可复用的 Hook 来存储之前的值,这在对比数据变化时非常有用:
import { useRef, useEffect } from 'react';
/**
* 存储上一次渲染的值
* @param value 当前值
* @returns 上一次的值(首次渲染返回 undefined)
*/
function usePrevious<T>(value: T): T | undefined {
// 1. 使用 ref 存储上次的值
const ref = useRef<T>();
// 2. 在每次渲染后更新 ref.current
useEffect(() => {
ref.current = value;
}, [value]);
// 3. 返回上一次的值(在更新 effect 之前执行)
return ref.current;
}
// ============= 使用示例 =============
export function Counter() {
const [count, setCount] = useState(0);
const prevCount = usePrevious(count);
return (
<div>
<p>当前: {count}</p>
<p>上次: {prevCount ?? '无'}</p>
<p>变化: {prevCount !== undefined ? count - prevCount : '首次渲染'}</p>
<button onClick={() => setCount(c => c + 1)}>增加</button>
</div>
);
}3. 生产级案例:定时器管理
使用 ref 存储定时器 ID,确保在组件卸载时正确清理,避免内存泄漏:
import { useState, useRef, useEffect } from 'react';
export function Stopwatch() {
const [seconds, setSeconds] = useState(0);
const [isRunning, setIsRunning] = useState(false);
// ✅ 使用 ref 存储定时器 ID
const intervalRef = useRef<number | null>(null);
// 启动定时器
const start = () => {
// ✅ 防止重复启动
if (intervalRef.current !== null) return;
intervalRef.current = window.setInterval(() => {
setSeconds(s => s + 1);
}, 1000);
setIsRunning(true);
};
// 停止定时器
const stop = () => {
// ✅ 清理定时器
if (intervalRef.current !== null) {
clearInterval(intervalRef.current);
intervalRef.current = null;
}
setIsRunning(false);
};
// 重置
const reset = () => {
stop();
setSeconds(0);
};
// ✅ 组件卸载时清理定时器
useEffect(() => {
// 清理函数:组件卸载时执行
return () => {
if (intervalRef.current !== null) {
clearInterval(intervalRef.current);
}
};
}, []);
return (
<div className="p-4">
<div className="text-3xl font-mono mb-4">{seconds}s</div>
<div className="space-x-2">
<button
onClick={isRunning ? stop : start}
className="px-4 py-2 bg-blue-500 text-white rounded"
>
{isRunning ? '暂停' : '开始'}
</button>
<button
onClick={reset}
className="px-4 py-2 bg-gray-500 text-white rounded"
>
重置
</button>
</div>
</div>
);
}4. 生产级案例:DOM 测量
使用 ref 结合 useLayoutEffect 实现 DOM 元素的尺寸测量,避免布局闪烁:
import { useState, useRef, useLayoutEffect } from 'react';
interface Dimension {
width: number;
height: number;
}
export function MeasureBox() {
const [dimensions, setDimensions] = useState<Dimension>({ width: 0, height: 0 });
const boxRef = useRef<HTMLDivElement>(null);
// ✅ 使用 useLayoutEffect 在浏览器绘制前同步读取布局
useLayoutEffect(() => {
if (boxRef.current) {
// 读取元素尺寸
const { width, height } = boxRef.current.getBoundingClientRect();
setDimensions({ width, height });
}
}, []);
return (
<div className="p-4">
{/* 渲染元素内容 */}
<div
ref={boxRef}
className="p-8 bg-blue-100 dark:bg-blue-900 rounded"
>
我是可测量盒子
</div>
{/* 显示尺寸 */}
<div className="mt-4 text-sm">
<p>宽度: {Math.round(dimensions.width)}px</p>
<p>高度: {Math.round(dimensions.height)}px</p>
</div>
</div>
);
}避坑指南
陷阱 1: 在渲染过程中读取 ref
问题:在组件渲染函数中直接读取 ref.current 可能导致逻辑错误, 因为 ref 的值可能在 useEffect 执行前还是旧值。
// ❌ 错误:在渲染中读取 ref
function Component({ items }) {
const countRef = useRef(0);
// ❌ 在渲染中读取 ref,可能得到过期值
if (countRef.current > 0) {
return <div>有 {countRef.current} 个项目</div>;
}
return <div>无项目</div>;
}// ✅ 正确:使用 state 存储需要渲染的值
function Component({ items }) {
const [count, setCount] = useState(0);
// ✅ 使用 state 值进行渲染判断
if (count > 0) {
return <div>有 {count} 个项目</div>;
}
return <div>无项目</div>;
}核心原则:ref 用于"存储但不渲染"的值,state 用于"需要渲染"的值。 不要在渲染逻辑中读取 ref.current。
陷阱 2: 期望 ref 更新触发渲染
问题:修改 ref.current 不会触发组件重新渲染, 这是 ref 的特性而非 bug,但初学者常误以为它会像 state 一样触发更新。
// ❌ 错误期望
function Counter() {
const countRef = useRef(0);
const handleClick = () => {
countRef.current++; // ❌ 不会触发重新渲染!
};
return (
<div>
<p>计数: {countRef.current}</p>
{/* 点击后 UI 不会更新 */}
<button onClick={handleClick}>增加</button>
</div>
);
}// ✅ 正确:使用 state
function Counter() {
const [count, setCount] = useState(0);
const handleClick = () => {
setCount(c => c + 1); // ✅ 会触发重新渲染
};
return (
<div>
<p>计数: {count}</p>
<button onClick={handleClick}>增加</button>
</div>
);
}陷阱 3: 在渲染时访问 DOM ref
问题:在首次渲染时,DOM ref.current 还是 null, 因为 DOM 元素尚未创建。必须在 useEffect 或事件处理函数中访问。
// ❌ 错误:首次渲染时访问 DOM ref
function Form() {
const inputRef = useRef<HTMLInputElement>(null);
// ❌ 首次渲染时 inputRef.current 是 null!
const inputValue = inputRef.current?.value ?? '';
return <input ref={inputRef} defaultValue={inputValue} />;
}// ✅ 正确:在 effect 中访问 DOM
import { useEffect } from 'react';
function Form() {
const inputRef = useRef<HTMLInputElement>(null);
// ✅ DOM 已经渲染完成
useEffect(() => {
if (inputRef.current) {
console.log('输入框值:', inputRef.current.value);
}
}, []);
return <input ref={inputRef} />;
}陷阱 4: 忘记清理 ref 中的副作用
问题:ref 中存储的定时器、事件监听器、第三方库实例等副作用, 如果在组件卸载时不清理,会导致内存泄漏。
// ❌ 错误:组件卸载后定时器仍在运行
function Timer() {
const intervalRef = useRef<number | null>(null);
const start = () => {
intervalRef.current = setInterval(() => {
console.log('定时中...');
}, 1000);
};
return <button onClick={start}>开始</button>;
// ❌ 组件卸载后定时器没有被清理!
}// ✅ 正确:在 useEffect 清理函数中释放资源
import { useEffect } from 'react';
function Timer() {
const intervalRef = useRef<number | null>(null);
const start = () => {
intervalRef.current = setInterval(() => {
console.log('定时中...');
}, 1000);
};
// ✅ 组件卸载时清理定时器
useEffect(() => {
return () => {
if (intervalRef.current !== null) {
clearInterval(intervalRef.current);
}
};
}, []);
return <button onClick={start}>开始</button>;
}最佳实践
✅ 推荐模式
- 用 ref 存储 DOM 元素引用
- 用 ref 存储定时器 ID、WebSocket 连接等
- 用 ref 存储不需要触发渲染的值
- 在 useEffect 清理函数中释放 ref 资源
- 使用 TypeScript 为 ref 添加类型注解
- 封装自定义 Hook 复用 ref 逻辑
❌ 避免模式
- 不要在渲染逻辑中读取 ref.current
- 不要期望 ref 更新触发重新渲染
- 不要在首次渲染时访问 DOM ref
- 不要忘记清理 ref 中的副作用
- 不要滥用 ref(能用 state 解决就用 state)
- 不要在渲染中直接修改 ref.current
📊 ref vs state 对比
| 特性 | ref | state |
|---|---|---|
| 更新触发渲染? | ❌ 否 | ✅ 是 |
| 在渲染中读取? | ❌ 不推荐 | ✅ 推荐且安全 |
| 在闭包中同步? | ✅ 始终最新值 | ❌ 闭包陷阱 |
| 典型用途 | DOM、定时器 ID | UI 状态、表单数据 |