useRef 有哪些用途?它和 useState 有什么区别?
useRef 返回一个在组件整个生命周期里都是同一个的 { current } 对象,改 current 不会触发重新渲染,适合保存定时器 ID、DOM 节点等不参与显示的值。useState 的值每次渲染是一份快照,改它要调用 setter 并触发渲染。渲染期间不要读写 ref.current(惰性初始化除外);React 19 中函数组件可以直接把 ref 当作 prop 接收。
简答
做一个秒表,点“开始”时
setInterval,点“停止”时要clearInterval,那个定时器 ID 存在哪里?存在普通变量里,下次渲染函数重新执行就丢了;存在 state 里,又会白白多渲染一次。这时就用useRef。
useRef(initialValue)返回一个{ current: initialValue }对象,组件存在多久,它就一直是同一个对象。你可以随时改current,但 React 不知道你改了,所以不会重新渲染。这就是它和useState最大的区别:state 一改就触发渲染,而且每次渲染拿到的是一份不变的快照;ref 是可变的,改了立刻生效,但界面不会跟着变。所以 ref 适合保存不影响显示的值,比如定时器 ID、上一次的值,还有通过
ref属性拿到的 DOM 节点,用来聚焦、滚动、测量。要显示在页面上的数据,应该用 state。要注意,渲染期间不要读写
ref.current,只有if (ref.current === null)这种只在首次执行的初始化例外,否则组件的行为就难以预测。另外在 React 19 里,函数组件可以把ref当作普通 prop 接收,不再需要forwardRef。
用 ref 保存定时器 ID
下面的秒表要显示经过的时间,所以 startTime 和 now 放在 state 里;定时器 ID 不显示,放在 ref 里。
import { useRef, useState } from 'react';
export default function Stopwatch() {
const [startTime, setStartTime] = useState(null);
const [now, setNow] = useState(null);
const intervalRef = useRef(null);
function handleStart() {
setStartTime(Date.now());
setNow(Date.now());
clearInterval(intervalRef.current);
intervalRef.current = setInterval(() => setNow(Date.now()), 10);
}
function handleStop() {
clearInterval(intervalRef.current);
}
const secondsPassed = startTime != null && now != null ? (now - startTime) / 1000 : 0;
return (
<>
<h1>已经过 {secondsPassed.toFixed(3)} 秒</h1>
<button onClick={handleStart}>开始</button>
<button onClick={handleStop}>停止</button>
</>
);
}
为什么 ID 不能放在普通变量 let intervalId 里?因为每次 setNow 都会让组件重新渲染,函数从头执行一遍,let 声明的变量又回到初始值,handleStop 拿到的就不是当初那个 ID 了。ref 对象由 React 保存,每次渲染返回的都是同一个,所以 handleStop 能读到 handleStart 写进去的值。
为什么不放进 state?可以,但没必要。ID 不显示在页面上,存进 state 每次开始都会多触发一次渲染。react.dev 的说法是:一个值需要保存,但不影响渲染逻辑,就选 ref。
ref 和 state 到底差在哪
用 state 时,setCount(count + 1) 之后紧接着读 count,读到的还是旧值,因为这次渲染的 count 是一份快照,新值要等下一次渲染才有,详见《为什么调用 setState 后,当前变量还是旧值?》。ref 不一样,在事件处理器里写 ref.current = 5,下一行读到的就是 5。
可是反过来,正因为 React 不知道 ref 什么时候变了,改 ref 不会更新界面。如果把计数器的数字存在 ref 里,点按钮时 current 确实在加,页面上的数字却一直不动。所以判断标准很简单:要显示的用 state,不显示、只在事件或 Effect 里用的用 ref。
为什么渲染期间不能读写 ref.current
React 要求组件是纯函数,同样的 props 和 state 应该渲染出同样的结果。如果在渲染时读 ref.current 并用它决定显示内容,结果就取决于一个 React 不知道何时变化的值;在渲染时写它,严格模式下组件函数会被调用两次,副作用也会发生两次。所以 react.dev 规定,渲染期间不要读也不要写 ref.current,读写应该放在事件处理器或 Effect 里。
唯一的例外是惰性初始化,比如只想创建一次的播放器对象:
function Video() {
const playerRef = useRef(null);
if (playerRef.current === null) {
playerRef.current = new VideoPlayer();
}
// ...
}
这样写是允许的,因为条件只在第一次渲染时成立,结果每次都一样。为什么不直接写 useRef(new VideoPlayer())?因为初始值虽然只在第一次被用到,可是 new VideoPlayer() 这个表达式每次渲染都会执行,白白创建对象。
用 ref 操作 DOM,以及 React 19 的 ref prop
把 ref 对象传给 JSX 元素的 ref 属性,React 会在创建 DOM 节点后把节点放进 current,之后就能在事件里调用 inputRef.current.focus()。
自己写的组件默认不会把内部 DOM 暴露出去。在 React 19 里,函数组件可以直接从 props 里取出 ref,再转交给内部的元素:
function MyInput({ label, ref, ...otherProps }) {
return (
<label>
{label}
<input {...otherProps} ref={ref} />
</label>
);
}
function Form() {
const inputRef = useRef(null);
return (
<>
<MyInput label="姓名" ref={inputRef} />
<button onClick={() => inputRef.current.focus()}>聚焦</button>
</>
);
}
以前要用 forwardRef 包一层才能接收 ref。react.dev 写明,React 19 里 forwardRef 已不再必要,以后的版本会将它废弃。至于 ref 回调、useLayoutEffect 里测量 DOM,见《useRef、useEffect 和 useLayoutEffect 有什么差别?》。
常见追问
怎样拿到上一次渲染时的值?
常见写法是在 Effect 里把当前值写进 ref,下一次渲染后就能在 Effect 或事件里读到上一次的值。但不要在渲染时读这个 ref 去显示内容;只是想根据旧值算新显示的话,react.dev 建议用一个 state 记住上一次的 prop,在渲染时比较。
改了 ref.current,为什么页面没变?
因为改 ref 不会触发重新渲染,React 也不知道它变了。要让界面更新,这个值应该放进 state。
严格模式下 ref 会被创建两次吗?
会。开发环境的严格模式会把组件函数调用两次,每个 ref 对象也会创建两次,其中一份被丢弃。组件是纯函数时这不影响结果,生产环境也不会这样。