React 基础体系 · 第 7/70 篇。示例以 React 19、现代 TypeScript 和主流框架能力为基础;客户端与服务端边界会明确说明。

React Ref 与 DOM:useRef、forwardRef、测量、焦点和命令式边界

在 React 中,组件通常通过 props 向下传递数据,通过 事件回调向上报告意图ref 属于另一条通道:它允许代码取得某个已提交到页面的 DOM 节点,或取得子组件主动暴露的命令式 API。

这条通道很有用,但也更容易绕过 React 的声明式数据流。理解 ref 的关键,不是记住“如何拿到 DOM”,而是明确:

  1. ref 何时可用,何时会被设为 null
  2. useRef 保存的值为什么变化却不会触发渲染;
  3. React 19 中函数组件如何接收 ref
  4. 测量和聚焦为什么通常需要 Effect;
  5. forwardRefuseImperativeHandle 如何形成明确的命令式边界;
  6. 在并发渲染、严格模式、SSR 和动态 DOM 中,哪些假设不成立。

一、ref 解决的是什么问题

1. 声明式状态与命令式对象

声明式代码描述“页面应该是什么样子”:

function Counter() {
  const [count, setCount] = useState(0);

  return (
    <button onClick={() => setCount(count + 1)}>
      {count}
    </button>
  );
}

这里的因果关系是:

count = 0
  ↓ setCount(1)
React 重新渲染
  ↓
button 文本变为 1

而聚焦、播放视频、读取布局等操作不是“声明一个值”就能完全完成的。例如:

inputElement.focus();
videoElement.play();
const rect = element.getBoundingClientRect();

这些 API 作用于浏览器已经创建的对象,属于命令式操作。ref 是 React 提供的入口,使组件能够在提交完成后访问这些对象。

2. ref 的三个基本对象类型

在 React 中,ref 最常见地指向三类对象:

目标 ref.current 的值
原生 DOM 元素 HTMLInputElementHTMLDivElement
类组件实例 旧式类组件实例
函数组件暴露的命令式句柄 useImperativeHandle 返回的对象

函数组件本身没有实例,因此不能直接把普通函数组件当成 DOM 节点来访问。它必须显式接收并处理 ref,或者暴露一个句柄。


二、useRef:一个跨渲染保持的可变容器

1. 基本语义

import { useRef } from "react";

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

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

useRef(initialValue) 返回一个稳定的对象:

{
  current: initialValue
}

在这个组件实例的整个生命周期内,React 会重复返回同一个 ref 对象。渲染之间变化的是 current 的内容,而不是 ref 对象本身。

可以把它形式化为:

R = useRef(initialValue)

第一次渲染:R = { current: initialValue }
后续渲染:R === 上一次的 R

但是:

R.current = newValue

不会自动触发重新渲染。因为 React 监听的是 setState 等更新机制,而不是普通 JavaScript 对象属性的赋值。

2. DOM ref 的生命周期

下面的组件中,React 会在提交阶段维护 inputRef.current

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

  function focusInput() {
    inputRef.current?.focus();
  }

  return (
    <>
      <input ref={inputRef} />
      <button type="button" onClick={focusInput}>
        聚焦
      </button>
    </>
  );
}

其生命周期大致是:

渲染阶段:
inputRef.current 可能仍为 null
        ↓
React 提交 <input> 到 DOM
        ↓
React 设置 inputRef.current = HTMLInputElement
        ↓
用户点击按钮
        ↓
focusInput 读取 current 并调用 focus()
        ↓
组件卸载或节点被替换
        ↓
React 设置 inputRef.current = null

因此,以下代码是不可靠的:

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

  // ❌ 渲染时不能假设 DOM 已经存在
  inputRef.current?.focus();

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

渲染函数必须尽量是纯函数。渲染期间可能发生预览、重试、放弃或重新开始;真正的 DOM 操作应放在事件处理函数、Effect 或 ref 回调中。

3. useRef 与 state 的区别

function Example() {
  const countRef = useRef(0);
  const [count, setCount] = useState(0);

  function updateBoth() {
    countRef.current += 1;
    setCount((value) => value + 1);
  }

  return (
    <button onClick={updateBoth}>
      state: {count}, ref: {countRef.current}
    </button>
  );
}

点击一次后,setCount 触发重新渲染,所以页面通常会看到:

state: 1, ref: 1

但如果只有:

countRef.current += 1;

React 不会重新执行组件函数,页面上的文本也不会更新。ref 适合保存“需要跨渲染存在,但变化本身不决定 UI”的值,例如:

  • DOM 节点;
  • 定时器 ID;
  • 上一次的值;
  • 外部库实例;
  • 不希望触发渲染的可变缓存。

