在 React Hooks 体系中,useState 和 useEffect 几乎是每位开发者的日常标配,但 useRef 却常常被误解或低估。很多人只知道它用来获取 DOM,却忽略了它 "持久可变对象" 的核心本质。本文将从底层原理出发,结合两个典型场景,带你彻底搞懂 useRef 的设计哲学与正确用法。
一、React 常用 Hooks 三剑客
在正式讲解 useRef 之前,我们先快速回顾另外两个核心 Hook,建立对比坐标系。
useState:响应式状态
useState 是 React 响应式编程的基石。它声明的状态与视图绑定,状态变更会自动触发组件重新渲染,页面同步展示最新值。这是 React 声明式编程的核心体现 ------ 数据驱动视图。
tsx
scss
const [count, setCount] = useState(0);
调用 setCount 后,React 内部会调度一次重渲染,组件函数重新执行,JSX 重新计算,DOM 高效更新。整个过程开发者不需要关心 DOM 操作,只需要关心数据状态。
useEffect:副作用处理
useEffect 用来处理渲染之外的 "副作用",比如数据请求、订阅、手动修改 DOM 等。它在组件渲染完成后异步执行,通过依赖数组控制执行时机,是 React 函数组件对接外部世界的主要窗口。
tsx
scss
useEffect(() => {
// 副作用逻辑
}, [deps]);
useRef:持久可变对象
useRef 返回一个带有 current 属性的普通 JavaScript 对象。它有两个最关键的特性:
- 跨渲染周期保持引用不变:组件无论重渲染多少次,返回的 ref 对象始终是同一个引用
- 修改 current 不会触发重渲染 :这是它与
useState最本质的区别
正是这两个特性,让 useRef 承担了两类核心职责:引用 DOM 节点、存储不需要驱动视图的持久化变量。
二、为什么 React 不推崇直接操作 DOM
在理解 useRef 操作 DOM 之前,我们需要先想明白一个问题:React 为什么要把 DOM 操作藏起来?
DOM 编程的性能本质
JavaScript 运行在 V8 引擎中,而 DOM 树归渲染引擎管理。两者分属不同线程,每次 JS 操作 DOM 都要跨越 "桥" 进行通信,这个桥接过程本身就有性能开销。频繁的 DOM 读写还会触发强制同步布局(Reflow),是前端性能优化的重点治理对象。
开发范式的跃迁
在 React、Vue 出现之前,前端是典型的命令式编程:找到元素 → 修改内容 → 绑定事件。开发者需要亲自管理每一步 DOM 操作,状态和视图的同步全靠人工维护,代码量一大就容易出 Bug。
React 带来了声明式编程 :你只需要描述 "数据是什么样子,视图就应该是什么样子",框架帮你完成 DOM 的 diff、更新、复用。useState + 数据绑定的模式,直接重构了前端开发的思维方式。
但这并不意味着 DOM 操作被完全禁止。总有一些场景,框架封装的能力不够用 ------ 比如输入框自动聚焦、获取元素宽高、操作 canvas 等。这时,useRef 就成了 React 留给开发者的 "安全出口"。
三、场景一:useRef 引用 DOM 节点
这是 useRef 最经典的使用场景。React 不推荐你直接操作 DOM,但如果你确实需要,它提供了标准的、可预测的方式。
基础用法示例
tsx
ini
import { useRef, useEffect, useState } from 'react';
const App = () => {
const [count, setCount] = useState(0);
const inputRef = useRef(null);
useEffect(() => {
inputRef.current.focus();
}, []);
return (
<>
<input
type="text"
placeholder="请输入用户名"
ref={inputRef}
/>
{count}
<button onClick={() => setCount(count + 1)}>增加</button>
</>
);
};
逐行拆解执行逻辑
第一步:初始化 ref
tsx
ini
const inputRef = useRef(null);
组件首次渲染时,useRef(null) 创建并返回一个对象 { current: null }。此时 DOM 还没挂载,current 自然是 null。如果你在函数体顶部打印 inputRef.current,会看到输出 null。
第二步:JSX 中绑定 ref 属性
tsx
ini
<input ref={inputRef} />
这是 React 的特殊语法。在组件提交阶段(commit phase),React 会把真实创建好的 DOM 元素赋值给 inputRef.current。绑定完成后,current 就指向了这个真实的 input DOM 节点。
第三步:useEffect 中使用 DOM
tsx
scss
useEffect(() => {
inputRef.current.focus();
}, []);
useEffect 的空依赖数组保证了这段代码只在组件挂载完成后执行一次。此时 DOM 已经挂载完毕,inputRef.current 指向真实元素,可以安全地调用原生 DOM API ------ focus() 让输入框自动获得焦点。
这个例子的产品意义也很明确:用户打开页面直接就能输入,不用再手动点一下输入框,体验更流畅。前端开发的职责,本质上就是打造这些细腻的用户体验。
补充:自动聚焦其实也可以用原生
autoFocus属性实现,这里主要用作演示 ref 的用法。
关键注意点
- 不要在组件函数体顶部直接访问
ref.current,首次渲染时一定是null - DOM 相关操作统一放在
useEffect或事件回调中执行 - 组件卸载时,React 会自动把
ref.current重置为null
四、场景二:useRef 存储非响应式变量
很多人不知道,useRef 不仅能存 DOM,还能存任意类型的值。当你需要一个 "可变、持久、但改了不想触发渲染" 的变量时,useRef 就是最佳选择。
经典示例:ref + 强制渲染
tsx
ini
const App = () => {
const numRef = useRef(0);
const [, forceRender] = useState(0);
console.log(numRef.current);
return (
<>
<div onClick={() => {
numRef.current += 1;
forceRender();
}}>
{numRef.current}
</div>
</>
);
};
export default App;
这段代码初看很反直觉:既然要显示到页面上,为什么不直接用 useState?我们逐行拆解它的运行机制,你就能理解 useRef 的行为模式。
逐行深度解析
第一行:声明 ref 存值
tsx
ini
const numRef = useRef(0);
初始值为 0,返回 { current: 0 }。重点是:后续每次组件重渲染,返回的都是同一个对象,current 的值会被保留,不会重置回 0。
第二行:只拿更新函数
tsx
scss
const [, forceRender] = useState(0);
数组解构中用逗号跳过第一个值,只取出更新函数 forceRender。这个 state 本身的值我们完全不用,它存在的唯一意义就是提供一个能触发重渲染的函数。
这是一种 "强制刷新" 的技巧:调用 forceRender() 会让 React 调度一次重渲染,但我们并不关心 state 的具体值。
第三行:打印验证时机
tsx
arduino
console.log(numRef.current);
每次组件渲染都会执行这行打印。初始渲染输出 0,后续每次点击重渲染都会输出最新的 current 值。
第四行:点击事件逻辑
tsx
scss
onClick={() => {
numRef.current += 1; // 改值,不触发渲染
forceRender(); // 手动触发渲染
}}
这是最核心的两行,执行顺序不能颠倒:
numRef.current += 1:内存中的数字加 1。但因为是 ref,React 感知不到变化,页面纹丝不动forceRender():触发组件重渲染。App 函数重新执行,JSX 重新计算,读取到最新的numRef.current并渲染到页面上
如果去掉 forceRender 会怎样
如果只写 numRef.current += 1 不调用强制渲染:
- 内存里的
current值确实在持续增长 - 但组件永远不会重渲染
- 页面上的数字永远停留在 0
这充分说明了一个本质:ref 只负责存值,视图更新完全依赖重渲染本身。ref 是非响应式的,修改它不会驱动视图。
五、useState vs useRef 核心区别对比
表格
| 特性 | useState | useRef |
|---|---|---|
| 触发重渲染 | 修改后自动触发 | 修改后不触发 |
| 读取时机 | 每次渲染拿到当次快照 | 随时读取最新值 |
| 适用场景 | 驱动视图的业务数据 | DOM 引用、定时器 ID、不需要展示的中间变量 |
| 更新方式 | 调用 set 函数 | 直接修改 current 属性 |
| 跨渲染保留 | 是 | 是 |
怎么选?一个简单判断标准
这个值变化后,页面需要跟着变吗?
- 需要 → 用
useState - 不需要 → 考虑
useRef
绝大多数业务场景下,需要展示给用户的数据都应该用 useState。useRef 更多是底层工具属性的存在,比如存定时器 ID、存上一次的 props 值、存第三方库的实例等。
面试延伸:为什么
useRef改了不触发渲染?因为 React 的重渲染调度是由 state 更新驱动的。ref 就是一个普通 JS 对象,React 没有对它做任何代理或监听,自然也感知不到变化。
六、useRef 典型使用场景总结
- 引用 DOM 元素:调用原生 API(focus、scroll、测量尺寸等),这是最常用的场景
- 存储定时器 ID:setInterval/setTimeout 的返回值,方便在卸载时清理
- 保存上一轮的 state/props:在 useEffect 中记录旧值,用于对比变化
- 存储不需要驱动视图的大数据:避免频繁 setState 造成不必要的重渲染
- 在闭包中获取最新值:解决 Hooks 闭包陷阱的常用手段
七、完整代码附录
示例一:DOM 自动聚焦
tsx
ini
import { useRef, useEffect, useState } from 'react';
const App = () => {
const [count, setCount] = useState(0);
const inputRef = useRef(null);
console.log('---------------');
console.log(inputRef.current); // 首次渲染为 null
useEffect(() => {
console.log(inputRef.current); // 挂载后为真实 DOM 元素
inputRef.current.focus();
}, []);
return (
<>
<input
type="text"
placeholder="请输入用户名"
ref={inputRef}
/>
{count}
<button onClick={() => setCount(count + 1)}>增加</button>
</>
);
};
export default App;
示例二:ref 存值 + 强制渲染
tsx
ini
import { useRef, useState } from 'react';
const App = () => {
const numRef = useRef(0);
const [, forceRender] = useState(0);
console.log(numRef.current);
return (
<>
<div onClick={() => {
numRef.current += 1;
forceRender();
}}>
{numRef.current}
</div>
</>
);
};
export default App;
useRef 的设计非常巧妙:它用最简单的对象引用机制,解决了 "在函数组件中持久保存可变值" 这个核心问题。理解了它,你就理解了 React 响应式系统的边界 ------ 哪些数据该走响应式流,哪些数据该游离在外。恰当使用 useRef,能让你的组件性能更优、逻辑更清晰。