12k
All articles

如何在 React 19.3 中使用 Fragment Ref

了解如何使用 React 19.3 Fragment ref,在不添加包装元素的情况下处理兄弟元素的事件、焦点、观察和测量,并掌握方法限制及何时仍需 DOM 元素。

OpenReplay Team
OpenReplay Team
如何在 React 19.3 中使用 Fragment Ref

在 React 19.3 中,向 <Fragment> 传递 ref 后,你会得到一个 FragmentInstance:它的 DOM 方法会把 Fragment 的所有子节点作为一个整体进行操作,而且不会向页面中添加任何额外元素。

你很可能遇到过这种情况:为了拿到一组兄弟元素的 ref,只好用一个 <div> 把它们包起来,结果卡片不再是容器的直接子元素,CSS grid 或 flex 布局随之错乱。借助 Fragment ref,你可以为一组兄弟元素绑定事件监听器、观察器和焦点处理逻辑,同时保持 DOM 结构不变。本文不做完整的版本回顾(早期特性可参阅 React 19 新特性),而是重点介绍该 API 的工作原理、各个方法能作用到哪些子节点,以及在哪些场景下包裹元素仍是更好的选择。

核心要点

  • Fragment ref 在 React 19.3 中已成为稳定特性。<></> 简写语法无法接收 ref,因此需要导入 Fragment 并写成 <Fragment ref={ref}>。
  • ref 中保存的是一个 FragmentInstance,既不是 DOM 节点,也不是数组。它提供十二个方法,分为四组:事件、焦点、观察器以及测量/滚动。
  • 事件、观察器和测量方法只能作用于 Fragment 的第一层 DOM 子节点;而 focus() 和 focusLast() 会以深度优先的方式搜索嵌套元素。
  • FragmentInstance 上的 scrollIntoView() 只接受 alignToTop 布尔值,传入选项对象会抛出错误。

什么是包裹 div 问题?

在 React 19.3 之前,想要操作一组兄弟元素,唯一的办法就是用一个真实的 DOM 元素把它们包起来,再把 ref 挂到这个元素上。但包裹元素会改变布局树,而 CSS grid 和 flexbox 这类布局系统只识别直接子元素。

// Before: the div is now the only grid item
<div className="grid">
  <div ref={groupRef}><Card /><Card /><Card /></div>
</div>

// After: each card is still a grid item
<div className="grid">
  <Fragment ref={groupRef}><Card /><Card /><Card /></Fragment>
</div>

在使用包裹 div 的版本中,卡片变成了包裹元素的子元素,而不再是 grid 的子元素。而 Fragment 版本生成的 DOM 与完全不使用 ref 时一模一样。

React Fragment Ref 是如何工作的?

React Fragment ref 的用法与其他 ref 相同,区别仅在于 React 赋给它的值是一个 FragmentInstance。Fragment Ref 已在 2026 年 9 月 9 日发布的 React 19.3 版本中成为稳定特性。你需要使用 React 19.3 或更高版本(react 与 react-dom 均为 19.3.0+,包括 19.3.1 补丁版本),因此请为这两个包安装相匹配的 19.3.x 版本。如果你是从更早的大版本升级,建议先阅读升级到 React 19 时的常见错误。

<Fragment> 参考文档列出了以下十二个方法:

方法分组方法作用范围说明
事件addEventListener、removeEventListener、dispatchEvent第一层子节点dispatchEvent 在 Fragment 上触发,并可冒泡到其 DOM 父节点
焦点focus、focusLast、blur嵌套节点,深度优先focus() 接受 FocusOptions
观察器observeUsing、unobserveUsing第一层子节点支持 IntersectionObserver 或 ResizeObserver;不支持文本节点
测量/滚动getClientRects、getRootNode、compareDocumentPosition、scrollIntoView第一层子节点getRootNode 从父节点开始计算;scrollIntoView 只接受布尔值

使用这个 API 时遇到的大多数意外情况,都源于”第一层”规则。下面这个简短的嵌套示例清楚地展示了每组方法能作用到哪些节点:

<Fragment ref={groupRef}>
  <div className="row">          {/* first-level: gets listeners, observed, measured */}
    <button>Save</button>        {/* nested: not observed; focus() can reach it */}
  </div>
  <Card />                       {/* component: the element Card renders at its top level is first-level */}
  Some text                      {/* text node: observeUsing does not work on it */}
</Fragment>

组件本身不算作一个层级。真正起作用的是每个组件在顶层渲染出的 DOM 元素,因此即使 <Card /> 是一个 React 组件,它也被视为第一层子节点。

如何为每个子节点添加同一个事件监听器?

在 FragmentInstance 上调用 addEventListener,会一次性为所有第一层 DOM 子节点注册该处理函数。请在 effect 中注册,并在清理函数中移除,两次调用都要传入同一个函数引用。

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

function ClickGroup({ onClick, children }) {
  const groupRef = useRef(null);

  useEffect(() => {
    const group = groupRef.current;
    if (!group) return;
    group.addEventListener('click', onClick);
    return () => group.removeEventListener('click', onClick);
  }, [onClick]);

  return <Fragment ref={groupRef}>{children}</Fragment>;
}

参考文档指出,当子节点进入或离开 Fragment 时,React 会自动为其添加或移除监听器。请传入一个稳定的 onClick(例如用 useCallback 包裹),否则每次渲染都会生成新函数,导致 effect 在每次渲染时都取消订阅再重新订阅。

当事件冒泡时,嵌套元素同样会触发监听器。点击第一层 <div> 内部的 <button>,事件会冒泡到该 <div>,而处理函数正绑定在这个 <div> 上。不冒泡的事件(如 focus 或 mouseenter)则无法从更深层的元素传递到监听器。

如何使用 observeUsing 监测整组元素?