如果一个值变化后 UI 必须变化,它应该是 state 或其他响应式数据,而不是 ref。

4. useRef 初始值只在首次渲染使用

const valueRef = useRef(createExpensiveObject());

createExpensiveObject() 这个表达式会在每次组件函数执行时被求值,但 React 只使用第一次得到的值作为 ref 初始值。若创建过程昂贵,不应误以为 useRef 会自动阻止表达式执行。

一种显式的惰性初始化方式是:

class VideoPlayer {
  play() {
    // ...
  }
}

function PlayerController() {
  const playerRef = useRef<VideoPlayer | null>(null);

  if (playerRef.current === null) {
    playerRef.current = new VideoPlayer();
  }

  return <button onClick={() => playerRef.current?.play()}>播放</button>;
}

这种初始化必须满足一个重要条件:同样的输入总是产生可预测的同一个初始化结果,并且不会在渲染过程中执行有外部副作用的操作。创建 WebSocket、订阅、启动计时器等不应这样做,应放入 Effect 并清理。


三、把 ref 传给 DOM:对象 ref、回调 ref 和函数组件

1. 对象 ref

对象 ref 是 useRef 返回的对象:

function TextField() {
  const ref = useRef<HTMLInputElement>(null);

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

React 在提交时写入:

ref.current = inputElement;

卸载或解除绑定时写入:

ref.current = null;

2. 回调 ref

ref 也可以是函数:

function TextField() {
  const handleRef = (node: HTMLInputElement | null) => {
    if (node) {
      node.focus();
    }
  };

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

回调 ref 在节点建立或解除绑定时接收:

node          建立绑定
null          解除绑定

回调 ref 适合需要“节点一出现就执行初始化”的场景,或者需要将一个节点同时交给多个系统的场景。

但不要在组件内无条件创建复杂回调并把它当成稳定订阅机制。回调函数身份变化可能导致 React 先调用旧回调并传入 null,再调用新回调传入节点。可以使用 useCallback 保持身份稳定:

const handleRef = useCallback((node: HTMLDivElement | null) => {
  if (node) {
    // 初始化第三方库
  } else {
    // 清理
  }
}, []);

React 19 支持回调 ref 返回清理函数;但若组件需要兼容较早的 React 版本,使用 null 分支进行清理更稳妥。

3. 同一个节点合并多个 ref

有时组件既要维护自己的 ref,又要把节点交给调用方:

import { useCallback, useRef } from "react";

function useMergedRefs<T>(
  ...refs: Array<React.Ref<T> | undefined>
) {
  return useCallback((node: T | null) => {
    for (const ref of refs) {
      if (!ref) continue;

      if (typeof ref === "function") {
        ref(node);
      } else {
        ref.current = node;
      }
    }
  }, refs);
}

使用:

function Input(props: React.ComponentProps<"input">) {
  const internalRef = useRef<HTMLInputElement>(null);
  const ref = useMergedRefs(internalRef, props.ref);

  return <input {...props} ref={ref} />;
}

这个示例的关键是:外部 ref 可能是对象,也可能是函数,不能只处理 MutableRefObject。在库代码中还应根据目标 React 版本和 TypeScript 类型定义验证 props.ref 的类型。


四、React 19 中的 ref 传递与 forwardRef

1. React 19 的首选写法:把 ref 当作 prop

在 React 19 中,函数组件可以直接声明 ref prop:

type TextFieldProps = {
  label: string;
  ref?: React.Ref<HTMLInputElement>;
};

function TextField({ label, ref }: TextFieldProps) {
  return (
    <label>
      {label}
      <input ref={ref} />
    </label>
  );
}

调用方:

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

  return <TextField label="邮箱" ref={inputRef} />;
}

React 19 文档将 forwardRef 标记为将被弃用的 API,并推荐直接使用 ref prop。这里的“推荐”不等于旧代码立刻失效:现有 forwardRef 仍是兼容和维护旧版本组件的重要方式。

实际项目中应区分:

  • 目标只支持 React 19:优先直接声明 ref prop;
  • 组件库需要兼容 React 18 或更早版本:继续使用 forwardRef
  • 升级中的代码库:不要为了形式统一而一次性改动所有 ref,先以兼容性和类型检查为准。

2. forwardRef 的工作方式

React 18 及之前,ref 不会像普通 prop 一样自动进入函数组件,因此需要:

import { forwardRef } from "react";

type TextFieldProps = {
  label: string;
};

const TextField = forwardRef<HTMLInputElement, TextFieldProps>(
  function TextField({ label }, ref) {
    return (
      <label>
        {label}
        <input ref={ref} />
      </label>
    );
  }
);

forwardRef(render) 的作用是把外部传入的 ref 作为第二个参数交给内部渲染函数:

<TextField ref={outerRef} />
        ↓
forwardRef render(props, ref)
        ↓
<input ref={ref} />

如果忘记透传:

const TextField = forwardRef<HTMLInputElement, TextFieldProps>(
  function TextField({ label }, _ref) {
    return (
      <label>
        {label}
        <input /> {/* ref 被丢弃 */}
      </label>
    );
  }
);

调用方的 outerRef.current 会一直是 null。这不是 React 找不到 DOM,而是组件没有建立 ref 的传递链。


五、测量 DOM:何时测量、测量什么、如何避免错误读数

1. DOM 测量依赖“已提交的布局”

测量通常使用:

const rect = element.getBoundingClientRect();

返回值包含:

{
  x, y, top, right, bottom, left,
  width, height
}

这些值描述元素相对于视口的几何信息。widthheight 可能包含边框盒尺寸;它们还会受到 CSS transform、滚动位置、缩放等因素影响,不能简单等同于 CSS widthheight

如果只想取得布局尺寸,还可能使用:

element.offsetWidth
element.offsetHeight

或:

element.clientWidth
element.clientHeight

它们对边框、滚动条和小数像素的处理不同。测量前应先明确问题是:

  • 需要元素在视口中的位置:getBoundingClientRect()
  • 需要布局占用的整数尺寸:offsetWidth / offsetHeight
  • 需要内容区域尺寸:clientWidth / clientHeight
  • 需要监听尺寸变化:ResizeObserver

2. useEffectuseLayoutEffect

React 的提交过程可以简化为:

渲染
  ↓
提交 DOM 更新
  ↓
浏览器布局和绘制
  ↓
useEffect

useLayoutEffect 的位置更接近:

渲染
  ↓
提交 DOM 更新
  ↓
useLayoutEffect
  ↓
浏览器绘制

当测量结果会立刻影响首次可见布局时,useLayoutEffect 可以避免用户看到“先显示错误位置,再跳到正确位置”的闪烁:

import { useLayoutEffect, useRef, useState } from "react";

function MeasuredPanel() {
  const panelRef = useRef<HTMLDivElement>(null);
  const [height, setHeight] = useState<number | null>(null);

  useLayoutEffect(() => {
    const node = panelRef.current;
    if (!node) return;

    const rect = node.getBoundingClientRect();
    setHeight(rect.height);
  }, []);

  return (
    <section>
      <div ref={panelRef}>需要测量的内容</div>
      <p>高度:{height === null ? "测量中" : `${height}px`}</p>
    </section>
  );
}

这里的步骤是:

  1. 首次渲染产生 sectiondiv
  2. React 提交 DOM;
  3. useLayoutEffect 读取已存在的 div
  4. 得到高度后设置 state;
  5. React 再渲染高度文本;
  6. 浏览器绘制最终结果。

如果测量只用于日志、统计或非视觉逻辑,普通 useEffect 通常足够。useLayoutEffect 会阻塞浏览器绘制,不能因为“测量都更准确”就默认使用它。

3. 处理动态尺寸:ResizeObserver

只在挂载时测量一次不能覆盖以下变化:

  • 文本换行;
  • 字体加载完成;
  • 容器宽度改变;
  • 响应式布局变化;
  • 子元素异步加载;
  • 用户改变窗口大小。

可以用 ResizeObserver 监听元素尺寸:

import {
  useLayoutEffect,
  useRef,
  useState,
} from "react";

type Size = {
  width: number;
  height: number;
};

function useElementSize<T extends HTMLElement>() {
  const ref = useRef<T | null>(null);
  const [size, setSize] = useState<Size | null>(null);

  useLayoutEffect(() => {
    const node = ref.current;
    if (!node) return;

    const update = (width: number, height: number) => {
      setSize((previous) => {
        if (
          previous &&
          previous.width === width &&
          previous.height === height
        ) {
          return previous;
        }

        return { width, height };
      });
    };

    const firstRect = node.getBoundingClientRect();
    update(firstRect.width, firstRect.height);

    const observer = new ResizeObserver((entries) => {
      const entry = entries[0];
      if (!entry) return;

      const box = entry.borderBoxSize?.[0];

      if (box) {
        update(box.inlineSize, box.blockSize);
      } else {
        update(entry.contentRect.width, entry.contentRect.height);
      }
    });

    observer.observe(node);

    return () => {
      observer.disconnect();
    };
  }, []);

  return [ref, size] as const;
}

function ResizableInfo() {
  const [panelRef, size] = useElementSize<HTMLDivElement>();

  return (
    <div ref={panelRef} style={{ resize: "both", overflow: "auto" }}>
      <p>
        {size
          ? `${Math.round(size.width)} × ${Math.round(size.height)}`
          : "测量中"}
      </p>
    </div>
  );
}

输入是一个可改变尺寸的 DOM 元素,预期结果是元素大小变化时文本同步更新。清理函数调用 disconnect(),避免组件卸载后观察器继续持有节点引用或更新已卸载组件。

需要注意,ResizeObserver 观察的是尺寸变化,不是位置变化。元素因滚动、定位或 transform 改变位置而没有改变尺寸时,它不会触发;这类需求可能需要滚动监听、IntersectionObserver 或重新测量策略。

4. 测量的失败路径

以下情况会导致读数与直觉不符:

const rect = node.getBoundingClientRect();
  • 节点还没有提交:ref.current === null
  • 节点处于 display: none 的子树:尺寸通常为零;
  • 字体尚未加载:文本最终换行后尺寸会改变;
  • 使用 CSS transform:视觉尺寸和布局尺寸可能不同;
  • 页面或祖先发生缩放:像素值不再代表未经缩放的 CSS 设计尺寸;
  • 测量后立刻写样式,再次测量:可能产生布局读写交错和性能问题。

基本的读写顺序应尽量保持:

先集中读取布局
  ↓
再集中写入样式或 state

不要在大量节点上交替执行:

读 A → 写 A → 读 B → 写 B

这可能迫使浏览器反复计算布局。具体性能取决于 DOM 规模和样式复杂度,不能用固定数字保证结果。


六、焦点管理:ref 只是入口,可访问性才是完整目标

1. 基本聚焦

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

  return (
    <form
      onSubmit={(event) => {
        event.preventDefault();
        inputRef.current?.focus();
      }}
    >
      <label htmlFor="query">搜索</label>
      <input id="query" ref={inputRef} />
      <button type="submit">提交</button>
    </form>
  );
}

