React useRef 详解:从获取 DOM 到保存可变值
在 React 中,我们通常通过状态驱动页面:数据变化,组件重新渲染,界面随之更新。但并不是组件里的每一个值都应该成为状态。
例如:
- 希望输入框在页面加载后自动获得焦点;
- 希望在多次渲染之间一直保存某个值;
- 希望保存一个 Worker 实例,但不希望保存动作导致页面刷新。
这些需求有一个共同点:组件需要持有一个可以改变、可以跨渲染保留,却不负责直接驱动页面的对象。这正是 useRef 适合处理的问题。
本文从 useRef 的基本概念出发,依次讲清楚它如何引用 DOM、为什么要配合 useEffect、它与 useState 有何区别,以及怎样用它保存普通值。最后再简单看一下 ref 与 Web Worker 的配合。
一、先理解 React 为什么不鼓励随意操作 DOM
在原生 JavaScript 开发中,我们可以主动查询和修改 DOM:找到一个元素,改变它的文本、样式或焦点状态。
React 则提供了另一种开发方式:开发者描述"数据处于某种状态时,界面应该是什么样子",随后由 React 完成实际的 DOM 更新。比如下面的状态:
jsx
const [count, setCount] = useState(0);
当调用 setCount 修改 count 后,React 会重新执行组件,根据最新状态得到新的界面。开发者不必亲自找到页面上的计数元素再修改它。
这就是常说的响应式思路:用数据描述界面,让框架负责 DOM 更新。
不过,"不随意手动操作 DOM"并不等于"永远不能访问 DOM"。有些能力本身就属于 DOM 节点,例如:
- 让输入框获得焦点;
- 获取某个真实节点对象;
- 调用节点自身提供的方法。
遇到这类需求时,可以把 DOM 节点交给 React 管理,再通过 useRef 获得它的引用。
二、useRef 到底是什么
useRef 是 React 提供的 Hook。调用它会得到一个带有 current 属性的对象:
jsx
const inputRef = useRef(null);
可以把返回结果理解为下面这样的对象:
js
{
current: null
}
这里传入的 null 是 current 的初始值。ref 最重要的特征有两个:
current是可变的,可以保存值或对象;- ref 对象会在组件的多次渲染之间被保留下来,修改
current不会主动触发重新渲染。
因此,可以给 useRef 下一个简洁的定义:
useRef用来为组件提供一个持久的可变对象。这个对象拥有current属性;修改它不会触发组件重新渲染。
它经常用来引用 DOM,但并不只能保存 DOM。数字、对象以及 Worker 实例同样可以放进 current。
三、使用 ref 获取输入框 DOM
先看一个让输入框在挂载后自动获得焦点的完整示例:
jsx
import { useEffect, useRef, useState } from "react";
const App = () => {
const [count, setCount] = useState(0);
const inputRef = useRef(null);
useEffect(() => {
console.log(inputRef.current);
inputRef.current.focus();
}, []);
return (
<>
<input
type="text"
ref={inputRef}
placeholder="请输入用户名"
/>
<span>{count}</span>
<button onClick={() => setCount(count + 1)}>增加</button>
</>
);
};
export default App;
这个过程可以拆成三个步骤。
1. 创建 ref 对象
jsx
const inputRef = useRef(null);
组件刚开始执行时,输入框还没有真正挂载到页面,因此先使用 null 作为初始值。此时访问 inputRef.current,得到的是 null。
2. 通过 JSX 的 ref 属性完成绑定
jsx
<input ref={inputRef} />
这里不是把 inputRef.current 传给元素,而是把整个 ref 对象交给 JSX 的 ref 属性。React 在输入框挂载完成后,会把对应的 DOM 节点写入 inputRef.current。
于是,挂载前后会出现这样的变化:
js
// 挂载前
inputRef.current === null
// 挂载后
inputRef.current === 输入框对应的 DOM 节点
3. 挂载后调用 DOM 方法
jsx
useEffect(() => {
inputRef.current.focus();
}, []);
focus() 是输入框 DOM 节点的方法。调用后,用户无需先点击输入框便可以直接输入。
这里把操作放进 useEffect 十分关键。组件函数执行、生成 JSX 时,真实 DOM 还没有挂载完成,current 最开始仍是 null;Effect 会在组件完成挂载后执行,此时才可以得到节点并调用它的方法。
空依赖数组 [] 表示该 Effect 与会变化的响应式数据无关。在这个示例中,它承担的是"组件挂载后让输入框获得焦点"这一工作。
四、为什么点击按钮重新渲染后,ref 没有丢失
继续观察上面的计数按钮:
jsx
const [count, setCount] = useState(0);
<button onClick={() => setCount(count + 1)}>增加</button>
点击按钮会更新 state,React 随后重新执行组件。虽然组件函数再次执行了,但 useRef 不会在每次渲染时都创建一个与之前毫无关系的新引用。React 会保留这个 ref 对象,所以 inputRef.current 仍然指向已经挂载的输入框。
这就是"持久"的含义:只要当前组件实例仍然存在,ref 就可以跨越多次渲染保存引用。
如果在组件函数中打印:
jsx
console.log(inputRef.current);
首次渲染阶段可能看到 null,后续由状态引起的渲染中则可以看到已经绑定的 DOM 节点。它反映的正是 DOM 挂载与 ref 赋值的先后顺序。
五、useRef 和 useState:都能保存值,但职责不同
useRef 和 useState 都可以让数据跨渲染保留下来,也都允许值发生改变,但二者最重要的区别是:值变化后,是否通知 React 重新渲染。
| 对比项 | useState |
useRef |
|---|---|---|
| 保存位置 | state 值 | ref 对象的 current 属性 |
| 能否修改 | 可以,通过 setter 更新 | 可以,直接修改 current |
| 修改后是否触发渲染 | 会 | 不会 |
| 更关注什么 | 页面需要展示的业务状态 | DOM 引用、无需驱动界面的可变对象 |
可以将选择标准概括为一句话:
页面需要根据这个值重新显示时,使用
useState;只需要把它保存下来,而不需要由它驱动页面时,使用useRef。
计数器显示在页面上,计数变化后页面也必须变化,所以正常情况下应当使用 state:
jsx
const [count, setCount] = useState(0);
return (
<button onClick={() => setCount(count + 1)}>
{count}
</button>
);
输入框 DOM 本身不是页面要渲染的业务数据,获得节点引用也不应该再次刷新页面,所以它更适合由 ref 保存。
六、useRef 也可以保存普通值
ref 的 current 可以指向 DOM,也可以直接保存一个数字:
jsx
import { useRef } from "react";
const App = () => {
const numRef = useRef(0);
return (
<div onClick={() => {
numRef.current += 1;
}}>
{numRef.current}
</div>
);
};
export default App;
每次点击都会执行:
jsx
numRef.current += 1;
因此 ref 内部保存的数字确实发生了变化。但页面上的数字不会跟着立即变化,因为修改 current 不会触发重新渲染。
这不是错误,而是 useRef 的设计特点。React 不会因为一个普通可变引用发生变化就重新执行组件。
借助 state 观察 ref 的最新值
如果只是为了演示,想在更新 ref 后强制组件再渲染一次,可以额外使用一个 state:
jsx
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(value => value + 1);
}}
>
{numRef.current}
</div>
);
};
export default App;
useState 返回数组中的第一个元素是状态值。这里并不需要读取它,所以用空位跳过:
jsx
const [, forceRender] = useState(0);
点击时发生了两件事:
jsx
numRef.current += 1;
forceRender(value => value + 1);
第一行修改 ref 保存的数字,但它本身不会刷新页面;第二行更新 state,通知 React 重新渲染。组件再次执行 JSX 时,读取到的已经是最新的 numRef.current,于是页面数字发生变化。
这里要注意,真正触发渲染的是 forceRender 对 state 的更新,不是 ref。这个写法适合用来观察二者的区别。如果页面本来就需要展示并持续更新这个数字,直接把数字放进 useState 会更符合它的职责。
同时,函数式更新确保每次都从上一次状态继续递增:
jsx
forceRender(value => value + 1);
如果只调用 forceRender(),传入的新状态会是 undefined:第一次可能因为 0 变成 undefined 而触发渲染,后续仍然更新为 undefined 时,就不能用它稳定地演示"每次点击都刷新"。
七、把两种 ref 用法放在一起理解
到这里可以看到,DOM 引用和普通数字看似是两种不同的需求,底层却使用了同一种能力:
jsx
const inputRef = useRef(null);
const numRef = useRef(0);
对于 DOM:
jsx
<input ref={inputRef} />
React 在挂载后负责把节点放入 inputRef.current。
对于普通值:
jsx
numRef.current += 1;
开发者自己读写 numRef.current。
二者共同满足下面两个条件:
- 需要在多次渲染之间保留;
- 它的改变不应该自动触发渲染。
因此,useRef 的本质不只是"获取 DOM 的 Hook",而是一个持久的可变容器。引用 DOM 是它最常见、也最直观的应用之一。
八、简单了解:用 ref 保存 Worker 实例
JavaScript 主线程还要处理页面渲染和用户交互。如果把耗时计算直接放在主线程中执行,就可能影响页面响应。浏览器提供的 Web Worker 可以在独立线程中承担这类计算,再通过消息机制与主线程联系。
Worker 实例也可以由 ref 持有:
jsx
import { useEffect, useRef } from "react";
function App() {
const workerRef = useRef(null);
useEffect(() => {
workerRef.current = new Worker(
new URL("./worker.js", import.meta.url)
);
}, []);
return <></>;
}
export default App;
Worker 文件中先用一条日志确认线程已经启动:
js
console.log("worker online");
这段代码仍然体现了 ref 的两个核心特点:Worker 实例需要在组件存在期间被持续引用,但把实例保存下来并不需要触发页面渲染,因此用 workerRef.current 承接它很合适。
创建动作放在 useEffect 中,让它发生在组件挂载之后:
jsx
workerRef.current = new Worker(
new URL("./worker.js", import.meta.url)
);
new URL("./worker.js", import.meta.url) 用当前模块地址解析 Worker 文件,再把得到的地址交给 Worker 构造函数。这里的重点仍是 ref 如何保存实例;Worker 的消息发送、任务计算等内容不再展开。
九、常见理解误区
误区 1:修改 current 后,页面应该自动更新
不会。ref.current 是可变的,但它不是响应式状态。要让某个值的变化立即反映到 JSX 中,应优先考虑 useState。
误区 2:创建 ref 后立刻就能拿到 DOM
不能。执行 useRef(null) 时,current 的初始值就是 null。只有 JSX 中的元素完成挂载,React 才会把节点写入 current。所以挂载后的 DOM 操作通常放在 useEffect 中。
误区 3:useRef 只能引用 DOM
不是。current 可以指向任何值。DOM 节点、数字和 Worker 实例都可以保存,只要这个值需要跨渲染持久存在,并且它的改变不需要主动驱动页面更新。
误区 4:ref 和 state 可以随意互换
二者虽然都能保存数据,但更新行为不同。如果用 ref 保存一个需要展示的计数,还必须另找 state 触发渲染,反而让逻辑变复杂。选择时应先判断这个数据是否参与页面显示。
十、总结
理解 useRef,关键不是死记某个 API,而是抓住"持久、可变、非响应式"三个词:
- 持久:组件重新渲染时,ref 对象不会丢失;
- 可变 :可以通过
ref.current保存和修改内容; - 非响应式 :修改
current不会主动触发重新渲染。
当需要操作输入框焦点时,可以创建 ref,通过 JSX 的 ref 属性绑定 DOM,并在 useEffect 中调用节点方法;当需要保存不驱动页面更新的普通值或实例时,也可以把它放在 current 中。
最后再用一句话区分 useState 与 useRef:
useState负责"值变了,页面也要变";useRef负责"把这个可变内容一直保存下来,但不必通知页面"。
掌握这条边界,便能判断什么时候应该交给 React 的状态系统,什么时候只需要一个稳定的引用。