首页 / 文章

React 19.3中的片段引用:兄弟元素组的DOM行为,无需包装器

了解 Fragment 中的引用器如何为监听器、焦点管理、观察者以及跨兄弟元素的测量提供 FragmentInstance,以及它的局限性所在。

发布者:REACTAPP.TOP阅读约 12 分钟2858 词

来源: React 19.3 Fragment Refs: Listen, Focus, Observe and Measure a Group of Elements Without a Wrapper div

大多数 React 代码库中都存在一个 <div>,它的存在只是为了让某个元素能够持有 ref。也许你需要测量的组件渲染了多个兄弟元素,或者该组件来自一个从不传递其 ref 的库。这样的额外元素会破坏网格或弹性布局,干扰 :nth-child 样式设置,而在 <tr> 元素周围则会导致无效的 HTML。React 19.3 将 Fragment Refs 定为稳定特性:当你在 <Fragment> 上附加 ref 时,会得到一个 FragmentInstance,它能够一次性对所有片段子元素执行监听、聚焦、观察、测量和滚动等常见的 DOM 操作,且不会为渲染树增添任何额外内容。本文将介绍相关 API、每种功能的实际应用模式、各方法作用的子元素,以及发布前需要了解的潜在问题。

由于在撰写本文时该功能尚属新功能,建议查看当前的 <Fragment> 文档,以了解方法名称或行为上的任何变化。

FragmentInstance API

通常的实现方式是显式导入 Fragment,因为该引用只有在组件挂载后才会存在:

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

该引用最初为 null,在片段挂载后会变为 FragmentInstance。下面的代码可以防止出现 null 的情况,同时会添加监听器并在清理时将其移除:

function Group({ children }) {
  const ref = useRef(null);            // becomes a FragmentInstance after mount
  useEffect(() => {
    const instance = ref.current;
    if (instance === null) return;
    instance.addEventListener('click', onClick);
    return () => instance.removeEventListener('click', onClick);
  }, []);
  return <Fragment ref={ref}>{children}</Fragment>;
}