observeUsing() 会将你事先创建好的 IntersectionObserver 或 ResizeObserver 连接到每个第一层 DOM 子节点上;unobserveUsing() 则会将同一个观察器实例与这些子节点断开。

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

function VisibilityGroup({ onVisibilityChange, children }) {
  const groupRef = useRef(null);

  useEffect(() => {
    const group = groupRef.current;
    if (!group) return;
    const visible = new Set();
    const observer = new IntersectionObserver((entries) => {
      for (const entry of entries) {
        if (entry.isIntersecting) visible.add(entry.target);
        else visible.delete(entry.target);
      }
      onVisibilityChange(visible.size > 0);
    });
    group.observeUsing(observer);
    return () => {
      group.unobserveUsing(observer);
      observer.disconnect();
    };
  }, [onVisibilityChange]);

  return <Fragment ref={groupRef}>{children}</Fragment>;
}

这里的 Set 用于记录当前处于相交状态的子节点,因此只要有任意一张卡片出现在屏幕上,整组就被视为可见。这些子节点可以是从不接收 ref 的组件。正如 Fragment 参考文档所述,文本节点不会被观察;如果 Fragment 只包含文本,React 会在开发环境中输出警告。

使用 focus() 移动焦点

在 Fragment ref 上调用 focus(),会将焦点移动到该组中第一个可聚焦的元素。即使输入框位于某个未暴露 ref 的子组件所渲染的 <label> 或 <fieldset> 内部,这一方法同样有效,因为焦点方法会以深度优先的方式搜索嵌套子节点。

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

function EditAddress() {
  const [editing, setEditing] = useState(false);
  const fieldsRef = useRef(null);

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

  return (
    <section>
      <button onClick={() => setEditing(true)}>Edit address</button>
      {editing && (
        <Fragment ref={fieldsRef}>
          <AddressFields />
        </Fragment>
      )}
    </section>
  );
}

在表单或模态框的会话回放中,你可能会看到状态变化后焦点无处落脚,导致下一次按键输入落到了 <body> 上。在 Fragment ref 上调用 focus() 即可解决这一问题,而无需额外的包裹元素。如需聚焦最后一个可聚焦元素,可以使用 focusLast();必要时还可以传入 FocusOptions,例如 { preventScroll: true }。使用 ref 回调同样可行,但要保持回调函数稳定,以免在每次渲染时重复执行。

什么时候仍然需要包裹元素?

当你真正需要的是 DOM 节点本身时,就应该使用包裹元素。Fragment ref 作用于一组子节点,它并不会创建一个盒子。在以下情况下,请保留包裹元素:

  • 需要为整组元素设置样式。 背景、边框、内边距和 grid 区域都需要一个真实的元素。
  • 需要一个统一的边界框。 getClientRects() 返回的是各子节点矩形组成的扁平数组,而不是一个合并后的矩形。
  • 目标元素嵌套在第一层子节点之下。 观察器和不冒泡的事件永远无法到达这些元素。

另外请注意,scrollIntoView() 只接受 alignToTop。如果你需要平滑滚动或 block 对齐方式,请在真实节点上使用 Element 方法。

总结

借助 Fragment ref,你可以为兄弟元素组绑定监听器、观察器和焦点逻辑,而不会破坏 grid 或 flex 布局。使用之前,请先确认”第一层”规则:事件、观察器和测量方法仅作用于直接 DOM 子节点,而焦点方法会向更深层搜索。想要上手体验,可以升级到 React 19.3,并将某个仅用于挂载 ref 的包裹 div 替换为 <Fragment ref>。该版本还新增了 Trusted Types 支持,详情请参阅 React 19.3 发布文章。

常见问题

如何在 TypeScript 中为 Fragment ref 声明类型?

使用 useRef,并传入从 react 导出的 FragmentInstance 类型,初始值设为 null。在 @types/react 19.3 中,FragmentInstance 接口默认是空的。@types/react-dom 会通过 declare module 'react' 对其进行扩充,添加 focus、observeUsing、addEventListener 等方法。如果这些方法看起来不存在,请安装 @types/react-dom,并确保 react-dom 模块包含在项目的类型依赖图中。

在 Fragment ref 上调用 scrollIntoView 时,会滚动到哪个子节点?

如果 alignToTop 为 true 或未传入,scrollIntoView() 会将 Fragment 的第一个子节点与可滚动祖先元素的顶部对齐;如果 alignToTop 为 false,则会将最后一个子节点与底部对齐。对于空的 Fragment,会回退到最近的兄弟节点或父节点。传入选项对象(例如将 behavior 设置为 'smooth')会直接抛出错误,而不会执行滚动。

在隐藏的 Activity 边界内,Fragment ref 的事件监听器是否生效?

不生效。对于位于隐藏 Activity 边界内的子节点,React 不会应用通过 FragmentInstance 的 addEventListener 添加的监听器。当该边界从隐藏切换为可见时,React 会自动应用这些监听器。内容变为可见后,你无需在自己的代码中重新注册。

如果焦点不在组内,在 Fragment ref 上调用 blur() 会怎样?

什么也不会发生。只有当 document.activeElement 位于 Fragment 内部时,FragmentInstance 上的 blur() 才会移除该元素的焦点。如果当前活动元素位于页面的其他位置,该调用不会产生任何效果。这意味着你可以在关闭或取消处理函数中直接调用 blur(),而无需事先检查焦点所在位置。

DevTools for the frontend

Gain Debugging Superpowers

Unleash the power of session replay to reproduce bugs, track slowdowns and uncover frustrations in your app. Get complete visibility into your frontend with OpenReplay — the most advanced open-source session replay tool for developers.

Star on GitHub12k

We use cookies to improve your experience. By using our site, you accept cookies.