这里使用了三项不同机制:

  • ref 取得输入框;
  • focus() 执行浏览器命令;
  • labelhtmlFor 建立可访问名称。

只有调用 focus() 而没有语义标签,并不能构成完整的可访问交互。

2. 在内容出现后聚焦

import { useEffect, useRef } from "react";

function SearchDialog({ open }: { open: boolean }) {
  const inputRef = useRef<HTMLInputElement>(null);

  useEffect(() => {
    if (!open) return;

    inputRef.current?.focus();
  }, [open]);

  if (!open) return null;

  return (
    <div role="dialog" aria-modal="true" aria-labelledby="dialog-title">
      <h2 id="dialog-title">搜索</h2>
      <input ref={inputRef} aria-label="搜索关键词" />
    </div>
  );
}

依赖数组中的 open 表示:当对话框从关闭变为打开时,执行聚焦逻辑。不能在渲染阶段调用 focus(),因为此时节点可能尚未提交。

如果对话框关闭时需要把焦点还给触发按钮,还要保存触发元素:

function DialogTrigger() {
  const triggerRef = useRef<HTMLButtonElement>(null);
  const [open, setOpen] = useState(false);

  function close() {
    setOpen(false);
    triggerRef.current?.focus();
  }

  return (
    <>
      <button ref={triggerRef} onClick={() => setOpen(true)}>
        打开对话框
      </button>

      {open && (
        <div role="dialog" aria-modal="true">
          <button onClick={close}>关闭</button>
        </div>
      )}
    </>
  );
}

