Strona główna / Artykuły / Żywые kolekcje DOM, problemy z układem i wyjaśnienie luki w atrybutach

Żywые kolekcje DOM, problemy z układem i wyjaśnienie luki w atrybutach

Dowiedz się, dlaczego DOM jest żywym drzewem renderowanym w czasie rzeczywistym, jak to powoduje pomijanie elementów pętli oraz wymuszone układy, oraz dlaczego atrybuty danych niestandardowe wymagają metody getAttribute.

1407 słów

DOM ma złą sławę ze względu na wolne działanie, a typowe wyjaśnienie brzmi, że jest to po prostu złożona struktura. To wyjaśnienie jest w większości błędne. Operacje DOM są dość szybkie; problemem jest powszechny wzorzec programowania, który polega na wielokrotnym żądaniu informacji od DOM i poleceniu mu zmiany, w kółko wewnątrz pętli. Aby to zrozumieć, najpierw potrzebna jest dokładna wizja tego, czym jest DOM, a ta sama wizja wyjaśnia dwa inne zachowania, które regularnie mylą programistów: pętle pomijające elementy oraz własne atrybuty zwracające wartość undefined.

DOM to żywy drzewo, a nie kopia twojego HTML

Gdy przeglądarka parsuje HTML, nie przechowuje go jako tekstu. Tworzy obiekt dla każdego elementu i układa te obiekty zgodnie z strukturą markup, tworząc w ten sposób drzewo. Rozważmy mały katalog produktów:

<div id="catalog">
  <div class="product">
    <h3>Desk Lamp</h3>
    <span class="price">$34</span>
  </div>
  <div class="product">
    <h3>Standing Mat</h3>
    <span class="price">$58</span>
  </div>
</div>

Na podstawie tego przeglądarka buduje drzewo żywych obiektów, do których może uzyskać dostęp JavaScript: #catalog zawiera dwa elementy .product, a każdy z nich posiada element <h3> oraz <span>. Kluczowym punktem jest to, że drzewo to nie jest opis utworzony raz i następnie odsunięty na bok – to struktura, którą przeglądarka renderuje w tym momencie. Jeśli zmienisz właściwość jednego z tych obiektów węzłowych, strona to odzwierciedli; nie ma oddzielnego kroku zapisu ani wywołania renderowania.

document.querySelector(".product h3").textContent = "Desk Lamp (Sale)";

To zdanie nie jest prośbą, aby przeglądarka przetworzyła je później w twoim imieniu. Samo przypisanie stanowi zmianę. Pamiętanie o tej „żywej” naturze DOM jest najprzydatniejszym modelem myślowym, jaki możesz mieć, a jednocześnie jest to źródło błędu, który niemal każdy popełnia w pewnym momencie.

Dlaczego pętla nad żywą kolekcją pomija elementy

Załóżmy, że chcesz usunąć każdą kartę produktu oznaczoną jako niedostępną. Oczywista pętla wygląda tak:

const outOfStockCards = document.getElementsByClassName("out-of-stock");

for (let i = 0; i < outOfStockCards.length; i++) {
  outOfStockCards[i].remove();
}

Wydaje się poprawna, ale w tle pomija co drugi element. Powodem jest to, że getElementsByClassName nie zwraca stałego tablicy – zwraca żywą kolekcję HTMLCollection, która odzwierciedla treść dokumentu w miarę jej zmian. Gdy tylko .remove() usuwa pierwszą kartę, kolekcja traci jeden element, a wszystkie pozostałe karty przesuwają się o jeden indeks w dół. Licznik pętli nadal rośnie, więc element, który właśnie znalazł się na indeksie 0, nigdy nie jest odwiedzany. Rozmiar kolekcji zmienia się podczas wykonywania pętli, przez co mniej więcej połowa elementów umyka uwadze.

Rozwiązaniem jest utworzenie statycznej kopii przed rozpoczęciem modyfikacji:

const outOfStockCards = Array.from(document.getElementsByClassName("out-of-stock"));

for (const card of outOfStockCards) {
  card.remove();
}

Array.from tworzy kopię żywej kolekcji w postaci zwykłego tablicy, dzięki czemu późniejsze usuwanie elementów nie może zmniejszyć rozmiaru obiektu, nad którym iterujemy. Prostszą opcją jest document.querySelectorAll(".out-of-stock"), która od początku zwraca statyczną listę NodeList i nie wymaga żadnej konwersji. Ta przewidywalność jest jednym z powodów, dla których querySelectorAll w większości przypadków wyparł starsze interfejsy wyszukiwania z obecnych baz kodu. Jeśli musisz pracować z żywą kolekcją, iterowanie wstecz od ostatniego indeksu również unika takich problemów, chociaż statyczna kopia zwykle jest bardziej czytelna.

Problemy z układem: prawdziwy powód wolnego działania kodu DOM

Teraz do rzeczywistego problemu wydajności. Obliczanie układu, czyli dokładnych rozmiarów i pozycji każdego elementu, jest kosztowne. Przeglądarki unikają tego, jeśli to nie jest konieczne: gdy zmieniasz style, nie obliczają ponownie układu od razu. Zbierają wszystkie planowane zmiany i obliczają układ tylko raz, tuż przed narysowaniem następnego kadru.

Ta strategia działa tylko wtedy, gdy twój kod na to pozwala. Niektóre właściwości i metody, w tym offsetWidth, offsetHeight oraz getBoundingClientRect(), wymagają aktualnych danych geometrycznych, aby móc zwrócić wartość. Odczytanie ich zmusza przeglądarkę do synchronicznego obliczania układu, ponieważ nie może podać szerokości elementu bez jej obliczenia.

Poniższy pętla łączy odczyt i zapis dla każdej karty:

// forces a full layout recalculation on every single iteration
const cards = document.querySelectorAll(".product");
cards.forEach((card) => {
  const width = card.offsetWidth; // read: forces layout
  card.style.width = width + 10 + "px"; // write: invalidates layout again
});

Każda iteracja odczytuje szerokość, a następnie zapisuje nową wartość. Odczyt nie może być przeprowadzony na podstawie przestarzałych danych, ponieważ zmiana stylu zarejestrowana w poprzedniej iteracji może wpłynąć na wynik. Dlatego przeglądarka usuwa czekające zmiany, ponownie oblicza układ, zwraca liczbę, a zaraz potem kolejna linia ponownie unieważnia ten układ. Ten cykl nazywany jest problemem przetwarzania układu (lub przymusowym synchronicznym przetwarzaniem układu) i to właśnie doświadczają użytkownicy, gdy mówią, że DOM działa wolno. DOM wykonywać kosztowne operacje znacznie częściej, niż to konieczne, ponieważ kod ciągle tego wymaga.

Rozdziel pracę na dwa etapy: najpierw wszystkie odczyty, a potem wszystkie zapisy:

const cards = document.querySelectorAll(".product");
const widths = Array.from(cards).map((card) => card.offsetWidth); // all reads, together
cards.forEach((card, i) => {
  card.style.width = widths[i] + 10 + "px"; // all writes, together
});

Teraz układ jest obliczany tylko raz, gdy odczytuje się pierwszą szerokość, a pozostałe odczyty wykorzystują ten sam wynik, ponieważ nic nie zostało zmienione pomiędzy nimi. Zapisy są następnie układane w kolejce i przetwarzane razem przed kolejnym rysowaniem. DOM nie stał się szybszy; kod po prostu przestał zmuszać go do ponownego wykonywania tych samych obliczeń przy każdej iteracji.

