Главная / Статьи

Фрагменты-ссылки в React 19.3: поведение DOM для групп братьев-сестер, отсутствие обёртки

Узнайте, как референс в Fragment предоставляет объект FragmentInstance для слушателей, управления фокусом, наблюдателей и измерений между смежными элементами, а также в чём заключаются его ограничения.

Издатель: REACTAPP.TOP12 мин чтения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 ссылки на фрагменты стали стабильными: когда вы привязываете ref к <Fragment>, вы получаете объект FragmentInstance, который позволяет выполнять такие распространенные операции с DOM, как прослушивание событий, фокусировка, наблюдение, измерение и прокрутка для всех дочерних элементов фрагмента одновременно, при этом не добавляя ничего в отрисованную структуру. В этой статье рассматриваются API, практические подходы к использованию каждой функции, дочерние элементы, на которые влияют эти методы, а также ошибки, о которых следует знать перед выпуском продукта.

Поскольку эта функция является новой на момент написания текста, проверьте актуальную документацию к <Fragment> на наличие изменений в названиях методов или их поведении.

API FragmentInstance

В типичной конфигурации элемент 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(): возвращает массив DOMRect[] для детей первого уровня DOM.
  • scrollIntoView(alignToTop?): прокручивает на вид первого ребенка, если значение — true, или последнего, если значение — false; принимается только логическое значение, любой объект параметров вызывает исключение.
  • getRootNode() и compareDocumentPosition(node): описывают положение фрагмента согласно тем же правилам, что и методы Node.
  • Один обработчик для группы детей

    Частой ситуацией является наличие панели инструментов, отображаемой компонентом, который находится не под вашим контролем, и вам нужен один обработчик нажатий для всех её кнопок, например, для отправки данных в систему аналитики. В демо-примере используется:

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

    Toolbar представляет собой сторонний компонент: он отображает простые кнопки без оболочки и без ссылок на них, а также опциональную четвертую кнопку. Tracked оборачивает свои дочерние элементы в Fragment, к которому привязана ссылка, и добавляет обработчик 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() рассматривают элементы за пределами первого уровня. Они прослеживают каждый потомок в порядке глубинного поиска, пока не найдут элемент, на который можно перевести фокус, поэтому поле ввода, расположенное внутри тега label внутри fieldset, также доступно. Благодаря этому задача «перевести фокус на первое поле в этом разделе» остается простой даже для маркировки, которая не принадлежит вам.

    В демо-версии импортируются только Fragment и useRef:

    import { Fragment, useRef } from 'react';
    

    Section отображает заголовок и три кнопки, подключенные к методам focus(), focusLast() и blur() экземпляра фрагмента, затем отображает его дочерние элементы внутри фрагмента, на который указан ref. Класс 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, хотя оно находится внутри тега <label> внутри набора полей <fieldset>, а «Последнее поле» — на элемент Phone. Этот поиск в глубину отличается от обработки событий и наблюдателей, которые останавливаются на первом уровне; в разделе ниже объясняется причина этого.

    Отслеживание видимости без нарушения макета

    observeUsing() принимает наблюдателя, созданного вами самостоятельно, что позволяет сохранять контроль над параметрами root, threshold и rootMargin; кроме того, экземпляр регистрирует все дочерние элементы первого уровня. В следующем примере последние несколько сообщений в чате отмечаются как прочитанные сразу же, как только одно из них становится видимым в прокручиваемом окне общения. Элемент-обертка вокруг этих сообщений нарушил бы сетку Flex-макета, при которой каждое сообщение выравнивается независимо.

    В импортах используется функция useLayoutEffect, чтобы наблюдатель был зарегистрирован до отрисовки:

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

    ReadReceipt создает объект IntersectionObserver, настроенный на область прокрутки с порогом 0,6, вызывает метод 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 преобразует эту область в прокручиваемую колонку типа flex и выравнивает собственные сообщения пользователя справа:

    .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, поэтому изменение размеров окна или перемещение контейнера с прокруткой приводит к устаревшим данным. Эту проблему можно решить, сочетая его с объектом ResizeObserver через метод observeUsing.

    Какие элементы целится каждый метод

    Большинство методов работают с тем, что React называет детьми хоста первого уровня, то есть с элементами DOM, к которым можно добраться, проходя через компоненты React до самого фактического элемента. Элементы, находящиеся внутри них, не являются прямыми целями.

    <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: это работает для собственного кода, невозможно для пакетов, и при этом получается всего один узел.
  • Запрос DOM из ref родителя, например parent.querySelectorAll('button') внутри эффекта: это перестает работать сразу после изменения детей после выполнения эффекта, в результате чего остается устаревший набор слушателей.
  • Зачем это полезно

    С помощью Fragment Refs можно добавлять логику обработки маркапа сторонних разработчиков, сохраняя этот маркап в неизменном виде. Это позволяет естественным образом создавать семейство компонентов, содержащих исключительно логику: <Tracked>, <InView>, <FocusScope>, <Outline>. Каждый из них представляет собой фрагмент вместе с эффектом, работает с произвольными дочерними элементами и не добавляет ничего в DOM. Больше всего от этого выигрывают создатели систем дизайна: компонент Menu может управлять фокусом среди элементов, отрисовываемых его потребителями, не навязывая им определенной структуры. В чатлоге изменений React это описывается как композитное поведение платформы, что является удобной концепцией: логика находится в фрагменте, а дочерние элементы могут быть чем угодно.

    Проблемы, на которые следует обратить внимание перед использованием

    • Для ref необходим полный формат; <> его не примет.
  • До монтирования значение поля 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 и более ранних версий

    В случае использования обработчиков событий наиболее разумной заменой является делегирование событий от родительского элемента. Поскольку обработчик находится у родителя и проверяет 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.

    Основные выводы

    • Использование атрибута ref для явного элемента <Fragment> возвращает объект FragmentInstance, который объединяет обработку событий, управление фокусом, отслеживание изменений, измерения и прокрутку для всех дочерних элементов без создания дополнительных узлов DOM.
    • Обработчики событий и механизмы отслеживания остаются синхронизированными при добавлении и удалении дочерних элементов, что исключает необходимость в ручном учёте.
  • Большинство методов направлены на детей хоста первого уровня; только focus и focusLast сканируют всю ветвь дерева, поскольку focus не выполняет операцию «бульблинга».
  • Обратите внимание на нюансы: метод scrollIntoView принимает только логические значения, отсутствует возможность наблюдения только за текстом, в тестах используется фокус окна, а в версии 19.3 была внедрена коррекция с использованием слушателя capture-listener.
  • Обёртки, ограничивающиеся только поведением, такие как трекинг, контроль видимости и управление фокусом, превращаются в простые, повторно используемые компоненты.
  • Полный список методов и соответствующие ограничения можно найти в официальной документации Fragment; связанные пул-запросы указаны в чатлоге проекта.