真实对话框还需要处理 Escape、焦点陷阱或采用成熟的对话框组件。role="dialog"aria-modal="true" 不会自动实现焦点管理;Portal 也不会自动实现这些行为。

3. 动态列表中的焦点

列表 ref 的常见错误是按数组下标保存:

// ❌ 删除或排序后,下标可能对应错误节点
const refs = items.map(() => useRef<HTMLButtonElement>(null));

Hooks 不能在循环中动态调用,而且下标与数据身份不是同一个概念。应使用稳定 key 和 Map:

function ActionList({ ids }: { ids: string[] }) {
  const refs = useRef(new Map<string, HTMLButtonElement>());

  function focusItem(id: string) {
    refs.current.get(id)?.focus();
  }

  return (
    <div>
      {ids.map((id) => (
        <button
          key={id}
          ref={(node) => {
            if (node) {
              refs.current.set(id, node);
            } else {
              refs.current.delete(id);
            }
          }}
        >
          项目 {id}
        </button>
      ))}

      <button onClick={() => focusItem(ids[0] ?? "")}>
        聚焦第一项
      </button>
    </div>
  );
}

这里必须同时清理 null,否则节点卸载后 Map 仍可能保存旧 DOM 引用。key 也必须代表数据身份;如果使用数组下标,排序和插入会导致 React 复用错误节点,焦点和输入状态可能看起来“跳错位置”。


