React 学习站
React›进阶›进阶

useRef 与命令式逃生舱

进阶进阶

一句话定义

const ref = useRef(initial) 返回一个跨渲染稳定、修改不触发重渲染的可变盒子:挂到 DOM 元素上就是句柄(ref.current 指向节点),用作普通容器则是「渲染之外的备忘录」。

为什么重要

React 是声明式的,但现实世界有命令式需求:聚焦、滚动、测量、播放器、第三方插件、requestAnimationFrame 循环。ref 是官方承认的逃生舱;同时它也是理解「快照模型 vs 可变现实」的对照物——用错 ref 会写出绕过渲染系统的幽灵状态。

前置知识

  • kp-014 快照与闭包(ref 正是「反快照」)。
  • kp-010 effect 时序(DOM ref 在提交后才可读)。

核心概念

  • 三种用途:① DOM 句柄;② 跨渲染存可变值(定时器 id、上一次值);③ 自定义 Hook 内部状态(不暴露给渲染)。
  • 写 ref 不触发渲染:ref.current = x 只是改盒子;界面不会因此更新。
  • 读 ref 在渲染期间不可靠:渲染应只读 props/state;渲染中读写 ref 违反纯函数纪律。
  • forwardRef / ref 作为 prop:React 19 起 ref 可以直接当 prop 传(无需 forwardRef);19 之前子组件必须用 forwardRef 转发。
  • useImperativeHandle:父组件只暴露受控的命令式 API 子集,而非整个 DOM 节点。

原理 / 机制

DOM 句柄的经典用法:

jsxfunction SearchBox() {
  const inputRef = useRef(null);

  function open() {
    inputRef.current.focus();        // 提交后 ref 已指向真实节点
    inputRef.current.select();
  }

  return (
    <>
      <input ref={inputRef} defaultValue="" />
      <button onClick={open}>搜索</button>
    </>
  );
}

时序要点:渲染期间 ref.current 是 null(DOM 还没提交);首次读到值最早在 useLayoutEffect/事件回调里。

作为「最新值盒子」解决 kp-014 的 stale closure(与 useCallback 的取舍):

jsxfunction useInterval(callback, delay) {
  const saved = useRef(callback);
  useEffect(() => { saved.current = callback; });  // 每次渲染刷新引用

  useEffect(() => {
    if (delay === null) return;
    const id = setInterval(() => saved.current(), delay);
    return () => clearInterval(id);
  }, [delay]); // delay 变才重建 interval;callback 永远最新
}

ref vs state 的判断题:

问题答案指向
这个值变化后需要重渲染吗?需要 → state;不需要 → ref
这个值要在渲染输出里出现吗?出现 → state;只在事件/异步里用 → ref
是 DOM 命令式操作吗?是 → ref

「上一次值」模式(比较渲染间差异):

jsxfunction usePrevious(value) {
  const ref = useRef();
  useEffect(() => {
    ref.current = value;   // 在提交后更新 → 渲染时读到的是"上一次"的值
  }, [value]);
  return ref.current;
}

直观类比

state 是演出的剧本:改一页就要重新走台(重渲染),观众看到的是新戏。ref 是后台工作群的便签:导演随手记「灯 3 号再调亮些」,不触发重新走台,但执行时(effect/事件)人人能看最新便签。把便签内容写到剧本上(用 ref 值渲染界面)= 演出和后台对不上号——这就是 ref 幽灵 bug。

实例 / 案例

useImperativeHandle:封装视频播放器,只暴露安全 API:

jsxfunction Player({ ref }) {
  const videoRef = useRef(null);

  useImperativeHandle(ref, () => ({
    play: () => videoRef.current.play(),
    pause: () => videoRef.current.pause(),
    seek: s => { videoRef.current.currentTime = s; },
  }), []); // 暴露的函数集固定

  return <video ref={videoRef} src="/demo.mp4" />;
}

// 父组件:拿到的是 {play, pause, seek},碰不到原始 <video> 节点

(React 19 直接用 function Player({ ref });19 前写法为 forwardRef((props, ref) => ...)。)

滚动联动列表:

jsxfunction Chat({ messages }) {
  const endRef = useRef(null);
  useEffect(() => {
    endRef.current?.scrollIntoView({ behavior: 'smooth' });
  }, [messages.length]); // 新消息到达 → 滚到底部

  return (
    <ul>
      {messages.map(m => <li key={m.id}>{m.text}</li>)}
      <li ref={endRef} aria-hidden="true" />
    </ul>
  );
}

常见误区

  • 用 ref 存「界面需要显示的值」:改了不更新、更新了不同步,各种诡异时序 bug;界面值一律 state。
  • 渲染期间写 ref:if (!ref.current) ref.current = compute()(缓存计算结果)——并发渲染下同一渲染可能执行多次/被丢弃,缓存会被污染;缓存计算用 useMemo(kp-018)。
  • 忘判 null:条件渲染的元素 ref 可能没挂载;ref.current?.focus()。
  • 暴露整个 DOM 节点:父组件能对子组件 DOM 任意操作,破坏封装;用 useImperativeHandle 白名单。
  • 以为 ref.current 是响应式的:它不会让 JSX 更新,也不会出现在依赖数组的追踪里——想「值变了触发 effect」就得配 state。

自测题

  1. 说出 ref 与 state 在「触发渲染」上的本质区别,并各举一个正确用例。
  2. 为什么「渲染期间缓存计算结果到 ref」在并发渲染下不安全?正确工具是什么?
  3. useInterval 案例里两个 effect 各自解决什么问题?
  4. 实现 usePrevious:为什么更新放在 effect 里而不是渲染期间?
  5. React 19 中 forwardRef 的地位发生了什么变化?

与其他知识点的关系

  • 向前:是 kp-011 非受控、kp-014 「逃出快照」、kp-015 useInterval 等模式的底层工具。
  • 向后:kp-020 中 Suspense 出现前的测量类需求全靠它;kp-019 的性能测量(getBoundingClientRect)也走 ref;kp-024 中 ref 在 Server Component 里不可用(无浏览器)——这是 RSC 边界的判据之一。

延伸阅读

  • Referencing Values with Refs:<https://react.dev/learn/referencing-values-with-refs>
  • Manipulating the DOM with Refs:<https://react.dev/learn/manipulating-the-dom-with-refs>
  • useImperativeHandle:<https://react.dev/reference/react/useImperativeHandle>