Fragment referencji w React 19.3: Zachowanie DOM dla grup elementów rodzinnych, brak otoczki
Dowiedz się, w jaki sposób referencja do Fragmentu dostarcza obiekt FragmentInstance dla słuchaczy, mechanizmów skupienia uwagi, obserwatorów oraz funkcji pomiarowych w elementach powiązanych, a także jakie są jego ograniczenia.
Większość baz kodu React zawiera element <div>, który istnieje wyłącznie po to, aby coś mogło przechowywać ref. Być może komponent, który chciałeś zmierzyć, renderował kilka elementów rodzinnych, albo pochodził z biblioteki, która nigdy nie przekazywała swojego ref. Ten dodatkowy element zakłóca wtedy układ grid lub flex, psuje stylizację za pomocą :nth-child lub, w przypadku elementów <tr>, tworzy nieważny HTML. React 19.3 uznaje Fragment Refs za stabilne: gdy przypiszesz ref do <Fragment>, otrzymasz obiekt FragmentInstance, który umożliwia wykonywanie standardowych operacji DOM, takich jak słuchanie zdarzeń, skupianie uwagi, obserwowanie zmian, mierzenie i przewijanie wszystkich dzieci fragmentu jednocześnie, bez dodawania niczego do renderowanego drzewa. Ten artykuł omawia API, praktyczne wzorce dla każdej z tych funkcji, które elementy są docelowe dla poszczególnych metod oraz pułapki, których należy unikać przed publikacją oprogramowania.
Ponieważ ta funkcjonalność jest nowa w momencie pisania tego tekstu, sprawdź aktualną referencję <Fragment> w celu wykrycia ewentualnych zmian w nazwach metod lub zachowaniu.
API FragmentInstance
Zwykłe ustawienie polega na wyraźnym importowaniu Fragment, ponieważ referencja istnieje tylko po zamontowaniu fragmentu:
import { Fragment, useRef, useEffect } from 'react';
Referencja początkowo ma wartość null i staje się FragmentInstance, gdy fragment zostanie zamontowany. Poniższy kod chroni przed wartością null, dodaje słuchacz i usuwa go podczas czyszczenia:
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>;
}
Wymagany jest wyraźny element <Fragment>. Skrót <>…</> nie akceptuje ani ref, ani key. Oto, co dostarcza instancja:
addEventListener(type, fn, opts?)orazremoveEventListener: działają na dzieciach DOM pierwszego poziomu, a zarejestrowane słuchacze są przenoszone na dzieci, które pojawiają się lub znikają później.dispatchEvent(event): wysyła zdarzenie bezpośrednio do samego fragmentu; przy użyciububbles: truezdarzenie propaguje się do węzła DOM rodzica.focus(opts?)orazfocusLast(opts?): przeszukują wszystkie zagnieżdżone potomki w głębi pierwszej, aby znaleźć pierwszy lub ostatni element nadający się do skupienia uwagi, i przyjmują wartość{ preventScroll }.blur(): zamazuje aktywny element, jeśli znajduje się wewnątrz fragmentu, w przeciwnym razie nic nie robi.
observeUsing(observer) i unobserveUsing(observer): rejestruje dzieci pierwszego poziomu DOM z użyciem IntersectionObserver lub ResizeObserver; węzły tekstowe nie są uwzględniane.getClientRects(): zwraca prosty tablicę DOMRect[] dla dzieci pierwszego poziomu DOM.scrollIntoView(alignToTop?): przewija pierwsze dziecko do widoku, jeśli wartość to true, lub ostatnie, jeśli false; przyjmowany jest tylko wartość logiczna, a obiekt opcji powoduje błąd.getRootNode() i compareDocumentPosition(node): opisują pozycję fragmentu zgodnie z tymi samymi zasadami co metody Node.Jeden słuchacz dla grupy dzieci
Częstym scenariuszem jest pasek narzędzi renderowany przez komponent, nad którym nie mamy kontroli, a w takim przypadku chcemy mieć jeden słuchacz kliknięć dla wszystkich jego przycisków, na przykład w celu przekazywania danych do analiz. Demo importuje:
import { Fragment, useRef, useEffect, useState } from 'react';
Toolbar reprezentuje komponent od strony trzeciej: renderuje proste przyciski bez otoczenia i bez ref, a także opcjonalny czwarty przycisk. Tracked otacza swoje dzieci refowanym Fragmentem i przypina obsługę onAny w efekcie, ponownie ją przypinając, gdy zmienia się obsługa. App zapisuje tekst każdego klikniętego przycisku oraz oferuje pole wyboru, które dodaje ten dodatkowy przycisk:
// 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>
</>
);
}
Style służą jedynie do układu paska narzędzi i linii z logiem:
.bar { display: flex; gap: 8px; margin-bottom: 10px; }
.log { margin: 10px 0 0; font-family: ui-monospace, monospace; opacity: .8; }
Włącz czwarty przycisk i kliknij go – jego działanie zostanie zapisane tak samo jak pozostałych. Instancja fragmentu śledzi dzieci elementów podczas montowania i demontowania w React oraz aktualizuje odpowiednio swoje słuchacze, co jest dokładnie tym, co musiałbyś sam wykonać w inny sposób. Zauważ, że w demonstracji onAny jest funkcją strzałkową typu inline, więc efekt ten ponownie się rejestruje przy każdym renderowaniu App; w prawdziwym kodzie należy go ustabilizować za pomocą useCallback, jeśli ta częsta aktualizacja ma znaczenie.
Przenoszenie fokusa wewnątrz zagnieżdżonego kontentu
Tylko focus() i focusLast() sprawdzają elementy poza pierwszym poziomem. Przechodzą przez wszystkie potomne elementy w kolejności głębokościowej, aż znajdą element nadający się do skupienia, więc pole wprowadzania umieszczone wewnątrz etykiety, a ta z kolei wewnątrz fieldsetu, jest nadal dostępne. Dzięki temu „skupienie się na pierwszym polu w tej sekcji” jest proste nawet w przypadku markupu, który nie należy do nas.
Demo importuje tylko Fragment i useRef:
import { Fragment, useRef } from 'react';
Section wyświetla tytuł oraz trzy przyciski podłączone do metod focus(), focusLast() i blur() na instancji fragmentu, a następnie wyświetla jego dzieci wewnątrz referencjonowanego Fragment. App przekazuje dwa zestawy pól zawierające pola wprowadzania z etykietami:
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>
);
}
Obwód skupienia ułatwia zobaczenie wyniku:
.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); }
„Pierwsze pole” odpowiada polu Street, mimo że znajduje się wewnątrz <label> w ramach <fieldset>, natomiast „Ostatnie pole” odpowiada polu Phone. To poszukiwanie w głębi różni się od zdarzeń i obserwatorów, które zatrzymują się na pierwszym poziomie; sekcja dotycząca celowania poniżej wyjaśnia dlaczego.
Nadzór nad widocznością bez zakłócania układu
observeUsing() przyjmuje obserwatora stworzonego przez Ciebie, dzięki czemu zachowujesz kontrolę nad root, threshold i rootMargin, a instancja rejestruje z nim każde dziecko pierwszego poziomu. W następnym przykładzie najnowsze wiadomości z czatu są oznaczane jako przeczytane, gdy tylko jedna z nich staje się widoczna w przewijanym oknie rozmowy. Element otaczający te wiadomości zakłóciłby układ typu flex okna, w którym każda bańka jest wyśrodkowana niezależnie.
W imporcie znajduje się również useLayoutEffect, aby obserwator został zarejestrowany przed procesem rysowania:
import { Fragment, useRef, useLayoutEffect, useState } from 'react';
ReadReceipt tworzy obserwatora IntersectionObserver umieszczonego w obszarze przewijania z progiem 0,6, wywołuje metodę onRead, gdy jakikolwiek element się z nim przecina, a następnie usuwa obserwację za pomocą unobserveUsing. App wyświetla czternaście wiadomości, przy czym ostatnie trzy są umieszczone wewnątrz 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>
</>
);
}
Kod CSS przekształca ten obszar w kolumnę typu flex, którą można przewijać, oraz wyśrodkowuje własne elementy użytkownika po prawej stronie:
.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 działa w identyczny sposób. Jeśli zarządzasz wieloma grupami, które dzielą te same opcje obserwatora, nie musisz mieć osobnego obserwatora dla każdej grupy: każde dziecko pierwszego poziomu posiada właściwość reactFragments, która zawiera zbiór Set instancji fragmentów, do których należy, co umożliwia pojedynczemu obserwatorowi znalezienie odpowiedniej grupy dla każdego elementu i wywołanie odpowiedniego obsługiwacza.
Pomiar grupy rodzeństwa
getClientRects() zwraca jeden prostokąt na każde dziecko pierwszego poziomu. Po ich połączeniu otrzymuje się prostokąt ograniczający całą grupę, który można wykorzystać do podświetlenia wybranej części lub otoczenia kilku nieprawidłowych pól, bez ingerencji w układ, w którym znajdują się te elementy.
Demo importuje:
import { Fragment, useRef, useState, useLayoutEffect } from 'react';
Outline odczytuje prostokąty w efekcie układu po jego aktywacji, oblicza minimalne wartości dla górnej i lewej krawędzi oraz maksymalne wartości dla dolnej i prawej krawędzi, dodaje odchylenia przewijania oraz niewielką marginesową przestrzeń, a następnie przechowuje wynik. Następnie renderuje obrys umieszczony w pozycji absolutnej obok fragmentu. App umieszcza dwie grupy po trzy pola wejściowe w tej samej siatce i umożliwia wybór, które z nich mają być oznaczone obrysem:
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>
</>
);
}
Sieć ma trzy kolumny, a obrys jest umieszczony w pozycji absolutnej z ustawieniem pointer-events: none, dzięki czemu nigdy nie przechwytuje kliknięć:
.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; }
Każde pole wprowadzania tutaj jest bezpośrednim dzieckiem tej samej sieci CSS. Umieszczenie każdej grupy w <div> spowodowałoby zmieszczenie jej trzech pól wprowadzania w jednej komórce; przy użyciu fragmentu sieć pozostaje nietknięta, a każdą grupę nadal można zmierzyć. Jedną z ograniczeń demonstracji jest to, że obszar ten jest obliczany tylko wtedy, gdy zmienia się wartość active, więc zmiana rozmiaru okna lub ruch kontenera przewijania sprawia, że dane stają się przestarzałe. Rozwiązaniem jest połączenie tego z obserwatorem ResizeObserver za pomocą metody observeUsing.
Które elementy są docelowe dla każdej metody
Większość metod działa na tym, co React nazywa dziećmi gospodarza pierwszego poziomu, czyli elementach DOM, do których dochodzi się poprzez przechodzenie przez komponenty React aż do rzeczywistego elementu. Elementy znajdujące się wewnątrz nich nie są adresowane bezpośrednio.
<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
W tym drzewie Wrapper jest komponentem, więc React przegląda go, a B zalicza się do poziomu pierwszego. C znajduje się wewnątrz elementu DOM B, więc nie jest bezpośrednim celem. Dlatego słuchacze, obserwatory i elementy typu rects odnoszą się do A, B i D, natomiast funkcje focus i focusLast przeszukują A, B, C i D w kolejności głębokościowej.
Różnica jest celowa. Ponieważ zdarzenia rozprzestrzeniają się w górę, kliknięcia wewnątrz C już docierają do słuchacza w B, a obszar i obserwacje B obejmują już C. Fokus nie ma odpowiednika w tym mechanizmie rozprzestrzeniania, a element, który może przyjąć fokus, może znajdować się na dowolnej głębokości, dlatego te dwa metody muszą szukać.
Rozwiązania zastępujące to podejście
Przed Fragment Refs istniały trzy powszechne metody, z których każda miała swoje wady:
- Element otaczający,
<div ref={ref}><Thing /></div>: wstawia nowy element typu flex lub grid tam, gdzie wcześniej znajdowała się seria elementów rodzinnych, zakłóca selektory:first-childi:nth-child, dodaje obszar układu, a ponadto jest całkowicie nieważny pomiędzy wierszami tabeli lub elementami listy.
innerRef: działa w przypadku własnego kodu, jest niemożliwa dla pakietów i nadal daje tylko jeden węzeł.parent.querySelectorAll('button') wewnątrz efektu: przestaje działać, gdy tylko dzieci ulegają zmianie po uruchomieniu efektu, co powoduje pozostanie starych słuchaczy.Dlaczego to jest przydatne
Dzięki Fragment Refs można dodać funkcjonalność do znaczników od firm trzecich, pozostawiając te znaczniki dokładnie takie, jakie są. To ułatwia tworzenie serii komponentów składających się wyłącznie z funkcjonalności: <Tracked>, <InView>, <FocusScope>, <Outline>. Każdy z nich to fragment w połączeniu z efektem, działa z dowolnymi dziećmi i nie dodaje nic do DOM-u. Najwięcej korzyści od tego mają twórcy systemów projektowych – komponent Menu może kontrolować fokus pomiędzy elementami renderowanymi przez jego użytkowników, bez narzucania im określonej struktury. W dzienniku zmian React opisuje to jako komponowalną funkcjonalność platformy, co stanowi przydatny model myślowy: fragment to miejsce, gdzie znajduje się funkcjonalność, a dzieci mogą być czymkolwiek, czym muszą być.
Potencjalne problemy do sprawdzenia przed użyciem
- Dla ref potrzebna jest forma rozszerzona;
<>jej nie przyjmie.
null, więc każdy efekt powinien najpierw to sprawdzić.scrollIntoView przyjmuje tylko wartość logiczną. Przekazanie { behavior: 'smooth' } powoduje błąd. Użyj scrollIntoView(), aby wyrównać pierwsze dziecko z górną granicą, lub scrollIntoView(false), aby wyrównać ostatnie dziecko z dolną granicą.observeUsing adresuje elementy; React ostrzega o tym podczas rozwoju aplikacji.<Activity> słuchacze dodane w stanie ukrycia są przyłączane dopiero wtedy, gdy poddrzewo staje się widoczne.#36047): opcje przechwytywania nie były normalizowane zgodnie z wymaganiami specyfikacji DOM, co oznaczało, że rejestracja za pomocą { capture: true } oraz odrejestrowanie przy użyciu zwykłego true pozostawiały słuchacza przyłączonego. Upewnij się, że używasz ustalonej wersji, zanim będziesz polegać na słuchaczach fazy przechwytywania.focus() wymaga, aby okno miało fokus w systemie operacyjnym. React potwierdza, że element przyjmuje fokus poprzez oczekiwanie na zdarzenie focus, którego Chrome pomija, gdy okno przeglądarki nie jest aktywne. Podczas testów bez interfejsu graficznego lub w tle zaobserwowano, że focusLast() przechodzi nad elementem Phone i pozostawia fokus na elemencie Street, więc upewnij się, że strona testowa rzeczywiście ma fokus, zanim sprawdzisz, gdzie on się znajduje.FragmentInstance nie jest elementem, więc takie właściwości jak classList, style i innerHTML nie istnieją w nim; wszystko, co je wymaga, nadal potrzebuje rzeczywistego węzła DOM.Rozwiązanie awaryjne dla React 19.2 i wcześniejszych wersji
W przypadku słuchaczy najbardziej rozsądną alternatywą jest delegacja zdarzeń od referencji rodzica. Ponieważ obsługa zdarzenia znajduje się w elemencie rodzicowym i sprawdza e.target.closest('button'), dzieci mogą się pojawiać i znikać bez konieczności ponownego przypinania ich do rodzica.
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>;
}
Styl display: contents usuwa własną ramkę otaczającego elementu, dzięki czemu rodzice typu flex i grid traktują jego dzieci jako bezpośrednie elementy. Mimo to nadal jest to element: nie może legalnie znajdować się wewnątrz <table>, czytniki ekranu oraz podobne narzędzia czasami traktują go błędnie, a pomiary lub obserwacja dzieci nadal wymagają użycia querySelectorAll. Nadaje się do obsługi słuchaczy i skupienia uwagi, ale w przypadkach obserwacji i pomiarów naprawdę potrzebny jest standard 19.3.
Główne wnioski
refna wyraźnym<Fragment>zwraca obiektFragmentInstance, który łączy funkcje słuchania, skupienia uwagi, obserwacji, pomiarów oraz przewijania pomiędzy elementami bez dodatkowych węzłów DOM.- Słuchacze i obserwatory pozostają zsynchronizowane podczas dodawania i usuwania dzieci, co eliminuje konieczność ręcznego prowadzenia rejestrów.
focus i focusLast przeszukują całe poddrzewo, ponieważ mechanizm focus nie przenosi się w górę drzewa.scrollIntoView obsługuje wyłącznie wartości logiczne, brakuje obserwacji tekstu, w testach uwzględniany jest stan fokusa okna oraz wprowadzono poprawkę capture-listener w wersji 19.3.Aby zapoznać się ze pełną listą metod oraz uwagami, sprawdź oficjalną dokumentację Fragment; powiązane prośby o zmiany znajdują się w dzienniku zmian projektu.
Powiązane materiały
- Odprowadzanie wrappera Providera: Kontekst renderowania z komponentów serwerowych — Dowiedz się, jak React 19.3 umożliwia komponentom serwerowym bezpośrednie renderowanie kontekstu klienta, jak Flight go serializuje oraz jakie ograniczenia dotyczące odczytu i wartości nadal obowiązują.
- React 19.3 poza hasełkami: submitter, właściwości Fullscreen i rozmiar pakietu — Praktyczny przegląd mniejszych zmian w React 19.3: właściwość submitter, właściwości zdarzenia fullscreen, równoległe przejścia, funkcja onReset, istotne poprawki błędów oraz rzeczywisty wzrost rozmiaru.