七、命令式边界:暴露能力,而不是暴露内部结构

1. 为什么不能让父组件随意修改子组件 DOM

下面这种 API 暴露了过多内部细节:

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

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

如果 InternalTextField 未来从 <input> 改成包含遮罩、隐藏输入和按钮的复杂结构,父组件就会依赖内部 DOM 类型。组件内部实现和外部调用方发生了不必要的耦合。

更稳定的做法是只暴露少量命令:

type TextFieldHandle = {
  focus: () => void;
  clear: () => void;
};

父组件关心“能做什么”,而不是“内部有哪个节点”。

2. useImperativeHandle

React 提供 useImperativeHandle(ref, createHandle, dependencies) 定义 ref 对外看到的值:

import {
  forwardRef,
  useImperativeHandle,
  useRef,
} from "react";

export type TextFieldHandle = {
  focus: () => void;
  clear: () => void;
};

type TextFieldProps = {
  label: string;
};

export const TextField = forwardRef<
  TextFieldHandle,
  TextFieldProps
>(function TextField({ label }, ref) {
  const inputRef = useRef<HTMLInputElement>(null);

  useImperativeHandle(
    ref,
    () => ({
      focus() {
        inputRef.current?.focus();
      },
      clear() {
        if (inputRef.current) {
          inputRef.current.value = "";
          inputRef.current.dispatchEvent(
            new Event("input", { bubbles: true })
          );
        }
      },
    }),
    []
  );

  return (
    <label>
      {label}
      <input ref={inputRef} />
    </label>
  );
});

调用方:

function Form() {
  const fieldRef = useRef<TextFieldHandle>(null);

  return (
    <>
      <TextField ref={fieldRef} label="用户名" />
      <button onClick={() => fieldRef.current?.focus()}>
        聚焦
      </button>
      <button onClick={() => fieldRef.current?.clear()}>
        清空
      </button>
    </>
  );
}

调用链为:

Form
  ↓ 调用 fieldRef.current.focus()
TextFieldHandle.focus()
  ↓
inputRef.current.focus()
  ↓
浏览器将焦点交给内部 input

3. useImperativeHandle 的依赖关系

createHandle 会读取组件作用域中的值,因此这些值必须出现在依赖数组中:

useImperativeHandle(
  ref,
  () => ({
    focus() {
      inputRef.current?.focus();
      console.log(label);
    },
  }),
  [label]
);

如果 label 改变而依赖数组仍为 [],句柄中的闭包可能继续读取旧值。inputRef 对象本身在组件生命周期内稳定,因此不需要因为 inputRef.current 改变而重新创建句柄;current 的变化由方法调用时读取。

命令式句柄的方法应当防御节点不存在:

focus() {
  inputRef.current?.focus();
}

组件可能刚刚卸载、条件渲染切换中,或调用方持有的是旧句柄。句柄不能假设 DOM 永远存在。

4. React 19 直接接收 ref 的句柄写法

在 React 19 中可以不使用 forwardRef

type TextFieldProps = {
  label: string;
  ref?: React.Ref<TextFieldHandle>;
};

function TextField({ label, ref }: TextFieldProps) {
  const inputRef = useRef<HTMLInputElement>(null);

  useImperativeHandle(
    ref,
    () => ({
      focus: () => inputRef.current?.focus(),
      clear: () => {
        if (inputRef.current) {
          inputRef.current.value = "";
        }
      },
    }),
    []
  );

  return (
    <label>
      {label}
      <input ref={inputRef} />
    </label>
  );
}

如果 clear() 需要与 React 控制的 value 同步,就不能只修改 DOM:

// ❌ 受控输入的 value 仍由 React state 决定
inputRef.current.value = "";

对于受控组件,应把清空定义成声明式动作,例如由父组件提供 onClear,或者让父组件修改 value。命令式 DOM 修改只适合组件确实以非受控方式管理该值的情况。


八、ref 与 Effect:时序、依赖和清理

1. Ref 不是 Effect 的替代品

下面代码把外部系统初始化放在渲染中:

function MapView() {
  const containerRef = useRef<HTMLDivElement>(null);

  // ❌ 渲染期间创建外部实例
  // const map = new MapLibrary(containerRef.current);

  return <div ref={containerRef} />;
}

DOM 节点尚未可靠提交,且渲染可能被 React 放弃。应在 Effect 中同步:

function MapView({ zoom }: { zoom: number }) {
  const containerRef = useRef<HTMLDivElement>(null);
  const mapRef = useRef<MapInstance | null>(null);

  useEffect(() => {
    const container = containerRef.current;
    if (!container) return;

    const map = new MapInstance(container, { zoom });
    mapRef.current = map;

    return () => {
      map.destroy();
      mapRef.current = null;
    };
  }, [zoom]);

  return <div ref={containerRef} />;
}

