讲解

useRef 返回一个 { current: ... } 对象,它有两个互不相干的用途。用途一(最广为人知):引用 DOM 元素——<input ref={inputRef}>,挂载后 inputRef.current 就是真实节点,.focus()、.scrollIntoView()、接第三方 DOM 库都靠它。用途二(常被忽视):存「不需要触发渲染的可变值」——定时器 id、上一次的 props、WebSocket 实例。改 ref.current 不会触发重渲染,这正是它和 useState 的分界线:影响界面的用 state,只影响逻辑的用 ref

为什么异步回调里要读 ref 而不是 state?因为 state 是每次渲染的快照:setTimeout 三秒后执行,里面读到的 count 是创建回调那次渲染的值。而 ref 对象是同一个,ref.current 永远是最新的——把「最新值」镜像到 ref 里,异步代码就能读到当下。常见模式:useEffect(() => { ref.current = value }) 每次渲染同步一次。

操作 DOM 的时机:挂载前 ref.current 是 null,所以 .focus() 这类调用放 useEffect 里。ref 回调写法 ref={(el) => ...} 更灵活,可以在列表里逐个收集节点。纪律:渲染期间不要读写 ref.current(渲染要纯净),ref 的读写属于事件回调和 effect。

示例

对比 state 快照 vs ref 最新值,看清异步回调为什么该读 ref(在本教程构建时被真实执行):

import assert from 'node:assert/strict';

// 模拟「state 是快照,ref 是最新值」
function createComponent() {
  const state = { count: 0 };
  const ref = { current: 0 };
  const pending = [];
  return {
    click() {
      state.count++;
      ref.current = state.count; // 镜像最新值
      // 事件回调里安排一个 3 秒后的读取(闭包捕获的是这次渲染的 state 对象)
      const snapshot = state.count;
      pending.push(() => ({ fromState: snapshot, fromRef: ref.current }));
    },
    flush: () => pending.map((f) => f()),
  };
}

const c = createComponent();
c.click(); // count = 1,安排读取
c.click(); // count = 2,安排读取
c.click(); // count = 3,安排读取

const results = c.flush(); // 三个定时器同时到点
assert.deepStrictEqual(results, [
  { fromState: 1, fromRef: 3 }, // state 是各自渲染的快照
  { fromState: 2, fromRef: 3 }, // ref 永远是最新值
  { fromState: 3, fromRef: 3 },
]);

// ref 存定时器 id:改它不触发渲染
const timerRef = { current: null };
timerRef.current = setInterval(() => {}, 1000);
assert.notStrictEqual(timerRef.current, null);
clearInterval(timerRef.current); // 卸载时清理
timerRef.current = null;
assert.strictEqual(timerRef.current, null);

console.log('三个回调读到: state =', results.map((r) => r.fromState).join('/'), ', ref =', results[0].fromRef);
console.log('定时器 id 存 ref,清理后归零,全程没触发渲染');

真实组件:

import { useEffect, useRef, useState } from 'react';

function SearchBox() {
  const inputRef = useRef(null); // 用途一:DOM 引用
  const [keyword, setKeyword] = useState('');
  const latestKeyword = useRef(''); // 用途二:最新值镜像

  useEffect(() => {
    inputRef.current.focus(); // 挂载后聚焦
  }, []);

  useEffect(() => {
    latestKeyword.current = keyword; // 每次渲染同步最新值
  });

  function handleSearchLater() {
    setTimeout(() => {
      console.log('实际搜索:', latestKeyword.current); // 3 秒后读到的是当下值
    }, 3000);
  }

  return (
    <>
      <input ref={inputRef} value={keyword} onChange={(e) => setKeyword(e.target.value)} />
      <button onClick={handleSearchLater}>3 秒后搜索</button>
    </>
  );
}

常见坑

  • 用 state 存定时器 id:每次存都触发一次无意义渲染;ref 才是这类「逻辑值」的家。
  • 渲染期间读写 ref.current:StrictMode 双渲染下行为不可预测;ref 操作放 effect 和事件回调。
  • 挂载前访问 DOM:顶层代码里 ref.current 是 null,DOM 操作放 useEffect。
  • 拿 ref 当 state 用:改了 ref 界面不更新,然后困惑半天——影响显示的值必须是 state。
  • 滥用 DOM 操作:能用 state/props 声明式表达的(禁用、显示隐藏)别用 ref 命令式改 DOM。

小结

ref = 不触发渲染的可变盒子;DOM 引用和「最新值镜像」两大用途;读写放 effect/回调,不放渲染期。下一章解决 props 透传:useContext。