第 7 章

useRef 与 DOM 操作:refs 的完整使用手册

第7章:useRef 与 DOM 操作:完整的 Ref 手册

useRef 是 React 中连接声明式世界与命令式现实的桥梁——它的不变性和持久性使它在三类场景中不可替代。

本章核心问题:useRef 的三种用途分别是什么?什么时候该用 ref 而非 state? 读完本章你将理解


Level 1 · 你需要知道的(1-3年经验)

Ref 的三种用途

useRef 返回一个可变的对象 { current: initialValue },这个对象在组件的整个生命周期中保持同一个引用。修改 .current 不会触发重渲染——这是 ref 与 state 的根本区别。

正是这两个特性(持久性 + 变更不触发渲染)让 ref 在三个场景中不可替代:

用途一:持久化跨渲染的可变值,比如计时器 ID、WebSocket 实例、上一次渲染的值。

用途二:访问 DOM 节点,对 DOM 进行命令式操作(聚焦、测量、动画)。

用途三:打破闭包陷阱,存储最新的回调函数或状态值,避免过期引用。

持久化可变值:不该放进 state 的数据

并非所有"需要在渲染间保持"的数据都适合放在 state 里。如果数据变化不需要触发 UI 更新,放进 state 就是过度设计——每次修改都会导致不必要的重渲染。

function StopWatch() {
  const [elapsedMs, setElapsedMs] = useState(0);
  const intervalRef = useRef<number | null>(null);

  function start() {
    if (intervalRef.current !== null) return; // 已经在运行
    const startTime = Date.now() - elapsedMs;
    intervalRef.current = setInterval(() => {
      setElapsedMs(Date.now() - startTime);
    }, 10);
  }

  function stop() {
    clearInterval(intervalRef.current!);
    intervalRef.current = null;
  }

  return (
    <div>
      <p>{(elapsedMs / 1000).toFixed(2)}s</p>
      <button onClick={start}>开始</button>
      <button onClick={stop}>停止</button>
    </div>
  );
}

intervalRef 存储定时器 ID,修改它不需要重渲染,用 ref 是正确的。elapsedMs 需要显示在 UI 上,用 state 是正确的。

存储上一次渲染的值

一个经典应用是比较当前值与上一次渲染的值:

function usePrevious<T>(value: T): T | undefined {
  const ref = useRef<T | undefined>(undefined);
  // 在 render 期间同步更新(不是在 effect 里)
  // 注意:这在 React 严格模式中可能有微妙行为
  useEffect(() => {
    ref.current = value;
  });
  return ref.current;
}

function PriceDisplay({ price }: { price: number }) {
  const prevPrice = usePrevious(price);
  const direction = prevPrice === undefined ? 'neutral'
    : price > prevPrice ? 'up' : price < prevPrice ? 'down' : 'neutral';

  return <span className={direction}>{price}</span>;
}

这里有一个微妙之处:useEffect 在渲染后运行,所以在 PriceDisplay 渲染期间,ref.current上一次的值,Effect 运行后更新为当前值——这正是我们想要的"前一次渲染的值"语义。

DOM 访问:命令式操作的正确方式

React 的默认工作方式是声明式的:你描述 UI 应该是什么样子,React 负责操作 DOM。但有些操作本质上是命令式的:

function AutoFocusInput() {
  const inputRef = useRef<HTMLInputElement>(null);

  useEffect(() => {
    inputRef.current?.focus();
  }, []);

  return <input ref={inputRef} />;
}

ref 对象传给 JSX 元素的 ref 属性后,React 会在组件挂载时将 DOM 节点赋给 ref.current,卸载时重置为 null

不要在渲染期间读取 ref.current

这是最常见的 ref 使用错误:

function Wrong() {
  const ref = useRef(null);

  // 错误:在渲染期间读取 DOM ref
  // 第一次渲染时 ref.current 是 null,
  // 即使后续渲染有值,也不能保证一致性
  const width = ref.current?.offsetWidth ?? 0;

  return <div ref={ref} style={{ fontSize: width > 500 ? '20px' : '16px' }}>
    内容
  </div>;
}

渲染期间 ref 不一定已经被赋值。正确的方式是在 Effect(或 Layout Effect)中读取:

function Correct() {
  const ref = useRef<HTMLDivElement>(null);
  const [fontSize, setFontSize] = useState(16);

  useLayoutEffect(() => {
    if (ref.current) {
      setFontSize(ref.current.offsetWidth > 500 ? 20 : 16);
    }
  }, []); // 必要时添加更多依赖

  return <div ref={ref} style={{ fontSize }}></div>;
}

Level 2 · 它是怎么运行的(3-5年经验)

forwardRef:跨组件传递 Ref

默认情况下,ref 不能传递给自定义组件——React 会报警告而不是把 ref 赋给 DOM 节点。如果你构建的是可复用的 UI 组件库,往往需要让父组件能够访问子组件内部的 DOM 节点(例如让父组件能调用 focus())。

React 18 及之前的方案是 forwardRef

// React 18 及之前
const CustomInput = forwardRef<HTMLInputElement, { label: string }>(
  function CustomInput({ label }, ref) {
    return (
      <label>
        {label}
        <input ref={ref} />
      </label>
    );
  }
);

// 父组件
function Form() {
  const inputRef = useRef<HTMLInputElement>(null);

  return (
    <>
      <CustomInput label="用户名" ref={inputRef} />
      <button onClick={() => inputRef.current?.focus()}>聚焦</button>
    </>
  );
}