这里有两个独立生命周期:

  • containerRef.current 由 React 维护;
  • mapRef.current 由 Effect 中的外部库实例维护。

清理函数必须撤销初始化产生的资源。若 zoom 变化会使实例配置失效,就需要销毁旧实例并创建新实例;如果外部库支持原地更新,也可以只调用 map.setZoom(zoom),此时应把更新逻辑和初始化逻辑分开。

2. Strict Mode 下的重复初始化暴露问题

开发环境的 Strict Mode 可能让 Effect 经历:

setup → cleanup → setup

这不是生产环境一定重复执行的承诺,而是帮助发现 Effect 不可逆的问题。若代码这样写:

useEffect(() => {
  subscribe();
}, []);

却没有取消订阅,开发环境中会看到重复监听、重复事件或重复网络行为。正确结构是:

useEffect(() => {
  const unsubscribe = subscribe();

  return () => {
    unsubscribe();
  };
}, []);

不要用 ref 设置“已经运行过”的标志来绕过清理:

// ❌ 掩盖生命周期错误
const ran = useRef(false);

useEffect(() => {
  if (ran.current) return;
  ran.current = true;
  subscribe();
}, []);

这会让开发环境看似不重复,但并没有解决资源释放问题。

3. 用 ref 读取最新值:解决闭包问题,但不触发响应

某些事件监听器只安装一次,却需要读取最新配置,可以将最新值写入 ref:

function KeyboardListener({ enabled }: { enabled: boolean }) {
  const enabledRef = useRef(enabled);

  useEffect(() => {
    enabledRef.current = enabled;
  }, [enabled]);

  useEffect(() => {
    function onKeyDown(event: KeyboardEvent) {
      if (enabledRef.current && event.key === "Escape") {
        console.log("处理 Escape");
      }
    }

    window.addEventListener("keydown", onKeyDown);
    return () => {
      window.removeEventListener("keydown", onKeyDown);
    };
  }, []);

  return null;
}

这里的取舍是:

  • 监听器不因 enabled 改变而反复安装;
  • 处理函数读取的是最新的 enabledRef.current
  • 修改 ref 不会渲染 UI。

如果监听器本来就应该随着依赖变化而重建,直接把 enabled 放进 Effect 依赖更清楚。ref 不是自动解决依赖问题的工具。


九、并发渲染下不能读取“尚未提交”的 ref

React 可以先渲染一个新树,再决定是否提交它。渲染期间写入或读取 ref 可能产生错误假设:

function Component({ value }: { value: string }) {
  const previous = useRef(value);

  // ⚠️ 渲染期间修改可变对象
  previous.current = value;

  return <span>{previous.current}</span>;
}

这个例子虽然看起来简单,但“渲染阶段修改 ref”会让可变对象暴露给其他逻辑,破坏渲染纯度。保存上一次已提交值时,更常见的写法是:

function PreviousValue({ value }: { value: string }) {
  const previousRef = useRef<string | undefined>(undefined);

  useEffect(() => {
    previousRef.current = value;
  }, [value]);

  return (
    <p>
      当前值:{value};上一次值:
      {previousRef.current ?? "无"}
    </p>
  );
}

Effect 在提交后更新,因此下一次渲染读取到的是上一次已提交值,而不是某次可能被放弃的渲染值。

需要区分一个常见例外:在渲染期间进行一次性、可预测的 ref 初始化,有时是允许的;但不能借此在渲染阶段读取 DOM、订阅外部资源或执行不可逆副作用。


十、服务端渲染与客户端边界

DOM 只存在于浏览器。服务端渲染时:

const ref = useRef<HTMLDivElement>(null);

可以创建 ref 容器,但服务端不会有真实的 HTMLDivElement,也不会执行浏览器的 focus()、测量或 ResizeObserver

因此,以下代码必须位于客户端组件或浏览器执行路径:

useEffect(() => {
  const node = ref.current;
  if (!node) return;

  node.focus();
}, []);

在支持 Server Components 的框架中,包含 useRef、事件处理和 DOM 操作的组件通常需要客户端边界,例如:

"use client";

import { useRef } from "react";

具体边界语法由框架决定,但原则不变:

服务端:
生成 HTML 和可序列化数据
        ↓
浏览器加载并 hydration
        ↓
客户端 React 创建 ref、绑定事件、执行 Effect
        ↓
DOM 操作才可发生

不要在模块顶层直接访问:

// ❌ 服务端导入模块时 window 可能不存在
const width = window.innerWidth;

