JSON Toolkit

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.

Zaawansowany~10 min czytaniaOpublikowano 2026-10-31Zaktualizowano 2026-10-31
Wirtualne przewijanie i strategia zwijania dla przeglądarki JSON

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_HEIGHT daje 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 transform lub 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 requestAnimationFrame i 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ć:

  1. Zachowaj kotwicę wizualną przy zwijaniu powyżej widocznego obszaru. Wiersze poniżej przesuwają się w górę; jeśli scrollTop nie 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.
  2. 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.
  3. 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.
  4. 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:

  1. Zmierz baseline: czas pierwszego renderowania, liczba węzłów, czas klatki przewijania;
  2. Zwijaj domyślnie: rozwiń dwa poziomy, pokaż typ i liczbę w streszczeniu;
  3. Lista wirtualna: stała wysokość wiersza, stabilne klucze, widoczny obszar plus mały bufor;
  4. Przenieś obliczenia: parsowanie, wyszukiwanie i spłaszczanie do Workera;
  5. Ogranicz podświetlanie: styluj tylko widoczne wiersze, unikaj przemalowywania całego drzewa;
  6. 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ść.