必须使用显式的 <Fragment> 元素。<>…</> 简写形式既不支持 ref 也不支持 key。以下是该实例所提供的功能:

  • addEventListener(type, fn, opts?) 和 removeEventListener:作用于一级 DOM 子节点,已注册的监听器会自动应用到之后新增或移除的子节点上。
  • dispatchEvent(event):在片段本身上触发事件;若设置 bubbles: true,则该事件会向父级 DOM 节点传播。
  • focus(opts?) 和 focusLast(opts?):以深度优先的方式搜索所有嵌套的后代节点,找到第一个或最后一个可聚焦元素,并支持传入参数 { preventScroll }。
  • blur():若当前活动元素位于该片段内,则使其失去焦点,否则不执行任何操作。
  • observeUsing(observer) 和 unobserveUsing(observer):将一级 DOM 子节点注册到 IntersectionObserver 或 ResizeObserver 中;文本节点不在范围内。
  • getClientRects():返回一级 DOM 子节点的扁平 DOMRect[] 数组。
  • scrollIntoView(alignToTop?):当参数为 true 时将第一个子节点滚动到可视区域,为 false 时将最后一个子节点滚动到可视区域;仅接受布尔值,传入选项对象会抛出异常。
  • getRootNode() 和 compareDocumentPosition(node):以与 Node 方法相同的规范描述片段的位置。
  • 为一组子节点设置一个监听器

    一种常见场景是使用你无法控制的组件来渲染工具栏,而你需要为该工具栏的所有按钮添加统一的点击监听器,例如用于上传分析数据。演示代码中导入的内容如下:

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

    Toolbar代表一个第三方组件:它直接渲染没有封装结构且没有ref属性的兄弟按钮,另外还可能包含一个可选的第四个按钮。Tracked则将子元素封装在带有ref属性的Fragment中,并通过effect函数绑定onAny处理函数,当该处理函数发生变化时还会重新绑定。App会记录每个被点击按钮的文本,并提供一个复选框用于添加额外的按钮:

    // Imagine this comes from a library: no ref, no wrapper, just siblings.
    function Toolbar({ extra }) {
      return (
        <>
          <button>Bold</button>
          <button>Italic</button>
          <button>Link</button>
          {extra && <button>Emoji</button>}
        </>
      );
    }function Tracked({ children, onAny }) {
      const ref = useRef(null);
      useEffect(() => {
        const instance = ref.current;
        if (instance === null) return;
        instance.addEventListener('click', onAny);
        return () => instance.removeEventListener('click', onAny);
      }, [onAny]);
      return <Fragment ref={ref}>{children}</Fragment>;
    }export default function App() {
      const [log, setLog] = useState([]);
      const [extra, setExtra] = useState(false);
      return (
        <>
          <div className="bar">
            <Tracked onAny={e => setLog(l => [e.target.textContent, ...l].slice(0, 6))}>
              <Toolbar extra={extra} />
            </Tracked>
          </div>
          <label><input type="checkbox" checked={extra} onChange={e => setExtra(e.target.checked)} /> add a fourth button</label>
          <p className="log">clicked: {log.join(' · ') || '—'}</p>
        </>
      );
    }
    

    样式仅用于布局工具栏和日志行:

    .bar { display: flex; gap: 8px; margin-bottom: 10px; }
    .log { margin: 10px 0 0; font-family: ui-monospace, monospace; opacity: .8; }
    

    启用第四个按钮并点击它,它会像其他按钮一样被记录下来。在 React 组件挂载和卸载时,片段实例会追踪其子元素,并相应地更新监听器,这正是否则你需要手动完成的操作。注意,在演示中 onAny 是内联箭头函数,因此每次渲染 App 时都会重新订阅该效果;在实际代码中,如果频繁重置会影响性能,应使用 useCallback 来稳定它。

    在嵌套内容中移动焦点

    只有 focus() 和 focusLast() 会查看第一层之外的内容。它们会以深度优先顺序遍历所有后代元素,直到找到可聚焦的元素,因此即使输入框嵌在 fieldset 中的 label 内,也能被访问到。这样一来,即便对于非自己编写的标记结构,“聚焦此部分中的第一个字段”也变得非常简单。

    该演示仅导入 Fragment 和 useRef:

    import { Fragment, useRef } from 'react';
    

    Section 会渲染一个标题和三个按钮,这些按钮分别与片段实例上的 focus()、focusLast() 和 blur() 方法相连,随后将该片段的所有子元素渲染到通过 useRef 指定的 Fragment 中。App 则传递包含带标签输入框的两个字段集:

    function Section({ title, children, controls }) {
      const ref = useRef(null);
      return (
        <>
          <div className="row">
            <strong>{title}</strong>
            <button onClick={() => ref.current.focus()}>First field</button>
            <button onClick={() => ref.current.focusLast()}>Last field</button>
            <button onClick={() => ref.current.blur()}>Blur</button>
          </div>
          <Fragment ref={ref}>{children}</Fragment>
        </>
      );
    }export default function App() {
      return (
        <Section title="Shipping">
          <fieldset>
            <legend>Address</legend>
            <label>Street <input name="street" /></label>{' '}
            <label>City <input name="city" /></label>
          </fieldset>
          <fieldset>
            <legend>Contact</legend>
            <label>Phone <input name="phone" /></label>
          </fieldset>
        </Section>
      );
    }
    

    焦点环使结果更加清晰可见:

    .row { display: flex; gap: 8px; align-items: center; margin-bottom: 10px; }
    fieldset { border-radius: 8px; margin: 0 0 8px; }
    input:focus { outline: 3px solid light-dark(#2563eb, #60a5fa); }
    

    尽管“第一个字段”位于 内的 中,但其焦点仍落在 Street 上;而“最后一个字段”的焦点则落在 Phone 上。这种深度优先搜索方式与会在第一层就停止处理的事件和观察者不同;下文中关于目标定位的部分会解释原因。

    在不破坏布局的情况下监控可见性

    observeUsing() 接受你自行创建的观察器,这样你就能掌控 root、threshold 和 rootMargin 的设置,同时该实例会将所有一级子元素都注册到观察器中。下一个示例会在滚动对话框中显示最新几条聊天消息时立即将其标记为已查看。如果在这些消息周围添加包装元素,就会打破面板内的弹性布局,因为每个消息气泡都是独立对齐的。

    导入的函数中包含 useLayoutEffect,这样观察器就能在绘制之前完成注册:

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

    ReadReceipt在可滚动区域创建一个阈值为0.6的IntersectionObserver,当有任何内容与该区域相交时调用onRead方法,最后通过unobserveUsing进行清理。App会渲染十四条消息,其中最后三条被包裹在ReadReceipt中:

    function ReadReceipt({ root, onRead, children }) {
      const ref = useRef(null);
      useLayoutEffect(() => {
        const instance = ref.current;
        const io = new IntersectionObserver(
          entries => { if (entries.some(e => e.isIntersecting)) onRead(); },
          { root: root.current, threshold: 0.6 }
        );
        instance.observeUsing(io);
        return () => instance.unobserveUsing(io);
      }, [root, onRead]);
      return <Fragment ref={ref}>{children}</Fragment>;
    }const MESSAGES = Array.from({ length: 14 }, (_, i) => `Message ${i + 1}`);export default function App() {
      const pane = useRef(null);
      const [read, setRead] = useState(false);
      return (
        <>
          <p className="status">{read ? '✅ Priya has read your last messages' : 'scroll the pane to the bottom…'}</p>
          <div className="pane" ref={pane}>
            {MESSAGES.slice(0, 11).map(m => <div key={m} className="bubble">{m}</div>)}
            <ReadReceipt root={pane} onRead={() => setRead(true)}>
              {MESSAGES.slice(11).map(m => <div key={m} className="bubble mine">{m}</div>)}
            </ReadReceipt>
          </div>
        </>
      );
    }
    

    CSS将该区域设置为可滚动的弹性列布局,并将用户自己的消息框对齐到右侧:

    .status { margin: 0 0 8px; font-weight: 600; }
    .pane { height: 190px; overflow: auto; display: flex; flex-direction: column; gap: 6px; padding: 8px; border-radius: 10px; background: light-dark(#f1f5f9, #262a33); }
    .bubble { align-self: flex-start; padding: 6px 10px; border-radius: 12px; background: light-dark(#fff, #363b47); }
    .mine { align-self: flex-end; background: light-dark(#dbeafe, #1e3a5f); }
    

    ResizeObserver的工作原理与之相同。如果你需要管理多个使用相同观察器选项的组,就无需为每个组单独设置观察器:每个一级子元素都包含一个reactFragments属性,该属性存储着它所属的片段实例的Set集合,这样单个观察器就可以为每个元素查找对应的组并调用相应的处理函数。

    测量一组兄弟姐妹元素

    getClientRects() 会为每个一级子元素返回一个矩形。将这些矩形组合起来即可得到整个组的边界框,你可以用它来绘制选择高亮或为多个无效字段加上括号,而无需改动这些子元素所在的布局。

    演示代码中导入了:

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

    Outline 在布局效果激活时会读取其中的矩形,计算出顶部和左侧的最小值以及底部和右侧的最大值,再加上滚动偏移量和少量内边距,然后将结果存储起来。之后它会在该片段旁边渲染一个绝对定位的轮廓线。App 则在同一网格中放置了两组各三个输入框,让用户选择要添加轮廓线的那组:

    function Outline({ active, children }) {
      const ref = useRef(null);
      const [box, setBox] = useState(null);
      useLayoutEffect(() => {
        if (!active) return setBox(null);
        const rects = ref.current.getClientRects();
        const top = Math.min(...rects.map(r => r.top)), left = Math.min(...rects.map(r => r.left));
        const bottom = Math.max(...rects.map(r => r.bottom)), right = Math.max(...rects.map(r => r.right));
        setBox({ top: top + scrollY - 6, left: left + scrollX - 6, width: right - left + 12, height: bottom - top + 12 });
      }, [active]);
      return (
        <>
          <Fragment ref={ref}>{children}</Fragment>
          {box && <div className="outline" style={box} />}
        </>
      );
    }export default function App() {
      const [which, setWhich] = useState('none');
      return (
        <>
          <div className="row">
            {['none', 'address', 'card'].map(w => (
              <label key={w}><input type="radio" checked={which === w} onChange={() => setWhich(w)} /> {w}</label>
            ))}
          </div>
          <div className="grid">
            <Outline active={which === 'address'}>
              <input placeholder="Street" /><input placeholder="City" /><input placeholder="PIN" />
            </Outline>
            <Outline active={which === 'card'}>
              <input placeholder="Card number" /><input placeholder="MM/YY" /><input placeholder="CVV" />
            </Outline>
          </div>
        </>
      );
    }
    

    该网格共有三列,轮廓元素通过pointer-events: none设置为绝对定位,因此不会拦截任何点击操作:

    .row { display: flex; gap: 14px; margin-bottom: 10px; }
    .grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; }
    .grid input { min-width: 0; }
    .outline { position: absolute; pointer-events: none; border: 2px dashed light-dark(#dc2626, #f87171); border-radius: 8px; }
    

    这里的每个输入元素都是同一CSS网格的直接子元素。如果将每组元素都包裹在<div>中,其三个输入元素就会被挤进同一个单元格;而使用片段则不会影响网格结构,每组元素仍可独立测量。该演示的一个局限性在于,只有当active属性发生变化时才会重新计算盒模型,因此窗口大小调整或滚动容器的移动会导致相关数据过时。通过observeUsing方法结合ResizeObserver即可解决这一问题。

    各方法所针对的子元素

    大多数方法操作的是 React 所谓的一级宿主子元素,即通过逐层遍历 React 组件最终得到的实际 DOM 元素。嵌套在这些元素内部的元素不会被直接处理。

    <Fragment ref={ref}>
      <div id="A" />
      <Wrapper>              {/* a component, not a DOM node: looked through */}
        <div id="B">
          <div id="C" />     {/* inside a DOM node: NOT targeted */}
        </div>
      </Wrapper>
      <div id="D" />
    </Fragment>
    // addEventListener / observeUsing / getClientRects → A, B, D
    // focus / focusLast → search A, B, C, D depth-first
    

    在這棵結構中,Wrapper 是一個組件,因此 React 會先遍历它,而 B 被視為一级元素。C 位於 DOM 元素 B 的內部,故不是直接處理對象。因此,監聽器、觀察者及矩形相關功能會作用在 A、B 和 D 上,而 focus 和 focusLast 則會以深度優先的順序搜索 A、B、C 和 D。

    这种差异是刻意设计的。由于事件会向上冒泡,C 内部的点击事件已经能够到达 B 上的监听器,而且 B 的容器及相关检测机制已经涵盖了 C。而焦点没有类似的冒泡机制,能够获得焦点的元素可能深埋在任意层级,因此这两种方法都需要进行搜索。

    它所替代的解决方案

    在 Fragment Refs 出现之前,有三种常见的处理方式,每种都有其代价:

    • 包装元素,即 <div ref={ref}><Thing /></div>:它在原本连续的兄弟元素位置插入一个新的 flex 或 grid 项目,会破坏 :first-child 和 :nth-child 选择器,还会增加一个布局容器,在表格行或列表项之间使用时是完全无效的。
  • 更改组件,使其转发其 ref 或接受 innerRef 属性:适用于自定义代码,但无法用于第三方包,且仍然只能生成一个节点。
  • 通过父级 ref 查询 DOM,例如在效应中使用 parent.querySelectorAll('button'):一旦效应执行后子元素发生变化,该方法就会失效,导致监听器集合过时。
  • 其实用处何在

    通过片段引用,你可以在保留第三方标记原样的同时为其添加行为逻辑。这便自然衍生出一组仅包含行为逻辑的组件:<Tracked>、<InView>、<FocusScope>、<Outline>。每个组件都由一个片段和一个效果组成,能够处理任意嵌套的子元素,且不会向 DOM 中添加任何内容。设计系统开发者能从中获得最大益处:一个Menu组件可以控制其使用者渲染的各个项目之间的焦点切换,而无需为这些项目强加特定结构。React 的更新日志将此称为可组合的平台行为,这一概念很便于理解:行为逻辑存在于片段中,而子元素则可以是任何形式。

    依赖该功能前需注意的陷阱

    • 引用必须使用完整形式;<>无法接受简写形式。
  • 在挂载之前,ref 的值为 null,因此每个效应都应首先检查该值。
  • 监听器、观察者以及测量方法仅查看一层 DOM 深度,而两种聚焦方法则会遍历整个子树。
  • scrollIntoView 只接受布尔值。传入 { behavior: 'smooth' } 会引发异常。使用 scrollIntoView() 可使第一个子元素对齐顶部,或使用 scrollIntoView(false) 使最后一个子元素对齐底部。
  • 仅包含文本的片段无法被观察,因为 observeUsing 是针对元素操作的;React 在开发模式下会对此发出警告。
  • 在隐藏的 <Activity> 树结构中,于隐藏状态下添加的监听器仅会在子树变得可见时才被绑定。
  • 版本 19.3 已修复监听器泄漏问题(#36047):捕获选项未按照 DOM 规范的要求进行标准化处理,导致使用 { capture: true } 注册监听器而仅用 true 取消注册时,监听器仍会保持激活状态。在依赖捕获阶段的监听器之前,请确保使用的是已修复版本的软件。
  • focus() 功能依赖于窗口拥有操作系统的焦点。React 通过等待元素的 focus 事件来确定该元素是否获得了焦点,而当浏览器窗口并非活动窗口时,Chrome 会跳过此步骤。在无头或后台运行的测试中,发现 focusLast() 会跳过 Phone 元素并将焦点留在 Street 元素上,因此在检查焦点所在位置之前,请确保测试页面确实已获得焦点。
  • FragmentInstance并非元素,因此不存在classList、style和innerHTML之类的属性;任何需要这些属性的操作仍需使用真正的DOM节点。
  • React 19.2及更早版本的替代方案

    对于监听器的情况,最合理的替代方法是通过父级ref实现事件委托。由于处理函数位于父节点上,并且会检查e.target.closest('button'),因此子元素的添加或移除无需重新绑定监听器:

    function Tracked({ children, onAny }) {
      const parent = useRef(null);
      useEffect(() => {
        const el = parent.current;
        const handler = (e) => { if (e.target.closest('button')) onAny(e); };
        el.addEventListener('click', handler);
        return () => el.removeEventListener('click', handler);
      }, [onAny]);
      return <div ref={parent} style={{ display: 'contents' }}>{children}</div>;
    }
    

    display: contents 样式会移除容器自身的边框,因此 flex 和 grid 容器会将子元素视为直接项。不过它仍然是一个元素:无法合法地置于 <table> 内部,屏幕阅读器等工具有时会对其处理不当,且要获取或观察子元素仍需使用 querySelectorAll。该样式适用于监听器和焦点控制,但观察和测量功能确实需要使用 19.3 版本。

    关键要点

    • 在显式的 <Fragment> 上使用 ref 可获得一个 FragmentInstance,它能无需额外的 DOM 节点即可对兄弟元素中的监听、焦点、观察、测量及滚动功能进行统一管理。
    • 当子元素被添加或移除时,监听器和观察器会保持同步,从而无需手动记录状态。
  • 大多数方法仅针对一级宿主节点的子元素;只有 focus 和 focusLast 会遍历整个子树,因为 focus 不会向上冒泡。
  • 需要注意一些细节:仅支持布尔值的 scrollIntoView,没有纯文本检测功能,测试中需要关注窗口焦点问题,以及 19.3 版本中的捕获监听器修复。
  • 诸如跟踪、可见性及焦点范围这类仅处理行为的封装组件,现已变成简单且可重复使用的组件。
  • 完整的函数列表及注意事项请参阅官方的 Fragment 文档;相关的拉取请求记录在项目的变更日志中。