也不要把 HTMLElement、函数、DOM 节点等不可序列化对象作为服务端到客户端的普通 props 传递。ref 应在客户端组件内部建立,并通过客户端事件或 Effect 使用。

useLayoutEffect 在服务端没有布局可测量。不同框架对服务端警告的处理不同;如果组件确实依赖布局测量,应将其放在客户端边界内,而不是试图在服务端模拟 DOM 尺寸。


十一、常见误解与诊断路径

1. ref.current 为什么是 null

依次检查:

  1. ref 是否真正挂在 JSX 节点上;
  2. 组件是否仍处于条件渲染的关闭分支;
  3. 读取是否发生在渲染期间,而不是事件或 Effect 中;
  4. 自定义组件是否透传了 ref;
  5. 是否在节点卸载之后调用;
  6. 是否把对象 ref 和回调 ref 的类型混用了。

错误:

function Wrapper({ ref }: { ref: React.Ref<HTMLInputElement> }) {
  return <div ref={ref} />; // 类型或语义都不匹配
}

正确做法是让 ref 类型与最终节点一致,或暴露明确句柄。

2. ref 改了但页面没有更新

这是预期行为:

ref.current = value;

如果页面需要显示 value,必须使用 state:

const [value, setValue] = useState("");

ref 只能作为非响应式存储。

3. 一个 ref 被多个节点使用

function Wrong() {
  const ref = useRef<HTMLDivElement>(null);

  return (
    <>
      <div ref={ref}>A</div>
      <div ref={ref}>B</div>
    </>
  );
}

同一个 ref 不能表达“两个当前节点”。最终 current 会指向后续绑定的节点,卸载顺序还可能使其变为 null。应使用多个 ref 或 Map。

4. 列表中的节点错位

若列表使用不稳定 key:

items.map((item, index) => (
  <input key={index} />
))

插入、删除、排序后,React 可能复用原节点。结果可能表现为:

  • 焦点跑到错误项目;
  • 非受控输入保留了错误文本;
  • ref Map 与屏幕上的数据不一致。

key 应稳定对应数据身份:

items.map((item) => (
  <input key={item.id} />
))

5. 测量结果总是零

先检查:

const node = ref.current;
console.log(node);
console.log(node?.getBoundingClientRect());

若节点存在但宽高为零,再检查:

  • 祖先是否 display: none
  • 元素是否还未打开;
  • CSS 是否尚未加载;
  • 是否测量了隐藏的 Portal 内容;
  • 是否误把 contentRect、border box 和视觉 transform 混为一谈。

如果尺寸会变化,应增加 ResizeObserver,而不是在一次 Effect 中无限重测。

6. 聚焦后马上丢失

常见原因包括:

  • 后续渲染替换了节点;
  • Effect 依赖变化导致另一个逻辑重新聚焦;
  • 弹窗关闭后焦点回收代码执行;
  • 测试环境没有真实布局或浏览器焦点支持;
  • 某个组件在 hydration 后重新生成 DOM。

诊断时观察浏览器 Elements 面板中的节点身份,而不仅是相同的标签和文本;同时记录焦点变化:

document.addEventListener("focusin", (event) => {
  console.log("focus:", event.target);
});

十二、如何划定命令式边界

一个组件是否应该暴露 ref,可以用以下因果条件判断:

外部是否需要操作一个非声明式浏览器对象?
        ↓
是:考虑 DOM ref 或命令式句柄
        ↓
外部是否必须知道内部 DOM 结构?
        ↓
尽量否:通过 useImperativeHandle 暴露最小 API

适合通过 ref 暴露的能力:

type EditorHandle = {
  focus: () => void;
  selectAll: () => void;
  scrollToSelection: () => void;
};

不适合通过 ref 暴露的能力:

type BadHandle = {
  inputElement: HTMLInputElement;
  internalWrapper: HTMLDivElement;
  setPrivateFlag: (value: boolean) => void;
};

前者表达稳定的用户行为,后者泄漏实现细节。命令式句柄仍然应服从组件的状态模型:如果一个动作会改变用户可见状态,最好由组件内部维护一致性,或通过明确的回调与 state 流转,而不是让父组件直接改 DOM 后再期待 React 知道发生了什么。


十三、一个完整的 React 19 示例

下面的组件同时展示:

  • 直接接收 ref prop;
  • 暴露有限命令;
  • 打开后聚焦;
  • 使用 ResizeObserver 更新测量结果;
  • 卸载时安全清理;
  • 不把内部 DOM 暴露给父组件。
"use client";

import {
  useEffect,
  useImperativeHandle,
  useLayoutEffect,
  useRef,
  useState,
} from "react";

export type SearchPanelHandle = {
  focusInput: () => void;
  clearInput: () => void;
};