Z tego wynikają kilka praktycznych uwag:

  • Ta sama zasada dotyczy innych odczytów geometrycznych, takich jak clientWidth, scrollTop oraz getComputedStyle().
  • W większych aplikacjach odczyty i zapisy często odbywają się w różnych funkcjach lub komponentach, więc problemy mogą występować nawet wtedy, gdy żadna pojedyncza pętla nie wydaje się podejrzana. Narzędzia do analizy wydajności przeglądarki wyróżniają przymusowe aktualizacje układu, co ułatwia ich wykrycie.
  • Zaplanowywanie zapisów za pomocą requestAnimationFrame to powszechny sposób na grupowanie ich tuż przed renderowaniem.
  • Właściwości i atrybuty to nie zawsze to samo

    Ostatnie zaskoczenie pochodzi ze związku pomiędzy atrybutami HTML a właściwościami JavaScript. Zazwyczaj odzwierciedlają one się nawzajem, ale to odzwierciedlanie ma swoje ograniczenia, które stają się istotne, gdy dodaje się dane niestandardowe. Weźmy ten element:

    <div class="product" data-sku="LAMP-2201"></div>
    

    A oto kod, który odczytuje z niego dane na trzy różne sposoby:

    const el = document.querySelector(".product");
    console.log(el.className); // "product" — standard attributes map to properties directly
    console.log(el.sku);       // undefined — custom attributes don't
    console.log(el.getAttribute("data-sku")); // "LAMP-2201" — this is how you actually reach it
    

    Standardowe atrybuty znane przeglądarce, takie jak href, src i class, są automatycznie udostępniane jako odpowiadające im właściwości. class pojawia się jako className, ponieważ class był słowem rezerwowanym w JavaScript. Atrybuty niestandardowe nie otrzymują takiego traktowania, w tym te z prefiksem data-, który jest standardowym mechanizmem do przechowywania własnych metadanych na elementach. Nie istnieje właściwość el.sku, więc wyszukiwanie zwraca undefined, a do odczytu lub zmiany wartości należy użyć getAttribute i setAttribute. Dla dodatkowej wygody przeglądarki udostępniają również atrybuty data- poprzez obiekt dataset, więc el.dataset.sku zwraca tę samą wartość. Ta niespójność jest niewielka, ale to właśnie ona powoduje problemy przy pracy z takimi elementami.

    ode>undefined – to pierwszy przypadek, gdy oczekujesz, że własny atrybut będzie zachowywał się jak href.

    Jeden model mentalny zamiast trzech pułapek

    To zachowania nie są odrębnymi drobiazgami. Wszystkie wynikają z jednego faktu: DOM to żywa struktura, która jest renderowana w czasie rzeczywistym, a nie nieruchome dane, które wprowadzasz tylko raz.

    • Kolekcje żywe zmieniają się podczas przeglądania ich w pętli, więc zrób ich kopię lub użyj querySelectorAll przed jakąkolwiek modyfikacją.
    • Czytanie informacji geometrycznych zmusza przeglądarkę do natychmiastowego obliczenia układu, więc należy najpierw przeczytać dane, a dopiero potem je zapisać.
    • Atrybuty w JavaScript są dostępne dla wygody, ale nie odzwierciedlają wszystkich możliwości DOM, więc do dostępu do własnych danych należy używać getAttribute lub dataset.

    Gdy potraktujesz DOM jako żywy drzewo, a nie powolną strukturę danych, takie zjawiska przestaną być zaskoczeniami i staną się przewidywalnymi konsekwencjami. Aby uzyskać szerszy obraz tego, jak w kontekście frameworka od zmian stanu dochodzi do tworzenia pikseli, zapoznaj się z tym, jak React przekształca aktualizacje stanu w piksele na ekranie.

    Literatura pokrewna

  • React 19.2 wyjaśnione: Activity, useEffectEvent i statyczne renderowanie — Dowiedz się, jak nowy komponent Activity, hook useEffectEvent oraz częściowe statyczne renderowanie w React 19.2 eliminują ukryte koszty wydajności w nowoczesnych interfejsach użytkownika.