Dlaczego duże drzewa JSON się zacinają: wirtualne przewijanie i strategie zwijania
Podsumowanie: Zacięcia zaczynają się przy tworzeniu węzłów DOM, a nie przy przewijaniu. Omawia renderowanie ze zwijaniem, listy wirtualne, podświetlanie wyszukiwania i granice Workera, wraz z metrykami.

Najważniejsze: rozbij „wolne” na parsowanie, tworzenie węzłów, układ i malowanie oraz interakcję, a następnie zmierz każdy etap. Wdrażaj w tej kolejności — domyślnie zwijaj, dodaj listę wirtualną, przenieś obliczenia do Workera i ogranicz podświetlanie do widocznych wierszy. Skok prosto do listy wirtualnej bez zwijania tylko zmniejsza widoczny zbiór; pełne przeliczanie drzewa nadal cię kosztuje.
Zacięcie zwykle pochodzi z setek tysięcy węzłów DOM, które utworzyłeś dla tych 5 MB, a nie z samych 5 MB.
1. Rozbij „wolne” na cztery etapy
Gdy użytkownik mówi „zawiesza się przy otwieraniu”, problem może żyć w czterech zupełnie różnych etapach, każdy z innym lekarstwem:
| Etap | Typowy wyzwalacz | Próg odczuwalności | Dominujący koszt |
|---|---|---|---|
| Parsowanie i budowa obiektów | Otwarcie pliku, wklejenie tekstu | Każde zadanie ponad 50 ms | Skanowanie łańcuchów, budowa grafu obiektów |
| Tworzenie węzłów DOM | Renderowanie całego drzewa naraz | Dziesiątki tysięcy węzłów | Alokacja elementów, węzłów tekstowych, obiektów stylu |
| Układ i malowanie | Pierwsze wyświetlenie, przewijanie | Każda klatka ponad 16,7 ms | Geometria, reflow, repaint |
| Odpowiedź interakcji | Rozwijanie, zwijanie, wyszukiwanie | Ponad 100 ms odczuwane jako opóźnienie | Ponowne spłaszczanie i re-renderowanie |
Wątek główny przeglądarki robi jedną rzecz naraz. Jeśli dowolny etap zajmuje go ponad 50 ms, przewijanie i wpisywanie gubią klatki; kilka takich zadań z rzędu i strona wygląda na martwą. Pierwszą optymalizacją nie jest więc requestAnimationFrame; jest nią zmierzenie, który etap faktycznie zużywa czas.
2. Liczba węzłów jest prawdziwym wrogiem
Przeglądarki drzewa z założenia mnożą liczbę węzłów. Pojedyncza linia JSON zwykle staje się „wcięcie + klucz + dwukropek + wartość + przecinek + szewron”, więc ostrożny szacunek to 3 do 6 węzłów DOM per linia:
| Rozmiar pliku | Przybliżona liczba linii | Węzły DOM widoku drzewa (szacunek) | Jak wygląda renderowanie za jednym razem |
|---|---|---|---|
| 100 KB | 2 500 | 10 000 | Zwykle znośne |
| 1 MB | 25 000 | 100 000 | Wyraźnie zacinające, możliwe sekundy pustego ekranu |
| 5 MB | 125 000 | 500 000 | Bardzo prawdopodobnie brak responsywności |
| 20 MB | 500 000 | Ponad 2 miliony | Zapada się zarówno pamięć, jak i układ |
Każdy element to więcej niż kilkadziesiąt bajtów obiektu: niesie reguły stylu, box układu i instrukcje malowania. Po utworzeniu węzeł nadal kosztuje pracę układu i stylu, nawet jeśli zostanie później ukryty przez zwiniętego rodzica, dopóki nie zostanie faktycznie usunięty. „Renderuj wszystko, potem ukryj” nie jest optymalizacją; tylko odkłada koszt do następnego reflow.
3. Najpierw zwijanie, potem wirtualizacja
Wirtualne przewijanie rozwiązuje „zbyt wiele widocznych wierszy”. Zwijanie rozwiązuje „w ogóle nie powinniśmy potrzebować tylu widocznych wierszy”. Kolejność ma znaczenie: dla milionlinijkowego dokumentu domyślnie rozwiniętego nawet idealna lista wirtualna musi przeliczyć całe drzewo, gdy użytkownik kliknie „zwiń wszystko”.
Wykonalna strategia zwijania:
- Domyślnie rozwijaj tylko dwa pierwsze poziomy. Korzeń i jego bezpośrednie pola są widoczne; głębsze poziomy pokazują streszczenie, takie jak
items: Array(12000); - Umieść typ i liczbę w streszczeniu. Użytkownicy sprawdzający „czy nazwy pól są poprawne” rzadko potrzebują dziesięciu tysięcy rekordów;
- Cache’uj wiersze per poddrzewo. Każdy rozwinięty węzeł posiada swoją spłaszczoną tablicę wierszy; zwijanie ją odrzuca, a rozwijanie przelicza tylko to poddrzewo;
- Nigdy nie skanuj całego drzewa przy każdym przełączeniu. Jeśli rozwinięcie następuje w wierszu 3 000, zastąp tylko ten zakres i zachowaj otaczające wiersze nienaruszone.
/**
* Insert a subtree's rows after its parent instead of re-flattening the whole tree.
* @param rows Current visible row array
* @param parentIndex Index of the parent row
* @param childRows Flattened rows of the subtree
* @returns A new visible row array
*/
function replaceSubtreeRows(
rows: TreeRow[],
parentIndex: number,
childRows: TreeRow[],
): TreeRow[] {
const nextRows = rows.slice();
nextRows.splice(parentIndex + 1, 0, ...childRows);
return nextRows;
}
Wzorzec, którego należy unikać, to „jedno rozwijanie, jeden pełny relayout”. Przechodzenie całego grafu obiektów przy każdym kliknięciu sprawia, że koszt rośnie liniowo z dokumentem. Przy cache per poddrzewo pojedyncze rozwijanie kosztuje tylko tyle, ile to poddrzewo.
4. Listy wirtualne: renderuj tylko widoczny obszar
Gdy zwijanie zmniejszyło liczbę wierszy, reszta nadal może liczyć dziesiątki tysięcy. Lista wirtualna ustawia wysokość kontenera na „liczba wierszy x stała wysokość wiersza” i tworzy elementy tylko dla widocznego obszaru plus mały bufor powyżej i poniżej.
const ROW_HEIGHT = 24;
const OVERSCAN = 8;
/**
* Compute the row range that the viewport should render.
* @param rows All visible rows
* @param scrollTop Current scroll offset of the container
* @param viewportHeight Visible height of the container
* @returns Rows to render, the start index, and the vertical offset
*/
function getViewportRows(
rows: TreeRow[],
scrollTop: number,
viewportHeight: number,
): { visibleRows: TreeRow[]; startIndex: number; offsetTop: number } {
const start = Math.max(0, Math.floor(scrollTop / ROW_HEIGHT) - OVERSCAN);
const end = Math.min(
rows.length,
Math.ceil((scrollTop + viewportHeight) / ROW_HEIGHT) + OVERSCAN,
);
const visibleNodes = rows.slice(start, end);
return {
visibleRows: visibleNodes,
startIndex: start,
offsetTop: start * ROW_HEIGHT,
};
}
Nienegocjowalne ograniczenia:
- Wysokość wiersza musi być stała. Tylko wtedy
scrollTop / ROW_HEIGHTdaje indeks bezpośrednio. Dopuszczenie zawijanych wierszy wymusza mierzenie i cache’owanie każdej wysokości, co dodaje rząd wielkości złożoności i drżenia; - Przesuwaj przez
transformlub pozycjonowanie absolutne. Nie popychaj treści za pomocą elementu odstępu, bo każde przewinięcie mutuje układ; - Używaj stabilnych kluczy. Kluczuj wiersze po ścieżce, a nie po indeksie tablicy, inaczej rozwijanie jednego wiersza remontuje każdy wiersz za nim;
- Wykonuj arytmetykę tylko w handlerze przewijania. Odrocz renderowanie do
requestAnimationFramei chroń się przed duplikatami harmonogramu; - Trzymaj bufor mały. Pięć do dziesięciu wierszy po każdej stronie wystarczy; podwojenie bufora podwaja węzły DOM i pracę stylu.
Przy 800-pikselowym widocznym obszarze i 24-pikselowych wierszach widocznych jest około 34 wierszy, a z buforem renderujesz około pięćdziesięciu. Dokument pod spodem może trzymać sto tysięcy wierszy, podczas gdy DOM konsekwentnie trzyma kilkaset elementów, utrzymując koszt układu płaskim.
5. Zwijanie, rozwijanie i zakotwiczenie przewijania
Gdy indeks wiersza staje się jedyną współrzędną, kilka szczegółów interakcji zaczyna się psuć:
- Zachowaj kotwicę wizualną przy zwijaniu powyżej widocznego obszaru. Wiersze poniżej przesuwają się w górę; jeśli
scrollTopnie zostanie dostosowany w tym samym commicie, treść wydaje się skakać. Zapisz ścieżkę wiersza kotwicy i przywróć pozycję przewijania, używając jej nowego indeksu. - Rozwijaj przecinków przed przeskokiem. Gdy wyszukiwanie trafi głębokie pole, najpierw rozwiń każdego rodzica, potem oblicz indeks docelowego wiersza, a następnie przewiń i podświetl. Pomiń pierwszy etap, a docelowy wiersz nawet nie znajdzie się w widocznej tablicy.
- Napędzaj nawigację klawiaturą z widocznych wierszy. W górę i w dół przesuwają indeks wiersza; w lewo i w prawo rozwijają i zwijają. Nigdy nie przechodź całego drzewa w handlerze klawisza.
- Partiami aktualizuj w jednej klatce. Rozwijając kilka poziomów z rzędu, scal zmiany tablicy i renderuj raz, inaczej każdy poziom wyzwala pełny reflow.
6. Wyszukiwanie i podświetlanie: policz raz, pokaż trochę
Wyszukiwanie to najczęściej przeoczana pułapka wydajnościowa w przeglądarce drzewa. Jeśli każde naciśnięcie klawisza przechodzi cały graf obiektów i odbudowuje listę wyników, dokument o stu tysiącach węzłów natychmiast się zawiesza.
Podejście warstwowe:
- Debounce wejścia 150–250 ms i oznacz każde wyszukiwanie numerem sekwencji, aby odrzucać nieaktualne wyniki;
- Skanuj w Workerze. Przechodzenie grafu obiektów to czyste obliczenie bez zależności od DOM, więc nie powinno blokować wpisywania;
- Przechowuj tylko ścieżki i indeksy wierszy. Nie kopiuj każdego dopasowanego węzła ani nie buduj wcześniej podświetlonego HTML-a;
- Podświetlaj tylko widoczne wiersze. Widoczny obszar trzyma kilkadziesiąt wierszy; stosuj style według dopasowanej ścieżki w czasie renderowania;
- Użyj „rozwiń przecinków, potem zlokalizuj indeks” do przeskoków, tej samej ścieżki co kliknięcie szewronu.
Jeden szczegół bezpieczeństwa: klucze i wartości w przeglądarce drzewa to dane użytkownika. Budowanie z nich podświetlonego HTML-a wymaga escape'owania, inaczej łańcuch zawierający znaczniki staje się XSS. Przeglądarka JSON nie potrzebuje tekstu sformatowanego, więc węzły tekstowe plus nazwy klas są jednocześnie bezpieczniejsze i szybsze.
7. Podświetlanie składni: CodeMirror i Monaco to różne narzędzia
Widok drzewa i edytor tekstu to różne modele renderowania, więc „który jest szybszy” nie jest bezpośrednim porównaniem:
| Wymiar | CodeMirror 6 | Monaco Editor |
|---|---|---|
| Pozycjonowanie | Modułowy rdzeń edytora | Pełny edytor IDE |
| Renderowanie | Oparte na widocznym obszarze | Oparte na widocznym obszarze, ale modele i dekoracje są cięższe |
| Parsowanie | Przyrostowe parsowanie Lezer na zmienionych zakresach | Tokenizer Monarch, linia po linii |
| Duże pliki | Zależy od rozszerzeń; domyślnie lekki | Pełny zestaw funkcji, wyższy koszt pamięci i startu |
| Zwijanie i wyszukiwanie | Osobne rozszerzenia, ładowane na żądanie | Wbudowane, ze stałym narzutem |
| Typowa pułapka | Zbyt wiele rozszerzeń przywraca koszt | Minimapa, przyklejone przewijanie i piętrowe dekoracje |
Dwa praktyczne wnioski:
- Widok drzewa nie potrzebuje pełnego tokenizera. Daj kluczom, łańcuchom, liczbom i wartościom logicznym jeden kolor każdy, za pomocą zwykłych węzłów tekstowych i klas CSS; kosztuje to o rząd wielkości mniej niż uruchamianie podświetlania składni;
- Nie montuj widoku drzewa i edytora jednocześnie. Utrzymywanie dwóch modeli dla jednego dokumentu podwaja pamięć; zniszcz stary widok, zamiast go ukrywać.
Jeśli edytor naprawdę należy do przeglądarki, CodeMirror 6 pasuje do „tylko odczyt, duży plik, lokalne podświetlanie”, a Monaco do uzupełniania, diagnostyki i edycji wielokursorem. Zmierz czas pierwszego renderowania i pamięć rezydentną, zanim przeczytasz listę funkcji. Głębsze porównanie edytorów i narzędzi CLI znajdziesz w Edytory, narzędzia wiersza poleceń i automatyzacja.
8. Granice Workera: przenieś parsowanie i budowę wierszy poza wątek główny
Druga połowa zacinania renderowania to parsowanie w wątku głównym. Dla JSON-a ponad 1 MB parsowanie, formatowanie i budowa płaskich wierszy należą do Web Workera; wątek główny tylko bierze wiersze i maluje widoczny obszar.
Workery nie są darmowe: postMessage używa klona strukturalnego, więc odesłanie grafu obiektów 50 MB do wątku głównego buduje drugą kopię i podwaja szczyt. Właściwą granicą jest parsowanie i spłaszczanie wewnątrz Workera, zwracanie tylko wierszy, których potrzebuje renderer:
worker.onmessage = (event: MessageEvent<{ rows: TreeRow[] }>) => {
rows = event.data.rows;
requestAnimationFrame(() => renderViewport());
};
worker.postMessage({ text });
Jeśli dane wierszy nadal są duże, spakuj wiersze do równoległych tablic (ścieżka, typ, przesunięcie wartości), umieść je w ArrayBuffer i przekaż własność, aby wątek główny nie płacił za kopię. Koszt to napisanie własnego serializatora, co ma sens tylko przy milionach wierszy.
Reguła dla granicy jest prosta: wątek główny powinien wykonywać tylko pracę, która musi dotknąć DOM. Parsowanie, wyszukiwanie, sortowanie, formatowanie i diffowanie mogą się wynieść. Renderowanie, przewijanie, wejście i fokus zostają w wątku głównym, a każda klatka musi zmieścić się w budżecie pracy.
9. Metryki i kolejność wdrażania
Optymalizacja nie może kończyć się na „wydaje się szybciej”. Poniższy zestaw można wstawić prosto do listy kontrolnej akceptacji:
| Metryka | Cel | Jak mierzyć |
|---|---|---|
| Pierwsza widoczna klatka | Poniżej 300 ms | performance.now() od otwarcia do pierwszego malowania |
| Długie zadania | Brak ponad 50 ms | PerformanceObserver obserwujący longtask |
| Czas klatki przewijania | Stabilnie poniżej 16,7 ms | Nagranie Performance w DevTools |
| Elementy DOM w widocznym obszarze | Poniżej 2 000 | document.querySelectorAll('*').length |
| Odpowiedź rozwijania / zwijania | Poniżej 50 ms | Znaczniki czasu wokół interakcji |
| Wyszukiwanie ponad 100 000 wierszy | Poniżej 300 ms, wewnątrz Workera | Czas w Workerze i zwrócona długość |
| Pamięć rezydentna sterty | Poniżej 3x rozmiaru tekstu | Zrzut sterty w panelu Memory DevTools |
Mierz na prawdziwym pliku, a nie na jednolicie generowanych małych obiektach; kształt danych ma znaczenie, a długie łańcuchy, głębokie zagnieżdżenie i bardzo szerokie tablice dają różne krzywe. Sam rachunek grafu obiektów opisano w Rachunek pamięci JSON.
Wdrażaj w tej kolejności:
- Zmierz baseline: czas pierwszego renderowania, liczba węzłów, czas klatki przewijania;
- Zwijaj domyślnie: rozwiń dwa poziomy, pokaż typ i liczbę w streszczeniu;
- Lista wirtualna: stała wysokość wiersza, stabilne klucze, widoczny obszar plus mały bufor;
- Przenieś obliczenia: parsowanie, wyszukiwanie i spłaszczanie do Workera;
- Ogranicz podświetlanie: styluj tylko widoczne wiersze, unikaj przemalowywania całego drzewa;
- Dodaj zabezpieczenia: powyżej progu zaproponuj „pokaż pierwsze N wierszy” albo wymagaj filtra wyszukiwania.
Każdy etap ma jeden cel: zmniejszyć liczbę węzłów i obiektów, które muszą istnieć w tym samym momencie, zamiast prosić przeglądarkę, by pracowała ciężej. Pełne tło strategii warstwowej znajduje się w Parsowanie, renderowanie i strumieniowanie dużego JSON.
Czy wirtualne przewijanie naprawi zacinanie przy parsowaniu?
Nie. Wirtualne przewijanie zmniejsza tylko węzły DOM. Jeśli parsowanie nadal odbywa się w wątku głównym, strona nadal się zacina. Przenieś parsowanie do Workera dla dokumentów ponad 1 MB i traktuj dwa problemy osobno.
Wszystko jest rozwinięte i nadal się zacina. Co jest nie tak?
Albo lista wirtualna faktycznie nie działa, albo każde rozwijanie skanuje całe drzewo. Sprawdź, czy wiersze poza ekranem nadal tworzą DOM i czy rozwijanie przelicza tylko dotknięte poddrzewo.
CodeMirror czy Monaco dla przeglądarki JSON?
Preferuj CodeMirror 6 do tylko odczytu, dużych plików i lokalnego podświetlania; jego modułowe, ładowane na żądanie podejście jest lżejsze. Wybierz Monaco, gdy potrzebujesz uzupełniania, diagnostyki i edycji wielokursorem. W obu przypadkach unikaj montowania widoku drzewa i edytora dla tego samego dokumentu jednocześnie.
Dlaczego pozycja przewijania jest zła po przeskoku wyszukiwania?
Zwykle bo widok przewinął się przed rozwinięciem. Najpierw rozwiń wszystkich przecinków, potem ustaw pozycję przewijania na podstawie nowego indeksu wiersza, a na końcu podświetl. Kluczuj wiersze po ścieżce, a nie po indeksie, aby rozwijanie nie przesuwało każdego indeksu.
FAQ
Jak ustalić, z którego etapu pochodzi zacinanie?
Rozbij „wolne” na parsowanie i budowę obiektów, tworzenie węzłów DOM, układ i malowanie oraz odpowiedź interakcji, i zmierz każdy etap przez performance.now() od otwarcia do pierwszego malowania. Potem obserwuj longtask przez PerformanceObserver — każde zadanie ponad 50 ms gubi klatki, a nagranie Performance w DevTools pokazuje, czy czas klatki przewijania pozostaje poniżej 16,7 ms.
Dlaczego renderowanie całego drzewa naraz opróżnia ekran?
Dokument 5 MB to około 125 000 linii, a widok drzewa zwykle zamienia każdą linię w 3 do 6 węzłów DOM — około 500 000 elementów naraz. Każdy element niesie też reguły stylu, box układu i instrukcje malowania, więc kilka zadań wątku głównego ponad 50 ms z rzędu sprawia, że strona wygląda na martwą. Węzły ukryte przez zwiniętego rodzica nadal kosztują pracę układu, więc „renderuj wszystko, potem ukryj” nie jest optymalizacją.
Czy odsyłać z Workera graf obiektów 50 MB?
Nie. postMessage używa klona strukturalnego, więc odbiorca buduje drugą kopię, a szczyt się podwaja. Właściwą granicą jest parsowanie i spłaszczanie wewnątrz Workera i zwracanie tylko wierszy, których potrzebuje renderer; przy milionach wierszy możesz spakować równoległe tablice (ścieżka, typ, przesunięcie wartości) do ArrayBuffer i przekazać własność.