如何在 React 19.3 中使用 Fragment Ref
了解如何使用 React 19.3 Fragment ref,在不添加包装元素的情况下处理兄弟元素的事件、焦点、观察和测量,并掌握方法限制及何时仍需 DOM 元素。
在 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(),而无需事先检查焦点所在位置。
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