Proč velké stromy JSON zadrhávají: virtuální scrollování a strategie sbalování
Shrnutí: Trhání začíná při vytváření DOM uzlů, nikoli při scrollování. Pokrývá vykreslování se sbalením předem, virtuální seznamy, zvýrazňování hledání a hranice Workeru, s metrikami.

Stručně: rozdělte „pomalé“ na parsování, vytváření uzlů, layout a paint a interakci a poté každou fázi změřte. Implementujte v tomto pořadí – ve výchozím stavu sbalené, poté přidejte virtuální seznam, přesuňte výpočty do Workeru a omezujte zvýrazňování na viditelné řádky. Skočit rovnou na virtuální seznam bez sbalení jen zmenší viditelnou množinu; přepočet celého stromu vás stále stojí.
Trhání obvykle pochází ze stovek tisíc DOM uzlů, které jste vytvořili pro těch 5 MB, nikoli z těch 5 MB samotných.
1. Rozdělte „pomalé“ na čtyři fáze
Když uživatel řekne „při otevření to zamrzne“, problém může žít ve čtyřech zcela odlišných fázích, každá s jiným řešením:
| Fáze | Typický spouštěč | Vnímatelný práh | Dominantní náklad |
|---|---|---|---|
| Parsování a stavba objektů | Otevření souboru, vložení textu | Jakákoli úloha přes 50 ms | Skenování řetězců, stavba grafu objektů |
| Vytváření DOM uzlů | Vykreslení celého stromu najednou | Desítky tisíc uzlů | Alokace elementů, textových uzlů, stylových objektů |
| Layout a paint | První zobrazení, scrollování | Jakýkoli snímek přes 16,7 ms | Geometrie, reflow, repaint |
| Reakce na interakci | Rozbalení, sbalení, hledání | Přes 100 ms působí zpožděně | Znovusploštění a znovuvykreslení |
Hlavní vlákno prohlížeče dělá jednu věc najednou. Pokud kterákoli fáze zabírá víc než 50 ms, scrollování a psaní ztrácejí snímky; několik takových úloh v řadě a stránka vypadá mrtvá. První optimalizace tedy není requestAnimationFrame; je to měření, která fáze skutečně spotřebovává čas.
2. Počet uzlů je skutečný nepřítel
Stromové prohlížeče násobí počty uzlů už ze své podstaty. Jediný řádek JSON se obvykle stane „odsazení + klíč + dvojtečka + hodnota + čárka + šipka“, takže konzervativní odhad je 3 až 6 DOM uzlů na řádek:
| Velikost souboru | Přibližné řádky | DOM uzlů stromového zobrazení (odhad) | Jak vypadá jednorázové vykreslení |
|---|---|---|---|
| 100 KB | 2 500 | 10 000 | Obvykle snesitelné |
| 1 MB | 25 000 | 100 000 | Zřetelně trhané, možná sekundy prázdné obrazovky |
| 5 MB | 125 000 | 500 000 | Velmi pravděpodobně neodpovídá |
| 20 MB | 500 000 | Přes 2 miliony | Zhroucení paměti i layoutu |
Každý element je víc než několik desítek bajtů objektu: nese stylová pravidla, layout box a instrukce pro paint. Jakmile je uzel vytvořen, stále stojí práci layoutu a stylů, i když je později skryt sbaleným rodičem, dokud nebyl skutečně odstraněn. „Vykreslit všechno a poté skrýt“ není optimalizace; jen odkládá náklad na příští reflow.
3. Nejprve sbalit, poté virtualizovat
Virtuální scrollování řeší „příliš mnoho viditelných řádků“. Sbalování řeší „těchto řádků bychom vůbec neměli potřebovat tolik vidět“. Pořadí záleží: u dokumentu s milionem řádků rozbaleného ve výchozím stavu musí i dokonalý virtuální seznam přepočítat celý strom, když uživatel klikne na „sbalit vše“.
Použitelná strategie sbalování:
- Ve výchozím stavu rozbalte jen první dvě úrovně. Kořen a jeho přímá pole jsou viditelné; hlubší úrovně ukazují shrnutí jako
items: Array(12000); - Do shrnutí dejte typ a počet. Uživatelé kontrolující „jsou názvy polí správné“ zřídka potřebují deset tisíc záznamů;
- Cachujte řádky pro každý podstrom. Každý rozbalený uzel vlastní své zploštělé pole řádků; sbalení ho zahodí a rozbalení přepočítá jen ten podstrom;
- Nikdy neskenujte celý strom při každém přepnutí. Pokud rozbalení nastane na řádku 3 000, nahraďte jen ten rozsah a okolní řádky nechte nedotčené.
/**
* Vloží řádky podstromu za jeho rodiče, místo aby znovu splošťoval celý strom.
* @param rows Aktuální pole viditelných řádků
* @param parentIndex Index řádku rodiče
* @param childRows Zploštěné řádky podstromu
* @returns Nové pole viditelných řádků
*/
function replaceSubtreeRows(
rows: TreeRow[],
parentIndex: number,
childRows: TreeRow[],
): TreeRow[] {
const nextRows = rows.slice();
nextRows.splice(parentIndex + 1, 0, ...childRows);
return nextRows;
}
Vzor, kterému se vyhnout, je „jedno rozbalení, jeden plný relayout“. Nové procházení celého grafu objektů při každém kliknutí činí náklad lineárně rostoucí s dokumentem. S cachováním po podstromu stojí jedno rozbalení jen tolik co ten podstrom.
4. Virtuální seznamy: vykreslujte jen viewport
Jakmile sbalení snížilo počet řádků, zbytek může stále být desítky tisíc řádků. Virtuální seznam nastaví výšku kontejneru na „celkový počet řádků × pevná výška řádku“ a vytváří elementy jen pro viewport plus malý buffer nad a pod.
const ROW_HEIGHT = 24;
const OVERSCAN = 8;
/**
* Vypočítá rozsah řádků, které má viewport vykreslit.
* @param rows Všechny viditelné řádky
* @param scrollTop Aktuální offset scrollování kontejneru
* @param viewportHeight Viditelná výška kontejneru
* @returns Řádky k vykreslení, počáteční index a vertikální 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,
};
}
Nepodkročitelná omezení:
- Výška řádku musí být pevná. Jen tehdy vám
scrollTop / ROW_HEIGHTdá index přímo. Povolení zalamovaných řádků vás nutí měřit a cachovat každou výšku, což přidává řád složitosti a chvění; - Offsetujte pomocí
transformnebo absolutního pozicování. Netlačte obsah dolů pomocí spacer elementu, jinak každé scrollování mutuje layout; - Používejte stabilní klíče. Klíčujte řádky podle cesty, nikoli podle indexu pole, jinak rozbalení jednoho řádku remountuje každý následující;
- Počítejte aritmetiku jen ve scroll handleru. Vykreslování odložte na
requestAnimationFramea chraňte se proti duplicitnímu plánování; - Držte buffer malý. Pět až deset řádků na každou stranu stačí; zdvojnásobení bufferu zdvojnásobí DOM uzly i práci se styly.
S 800pixelovým viewportem a 24pixelovými řádky je viditelných zhruba 34 řádků a i s bufferem vykreslíte asi padesát. Základní dokument může nést sto tisíc řádků, zatímco DOM trvale drží několik stovek elementů, čímž náklady na layout zůstávají ploché.
5. Sbalování, rozbalování a ukotvení scrollování
Jakmile se index řádku stane jedinou souřadnicí, několik detailů interakce se začne rozbíjet:
- Zachovejte vizuální kotvu při sbalování nad viewportem. Řádky níže se posunou nahoru; pokud se
scrollTopneupraví ve stejném commitu, obsah vypadá, že skočil. Zaznamenejte cestu kotvového řádku a obnovte pozici scrollování pomocí jeho nového indexu. - Před skokem rozbalte předky. Když hledání zasáhne hluboké pole, nejprve rozbalte všechny rodiče, poté vypočítejte index cílového řádku, pak scrollujte a zvýrazněte. Přeskočte první krok a cílový řádek v poli viditelných ani není.
- Navigaci klávesnicí řiďte z viditelných řádků. Nahoru a dolů posouvají index řádku; vlevo a vpravo rozbalují a sbalují. Nikdy neprocházejte celý strom uvnitř handleru klávesy.
- Dávkujte aktualizace do jednoho snímku. Při rozbalení několika úrovní v řadě sloučte změny pole a vykreslete jednou, jinak každá úroveň spustí plný reflow.
6. Hledání a zvýrazňování: spočítejte jednou, zobrazte málo
Hledání je nejčastěji přehlížená výkonová past ve stromovém prohlížeči. Pokud každý stisk klávesy prochází celý graf objektů a znovu staví seznam výsledků, dokument se sto tisíci uzlů okamžitě zamrzne.
Vrstvený přístup:
- Debouncujte vstup na 150–250 ms a každé hledání označte pořadovým číslem, aby se zastaralé výsledky zahodily;
- Skenujte ve Workeru. Procházení grafu objektů je čistý výpočet bez závislosti na DOM, takže nemá blokovat psaní;
- Ukládejte jen cesty a indexy řádků. Nekopírujte každý nalezený uzel ani nepředstavějte zvýrazněné HTML;
- Zvýrazňujte jen viditelné řádky. Viewport drží několik desítek řádků; styly aplikujte podle nalezené cesty při vykreslení;
- Pro skoky znovu použijte „rozbalit předky, poté lokalizovat index“, stejnou cestu jako při kliknutí na šipku.
Jeden bezpečnostní detail: klíče a hodnoty ve stromovém prohlížeči jsou data uživatele. Stavět z nich zvýrazněné HTML vyžaduje escapování, jinak se řetězec obsahující značky stane XSS. Prohlížeč JSON nepotřebuje bohatý text, takže textové uzly plus názvy tříd jsou bezpečnější i rychlejší.
7. Zvýrazňování syntaxe: CodeMirror a Monaco jsou různé nástroje
Stromové zobrazení a textový editor jsou různé vykreslovací modely, takže „co je rychlejší“ není přímé srovnání:
| Dimenze | CodeMirror 6 | Monaco Editor |
|---|---|---|
| Pozicování | Modulární jádro editoru | Plnohodnotný editor IDE |
| Vykreslování | Podle viewportu | Podle viewportu, ale modely a dekorace jsou těžší |
| Parsování | Inkrementální parsování Lezerem nad změněnými rozsahy | Monarch tokenizer, řádek po řádku |
| Velké soubory | Závisí na rozšířeních; ve výchozím stavu lehký | Plný funkcí, vyšší paměť a náklady na start |
| Skládání a hledání | Samostatná rozšíření, načítaná na vyžádání | Vestavěné, s trvalou režií |
| Typická past | Příliš mnoho rozšíření obnoví náklady | Minimap, sticky scroll a vrstvené dekorace |
Dva praktické závěry:
- Stromové zobrazení nepotřebuje plný tokenizer. Dejte klíčům, řetězcům, číslům a booleanům jednu barvu každému pomocí prostých textových uzlů a CSS tříd; to stojí o řád méně než spouštění zvýrazňování syntaxe;
- Nedávejte stromové zobrazení a editor najednou. Udržování dvou modelů pro jeden dokument zdvojnásobuje paměť; staré zobrazení zničte, místo abyste ho skryli.
Pokud editor do prohlížeče skutečně patří, CodeMirror 6 se hodí pro „jen pro čtení, velký soubor, lokální zvýrazňování“, zatímco Monaco pro dokončování, diagnostiku a multi-kurzorové úpravy. Než začnete číst seznam funkcí, změřte dobu prvního vykreslení a rezidentní paměť. Hlubšímu srovnání editorů a CLI nástrojů se věnuje Editory, nástroje příkazového řádku a automatizace.
8. Hranice Workeru: přesuňte parsování a stavbu řádků mimo hlavní vlákno
Druhá polovina trhání vykreslování je parsování v hlavním vlákně. Pro JSON nad 1 MB patří parsování, formátování a stavba zploštěných řádků do Web Workeru; hlavní vlákno jen bere řádky a maluje viewport.
Workers nejsou zdarma: postMessage používá strukturované klonování, takže poslání 50MB grafu objektů zpět do hlavního vlákna staví druhou kopii a zdvojnásobuje vrchol. Správná hranice je parsovat a zplošťovat uvnitř Workeru, vracet jen řádky, které renderer potřebuje:
worker.onmessage = (event: MessageEvent<{ rows: TreeRow[] }>) => {
rows = event.data.rows;
requestAnimationFrame(() => renderViewport());
};
worker.postMessage({ text });
Pokud jsou data řádků stále velká, zkomprimujte řádky do paralelních polí (cesta, typ, offset hodnoty), umístěte je do ArrayBuffer a převeďte vlastnictví, aby hlavní vlákno neplatilo za kopii. Cenou je psaní vlastního serializátoru, což má cenu jen u milionů řádků.
Pravidlo pro hranici je jednoduché: hlavní vlákno má dělat jen práci, která se musí dotknout DOM. Parsování, hledání, řazení, formátování i diff se mohou přesunout. Vykreslování, scrollování, vstup a fokus zůstávají v hlavním vlákně a každý snímek se musí vejít do rozpočtu práce.
9. Metriky a pořadí implementace
Optimalizace nemůže skončit u „působí rychleji“. Následující sada může jít rovnou do kontrolního seznamu pro přijetí:
| Metrika | Cíl | Jak měřit |
|---|---|---|
| První viditelný snímek | Pod 300 ms | performance.now() od otevření po první paint |
| Dlouhé úlohy | Žádná přes 50 ms | PerformanceObserver sledující longtask |
| Čas snímku při scrollování | Trvale pod 16,7 ms | Záznam v DevTools Performance |
| DOM elementů ve viewportu | Pod 2 000 | document.querySelectorAll('*').length |
| Reakce rozbalení / sbalení | Pod 50 ms | Časové značky kolem interakce |
| Hledání nad 100 000 řádky | Pod 300 ms, uvnitř Workeru | Čas ve Workeru a vrácená doba |
| Rezidentní halda | Pod 3násobek velikosti textu | Snapshot haldy v DevTools Memory |
Měřte se skutečným souborem, nikoli uniformně generovanými malými objekty; tvar dat záleží a dlouhé řetězce, hluboké vnoření a velmi široká pole produkují různé křivky. Samotnému účtu grafu objektů se věnuje Účet paměti JSON.
Implementujte v tomto pořadí:
- Změřte baseline: doba prvního vykreslení, počet uzlů, čas snímku při scrollování;
- Ve výchozím stavu sbaleno: rozbalte dvě úrovně, ve shrnutích ukažte typ a počet;
- Virtuální seznam: pevná výška řádku, stabilní klíče, viewport plus malý buffer;
- Přesuňte výpočty: parsování, hledání a zplošťování do Workeru;
- Omezte zvýrazňování: stylujte jen viditelné řádky, vyhněte se znovuvykreslení celého stromu;
- Přidejte zábrany: nad prahem nabídněte „zobrazit prvních N řádků“ nebo vyžadujte filtr hledání.
Každý krok sdílí jeden cíl: snížit počet uzlů a objektů, které musí existovat ve stejném okamžiku, místo abyste žádali prohlížeč, aby na nich pracoval tvrději. Úplné pozadí vrstvené strategie je v Parsování, vykreslování a streamování velkého JSON.
Může virtuální scrollování opravit trhání při parsování?
Ne. Virtuální scrollování jen snižuje počet DOM uzlů. Pokud parsování stále probíhá v hlavním vlákně, stránka stále zadrhává. Přesuňte parsování do Workeru pro dokumenty nad 1 MB a oba problémy řešte odděleně.
Všechno je rozbalené a stále to zadrhává. Co je špatně?
Buď virtuální seznam není skutečně aktivní, nebo každé rozbalení znovu skenuje celý strom. Zkontrolujte, zda řádky mimo obrazovku stále vytvářejí DOM a zda rozbalení přepočítává jen postižený podstrom.
CodeMirror, nebo Monaco pro prohlížeč JSON?
Pro jen pro čtení, velké soubory a lokální zvýrazňování preferujte CodeMirror 6; jeho modulární načítání na vyžádání je lehčí. Monaco zvolte, když potřebujete dokončování, diagnostiku a multi-kurzorové úpravy. U obou se vyhněte mountování stromového zobrazení a editoru pro stejný dokument najednou.
Proč je pozice scrollování po skoku z hledání špatná?
Obvykle protože se zobrazení posunulo před rozbalením. Nejprve rozbalte všechny předky, poté nastavte pozici scrollování podle nového indexu řádku a zvýrazněte až nakonec. Klíčujte řádky podle cesty, nikoli podle indexu, aby rozbalení neposunulo každý index.
Časté dotazy
Jak zjistit, ze které fáze trhání pochází?
Rozdělte „pomalé“ na parsování a stavbu objektů, vytváření DOM uzlů, layout a paint a reakci na interakci, a každou fázi změřte pomocí performance.now() od otevření po první paint. Poté sledujte longtask pomocí PerformanceObserver – jakákoli úloha přes 50 ms ztrácí snímky a záznam v DevTools Performance ukáže, zda čas snímku při scrollování zůstává pod 16,7 ms.
Proč vykreslení celého stromu najednou ztmavne obrazovku?
5MB dokument má asi 125 000 řádků a stromové zobrazení obvykle mění každý řádek na 3 až 6 DOM uzlů – zhruba 500 000 elementů najednou. Každý element nese také stylová pravidla, layout box a instrukce pro paint, takže několik úloh hlavního vlákna přes 50 ms v řadě nechá stránku vypadat mrtvou. Uzly skryté sbaleným rodičem stále stojí práci layoutu, takže „vykreslit všechno a poté skrýt“ není optimalizace.
Mám poslat 50MB graf objektů zpět z Workeru?
Ne. postMessage používá strukturované klonování, takže přijímající strana staví druhou kopii a vrchol se zdvojnásobí. Správná hranice je parsovat a zplošťovat uvnitř Workeru a vracet jen řádky, které renderer potřebuje; u milionů řádků můžete zabalit paralelní pole (cesta, typ, offset hodnoty) do ArrayBuffer a převést vlastnictví.