Фрагменти-референси в React 19.3: поведінка DOM для груп братських елементів, без обгортки
Дізнайтеся, як референс у Fragment надає вам об’єкт FragmentInstance для слухачів, фокусу, спостерігачів та вимірювань між сусідніми елементами, а також у чому полягають його обмеження.
Більшість кодових баз React містять елемент <div>, який існує лише для того, щоб щось могло мати ref. Можливо, компонент, який потрібно було виміряти, мав кілька „братських“ елементів, або він походив з бібліотеки, яка ніколи не передавала свій ref. Цей додатковий елемент порушує сітчасту чи гнучку компоновку, заважає стилізації за допомогою :nth-child чи, у випадку елементів <tr>, створює недійсний HTML. У React 19.3 Reference для фрагментів стали стабільними: коли ви приєднуєте 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 — це компонент від стороннього виробника: він відображає прості кнопки без обгортки та без ref, а також необов’язкову четверту кнопку. Tracked обгортає свої дочірні елементи у Fragment з ref та приєднує обробник 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() у екземплярі фрагмента, а потім відображає його дочірні елементи всередині 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, хоча знаходиться всередині <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, додає контейнер для макетування та є повністю недійсним між рядками таблиці чи елементами списку.
innerRef: це працює для власного коду, але неможливо для пакетів, і все одно створюється лише один вузол.parent.querySelectorAll('button') всередині ефекту: це припиняє працювати як тільки дочірні елементи змінюються після виконання ефекту, що залишає застарілий набір слухачів.Чому це корисно
За допомогою Fragment Refs ви можете додати поведінку до маркапу третіх сторін, залишаючи цей маркап абсолютно таким, яким він є. Це робить природним набір компонентів, що містять лише поведінку: <Tracked>, <InView>, <FocusScope>, <Outline>. Кожен з них — це фрагмент разом із ефектом, він працює з довільними дочірніми елементами та нічого не додає до DOM. Найбільше виграють автори систем дизайну; компонент Menu може керувати фокусом серед елементів, які відображають його користувачі, не нав’язуючи їм певної структури. У хроніці змін React це описується як комбінована платформенна поведінка, що є корисною концепцією: поведінка знаходиться у фрагменті, а дочірні елементи можуть бути будь-якими, якими вони потребують.
Проблеми, які слід перевірити перед використанням
- Для ref потрібна повна форма;
<>її не прийматиме.
null, тому кожен ефект повинен спочатку перевіряти його значення.scrollIntoView приймає лише логічну величину. Передача { behavior: 'smooth' } спричиняє помилку. Використовуйте scrollIntoView(), щоб вирівняти першу дитину з верхньою частиною, або scrollIntoView(false), щоб вирівняти останню дитину з нижньою частиною.observeUsing спрямований на елементи; React попереджає про це під час розробки.<Activity> слухачі, додані під час прихованого стану, підключаються лише тоді, коли піддрів стає видимим.#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 приймає лише значення типу boolean, немає функцій для спостереження лише за текстом, у тестах використовується фокус вікна, а також існує виправлення з capture-listener у версії 19.3.Для повного списку методів та застережень дивіться офіційну документацію до Fragment; відповідні pull requests наведені у журналі змін проекту.