forwardRefref 从 "不透明的特殊属性" 变成了普通的第二个参数,让组件可以决定把它转发到哪里。

useImperativeHandle:精确控制暴露的 API

有时候你不希望父组件能访问整个 DOM 节点(这会导致父组件绕过 React 直接操作 DOM),而只想暴露特定的方法:

type VideoPlayerHandle = {
  play: () => void;
  pause: () => void;
  seekTo: (time: number) => void;
};

// React 18 写法
const VideoPlayer = forwardRef<VideoPlayerHandle, { src: string }>(
  function VideoPlayer({ src }, ref) {
    const videoRef = useRef<HTMLVideoElement>(null);

    useImperativeHandle(ref, () => ({
      play() {
        videoRef.current?.play();
      },
      pause() {
        videoRef.current?.pause();
      },
      seekTo(time: number) {
        if (videoRef.current) {
          videoRef.current.currentTime = time;
        }
      },
    }), []);

    return <video ref={videoRef} src={src} />;
  }
);

// 父组件只能调用 play/pause/seekTo,无法访问 video DOM 节点
function MovieScreen() {
  const playerRef = useRef<VideoPlayerHandle>(null);

  return (
    <>
      <VideoPlayer ref={playerRef} src="/movie.mp4" />
      <button onClick={() => playerRef.current?.play()}>播放</button>
    </>
  );
}

useImperativeHandle 的第三个参数是依赖数组,遵循与 useEffect 相同的规则:当依赖变化时重新创建 handle 对象。如果 handle 方法只依赖 ref(ref 是稳定的),依赖数组可以是空数组。

什么时候应该用 useImperativeHandle?

不应该用它把组件变成"充满命令式方法的控制器"。如果你发现 useImperativeHandle 里有十个方法,通常意味着这些操作应该由状态驱动,而不是命令式触发。

ref 回调:精细控制挂载时机

除了传递 ref 对象,你还可以传递一个ref 回调函数。当 DOM 节点挂载时,React 会调用这个函数并传入节点;卸载时调用并传入 null

function MeasureDiv() {
  const [height, setHeight] = useState<number | null>(null);

  const measuredRef = useCallback((node: HTMLDivElement | null) => {
    if (node !== null) {
      setHeight(node.getBoundingClientRect().height);
    }
  }, []);

  return (
    <>
      <div ref={measuredRef}>内容</div>
      {height !== null && <p>高度:{height}px</p>}
    </>
  );
}

ref 回调的一个重要细节:如果回调函数在每次渲染时都是新的(内联函数),React 会在每次渲染时先调用 null,再调用新节点——相当于卸载再挂载。用 useCallback 稳定化回调可以避免这个问题。

ref 回调的高级用法:观察多个节点

function List({ items }: { items: string[] }) {
  const rowRefs = useRef<Map<string, HTMLLIElement>>(new Map());

  function getOrCreateRef(id: string) {
    return (node: HTMLLIElement | null) => {
      if (node) {
        rowRefs.current.set(id, node);
      } else {
        rowRefs.current.delete(id);
      }
    };
  }

  function scrollToItem(id: string) {
    rowRefs.current.get(id)?.scrollIntoView({ behavior: 'smooth' });
  }

  return (
    <ul>
      {items.map(item => (
        <li key={item} ref={getOrCreateRef(item)}>
          {item}
        </li>
      ))}
    </ul>
  );
}

这个模式比用数组索引存储 ref 更健壮:列表项可以重排、添加、删除,ref 映射始终与实际 DOM 对应。


Level 3 · 规范怎么定义的(资深)

React 19:ref 作为普通 Prop

React 19 消除了 forwardRef 的必要性。现在可以直接把 ref 作为普通 prop 传递:

// React 19:不需要 forwardRef
function CustomInput({ label, ref }: { label: string; ref?: React.Ref<HTMLInputElement> }) {
  return (
    <label>
      {label}
      <input ref={ref} />
    </label>
  );
}

// 使用方式完全相同
function Form() {
  const inputRef = useRef<HTMLInputElement>(null);
  return (
    <>
      <CustomInput label="用户名" ref={inputRef} />
      <button onClick={() => inputRef.current?.focus()}>聚焦</button>
    </>
  );
}

这是 React 19 最受欢迎的 API 简化之一。forwardRef 产生了额外的包装组件层,在 DevTools 中增加了噪音。React 19 的 ref-as-prop 让组件树更清晰。

向后兼容:React 19 仍然支持 forwardRef,不需要立即迁移现有代码,但新代码建议直接用 prop 方式。


Level 4 · 边界与陷阱(所有人)

生产环境常见问题

在实际项目中,本章涵盖的概念最常见的问题包括:

  1. 忽视边界条件:在正常路径下工作正常的代码,在异常路径(网络失败、用户快速操作、组件卸载)下可能产生 bug。始终考虑清理和取消逻辑。

  2. 过早优化:在没有测量的情况下添加优化代码,增加了复杂度但不一定提升性能。先用 Profiler 确认问题存在,再实施优化。

  3. 文档与实际行为的差异:React 的行为在不同版本间可能有微妙差异,尤其是并发模式相关的特性。始终以实际测试结果为准。

本章评分
4.6  / 5  (50 评分)

💬 留言讨论