前端知库

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 里。

JSX
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 里。

唯一的例外是惰性初始化,比如只想创建一次的播放器对象:

JSX
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,再转交给内部的元素:

JSX
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 对象也会创建两次,其中一份被丢弃。组件是纯函数时这不影响结果,生产环境也不会这样。