type SearchPanelProps = {
  open: boolean;
  onSubmit: (query: string) => void;
  ref?: React.Ref<SearchPanelHandle>;
};

export function SearchPanel({
  open,
  onSubmit,
  ref,
}: SearchPanelProps) {
  const inputRef = useRef<HTMLInputElement>(null);
  const [query, setQuery] = useState("");
  const [width, setWidth] = useState<number | null>(null);

  useImperativeHandle(
    ref,
    () => ({
      focusInput() {
        inputRef.current?.focus();
      },
      clearInput() {
        setQuery("");
        inputRef.current?.focus();
      },
    }),
    []
  );

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

  useLayoutEffect(() => {
    const node = inputRef.current;
    if (!node) return;

    const update = (nextWidth: number) => {
      setWidth((previous) =>
        previous === nextWidth ? previous : nextWidth
      );
    };

    update(node.getBoundingClientRect().width);

    const observer = new ResizeObserver(([entry]) => {
      if (entry) {
        update(entry.contentRect.width);
      }
    });

    observer.observe(node);

    return () => {
      observer.disconnect();
    };
  }, [open]);

  if (!open) {
    return null;
  }

  return (
    <form
      role="search"
      onSubmit={(event) => {
        event.preventDefault();
        onSubmit(query);
      }}
    >
      <label htmlFor="search-query">关键词</label>
      <input
        id="search-query"
        ref={inputRef}
        value={query}
        onChange={(event) => setQuery(event.target.value)}
      />
      <button type="submit">搜索</button>
      <button
        type="button"
        onClick={() => {
          setQuery("");
          inputRef.current?.focus();
        }}
      >
        清空
      </button>
      <small>
        输入框宽度:
        {width === null ? "测量中" : `${Math.round(width)}px`}
      </small>
    </form>
  );
}

父组件:

function SearchPage() {
  const [open, setOpen] = useState(false);
  const panelRef = useRef<SearchPanelHandle>(null);

  return (
    <>
      <button
        onClick={() => {
          setOpen(true);
          // 这里不能立即假设面板已提交。
          // 聚焦由 SearchPanel 的 open Effect 完成。
        }}
      >
        打开搜索
      </button>

      <button onClick={() => panelRef.current?.focusInput()}>
        聚焦搜索框
      </button>

      <SearchPanel
        ref={panelRef}
        open={open}
        onSubmit={(query) => {
          console.log("提交查询:", query);
        }}
      />
    </>
  );
}

关键时序如下:

点击“打开搜索”
  ↓
setOpen(true)
  ↓
SearchPanel 渲染出 form 和 input
  ↓
React 提交 DOM,并绑定 inputRef
  ↓
open Effect 调用 focus()
  ↓
layout Effect 首次测量
  ↓
ResizeObserver 继续监听尺寸变化

clearInput 修改的是 React state,而不是直接修改受控 input 的 DOM value,因此 React 的数据源仍然唯一。对外只暴露 focusInputclearInput,父组件无需知道内部使用了哪个 HTML 元素。


十四、规范保证、实现事实与工程取舍

需要区分三类结论:

React API 的规范语义

  • useRef 在组件实例生命周期内返回稳定的 ref 对象;
  • 修改 ref.current 不会触发 React 渲染;
  • DOM ref 在节点提交后指向节点,在解除绑定后为 null
  • useImperativeHandle 定义外部通过 ref 看到的值;
  • React 19 支持函数组件直接接收 ref prop。

浏览器行为

  • focus() 受浏览器焦点策略、用户手势和节点可见性影响;
  • getBoundingClientRect() 返回当前布局结果;
  • ResizeObserver 监听尺寸变化,不保证监听位置变化;
  • display: none、字体加载、缩放和 transform 都可能影响测量结果。

工程建议

  • 视觉首帧依赖测量时考虑 useLayoutEffect
  • 非视觉同步优先使用 useEffect
  • 外部库初始化必须配套清理;
  • 跨版本组件库应保守评估 React 19 的直接 ref prop 写法;
  • 对外优先暴露语义化命令,而不是内部 DOM 节点。

ref 的价值在于连接 React 与那些无法仅靠 JSX 描述的对象:焦点、布局、媒体、动画库和外部实例。它不是第二套 state,也不是绕过组件边界的通行证。把 DOM 访问放在提交之后,把外部资源放进可清理的 Effect,把可见状态留在 React 数据流中,再用最小命令式句柄连接两者,才能让 ref 在并发渲染、动态布局、可访问性和服务端边界下保持可预测。


系列导航与关联阅读

官方资料

本文依据 React 与生态项目官方文档重新梳理;正文与示例由 WR BLOG 编写。