Mengapa Tree JSON Besar Tersendat: Virtual Scrolling dan Strategi Collapse
Intinya: Sendatan dimulai saat node DOM dibuat, bukan saat Anda menggulir. Membahas rendering collapse-first, virtual list, penyorotan pencarian, dan batas Worker, lengkap dengan metrik.

Intinya: pecah "lambat" menjadi parsing, pembuatan node, layout dan paint, serta interaksi, lalu ukur setiap tahap. Implementasikan dalam urutan ini — collapse secara default, tambahkan virtual list, pindahkan komputasi ke Worker, dan batasi penyorotan hanya pada baris yang terlihat. Langsung melompat ke virtual list tanpa collapse hanya memperkecil himpunan yang terlihat; komputasi ulang seluruh tree tetap memakan biaya.
Sendatan biasanya berasal dari ratusan ribu node DOM yang Anda buat untuk 5MB itu, bukan dari 5MB itu sendiri.
1. Pecah "lambat" menjadi empat tahap
Ketika pengguna berkata "aplikasi membeku saat dibuka," masalahnya bisa berada di empat tahap yang sangat berbeda, masing-masing dengan obat yang berbeda:
| Tahap | Pemicu umum | Ambang terasa | Biaya dominan |
|---|---|---|---|
| Parse dan bangun object | Membuka file, menempel teks | Tugas apa pun di atas 50ms | Memindai string, membangun object graph |
| Membuat node DOM | Merender seluruh tree sekaligus | Puluhan ribu node | Mengalokasikan elemen, text node, object style |
| Layout dan paint | Tampilan pertama, menggulir | Frame apa pun di atas 16,7ms | Geometri, reflow, repaint |
| Respons interaksi | Expand, collapse, pencarian | Di atas 100ms terasa tertunda | Flattening ulang dan rendering ulang |
Main thread browser mengerjakan satu hal pada satu waktu. Jika tahap mana pun menempatinya lebih dari 50ms, menggulir dan mengetik akan kehilangan frame; beberapa tugas berurutan seperti itu dan halaman tampak mati. Jadi optimasi pertama bukan requestAnimationFrame; melainkan mengukur tahap mana yang sebenarnya menghabiskan waktu.
2. Jumlah node adalah musuh sesungguhnya
Tree viewer mengalikan jumlah node secara desain. Satu baris JSON biasanya menjadi "indentasi + key + titik dua + value + koma + chevron," jadi perkiraan konservatif adalah 3 sampai 6 node DOM per baris:
| Ukuran file | Perkiraan baris | Node DOM tree-view (perkiraan) | Tampilan rendering sekali jalan |
|---|---|---|---|
| 100 KB | 2.500 | 10.000 | Biasanya masih bisa ditoleransi |
| 1 MB | 25.000 | 100.000 | Jelas tersendat, mungkin beberapa detik layar kosong |
| 5 MB | 125.000 | 500.000 | Sangat mungkin tidak responsif |
| 20 MB | 500.000 | Lebih dari 2 juta | Memori dan layout keduanya kolaps |
Setiap elemen lebih dari sekadar beberapa lusin byte object: ia membawa aturan style, kotak layout, dan instruksi paint. Setelah dibuat, node tetap memakan kerja layout dan style meski nanti disembunyikan oleh induk yang di-collapse, selama ia belum benar-benar dihapus. "Render semuanya, lalu sembunyikan" bukan optimasi; itu hanya menunda biaya ke reflow berikutnya.
3. Collapse dulu, virtualisasi kemudian
Virtual scrolling menyelesaikan "terlalu banyak baris terlihat." Collapse menyelesaikan "kita seharusnya tidak perlu melihat sebanyak ini sama sekali." Urutannya penting: untuk dokumen sejuta baris yang diperluas secara default, bahkan virtual list yang sempurna harus menghitung ulang seluruh tree ketika pengguna mengklik "collapse all."
Strategi collapse yang bisa diterapkan:
- Perluas hanya dua level pertama secara default. Root dan field langsungnya terlihat; level lebih dalam menampilkan ringkasan seperti
items: Array(12000); - Tampilkan tipe dan jumlah di ringkasan. Pengguna yang memeriksa "apakah nama field sudah benar" jarang membutuhkan sepuluh ribu record;
- Cache baris per subtree. Setiap node yang diperluas memiliki array baris hasil flattening-nya; collapse membuangnya, dan expand hanya menghitung ulang subtree itu;
- Jangan pernah memindai ulang seluruh tree pada setiap toggle. Jika perluasan terjadi di baris ke-3.000, ganti hanya rentang itu dan biarkan baris di sekitarnya tetap utuh.
/**
* Menyisipkan baris subtree setelah induknya alih-alih melakukan flatten ulang seluruh tree.
* @param rows Array baris yang terlihat saat ini
* @param parentIndex Indeks baris induk
* @param childRows Baris subtree yang sudah di-flatten
* @returns Array baris terlihat yang baru
*/
function replaceSubtreeRows(
rows: TreeRow[],
parentIndex: number,
childRows: TreeRow[],
): TreeRow[] {
const nextRows = rows.slice();
nextRows.splice(parentIndex + 1, 0, ...childRows);
return nextRows;
}
Pola yang harus dihindari adalah "satu expand, satu relayout penuh." Menelusuri ulang object graph lengkap pada setiap klik membuat biaya tumbuh linear terhadap dokumen. Dengan cache per subtree, satu perluasan hanya memakan biaya sebesar subtree itu.
4. Virtual list: render hanya viewport
Setelah collapse mengurangi jumlah baris, sisanya masih bisa puluhan ribu baris. Virtual list menetapkan tinggi container ke "total baris x tinggi baris tetap" dan membuat elemen hanya untuk viewport plus buffer kecil di atas dan di bawah.
const ROW_HEIGHT = 24;
const OVERSCAN = 8;
/**
* Menghitung rentang baris yang harus dirender viewport.
* @param rows Semua baris yang terlihat
* @param scrollTop Offset scroll container saat ini
* @param viewportHeight Tinggi container yang terlihat
* @returns Baris yang harus dirender, indeks awal, dan offset vertikal
*/
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,
};
}
Batasan yang tidak bisa ditawar:
- Tinggi baris harus tetap. Hanya dengan begitu
scrollTop / ROW_HEIGHTbisa langsung memberi indeks. Membiarkan baris wrap memaksa Anda mengukur dan cache setiap tinggi, yang menambah satu orde kompleksitas dan kegelisahan; - Offset dengan
transformatau absolute positioning. Jangan mendorong konten ke bawah dengan elemen spacer, atau setiap scroll memutasi layout; - Gunakan key yang stabil. Beri key baris berdasarkan path, bukan indeks array, atau memperluas satu baris akan me-remount setiap baris setelahnya;
- Lakukan aritmetika hanya di handler scroll. Tunda rendering ke
requestAnimationFramedan lindungi dari penjadwalan ganda; - Jaga buffer tetap kecil. Lima sampai sepuluh baris di setiap sisi sudah cukup; menggandakan buffer menggandakan node DOM dan kerja style.
Dengan viewport 800 piksel dan baris 24 piksel, sekitar 34 baris terlihat, dan bahkan dengan buffer Anda merender sekitar lima puluh. Dokumen di bawahnya bisa memuat seratus ribu baris sementara DOM konsisten memuat beberapa ratus elemen, menjaga biaya layout tetap datar.
5. Collapse, expand, dan anchoring scroll
Begitu indeks baris menjadi satu-satunya koordinat, beberapa detail interaksi mulai rusak:
- Pertahankan jangkar visual saat collapse di atas viewport. Baris di bawah bergeser ke atas; jika
scrollToptidak disesuaikan dalam commit yang sama, konten tampak melompat. Catat path baris jangkar dan pulihkan posisi scroll menggunakan indeks barunya. - Perluas ancestor sebelum melompat. Ketika pencarian mengenai field dalam, perluas dulu setiap induk, lalu hitung indeks baris target, lalu gulir dan sorot. Lewati langkah pertama dan baris target bahkan tidak ada di array yang terlihat.
- Kendalikan navigasi keyboard dari baris yang terlihat. Atas dan bawah memindahkan indeks baris; kiri dan kanan memperluas dan meng-collapse. Jangan pernah menelusuri seluruh tree di dalam handler key.
- Batch update ke satu frame. Saat memperluas beberapa level berturut-turut, gabungkan perubahan array dan render sekali, atau setiap level memicu reflow penuh.
6. Pencarian dan penyorotan: hitung sekali, tampilkan sedikit
Pencarian adalah jebakan kinerja yang paling sering terlewat di tree viewer. Jika setiap ketikan menelusuri seluruh object graph dan membangun ulang daftar hasil, dokumen seratus ribu node akan langsung membeku.
Pendekatan berlapis:
- Debounce input 150-250ms dan tandai setiap pencarian dengan nomor urut agar hasil basi dibuang;
- Pindai di Worker. Menelusuri object graph adalah komputasi murni tanpa ketergantungan DOM, jadi tidak boleh memblokir pengetikan;
- Simpan hanya path dan indeks baris. Jangan menyalin setiap node yang cocok atau membangun HTML tersorot terlebih dahulu;
- Sorot hanya baris yang terlihat. Viewport memuat beberapa lusin baris; terapkan style berdasarkan path yang cocok saat render;
- Gunakan ulang "perluas ancestor, lalu temukan indeks" untuk lompatan, jalur yang sama dengan mengklik chevron.
Satu detail keamanan: key dan value di tree viewer adalah data pengguna. Membangun HTML tersorot dari mereka membutuhkan escaping, atau string berisi markup menjadi XSS. JSON viewer tidak perlu rich text, jadi text node plus nama class lebih aman sekaligus lebih cepat.
7. Syntax highlighting: CodeMirror dan Monaco adalah alat berbeda
Tree view dan text editor adalah model rendering yang berbeda, jadi "mana yang lebih cepat" bukan perbandingan langsung:
| Dimensi | CodeMirror 6 | Monaco Editor |
|---|---|---|
| Posisi | Inti editor modular | Editor IDE lengkap |
| Rendering | Berbasis viewport | Berbasis viewport, tetapi model dan decoration lebih berat |
| Parsing | Parsing inkremental Lezer atas rentang yang berubah | Tokenizer Monarch, baris per baris |
| File besar | Bergantung extension; ringan secara default | Fitur lengkap, memori dan biaya startup lebih tinggi |
| Folding dan pencarian | Extension terpisah, dimuat sesuai permintaan | Bawaan, dengan overhead permanen |
| Jebakan umum | Terlalu banyak extension mengembalikan biaya | Minimap, sticky scroll, dan decoration bertumpuk |
Dua kesimpulan praktis:
- Tree view tidak butuh tokenizer penuh. Beri key, string, number, dan boolean satu warna masing-masing dengan text node biasa dan class CSS; itu memakan satu orde lebih sedikit daripada menjalankan syntax highlighting;
- Jangan mount tree view dan editor sekaligus. Memelihara dua model untuk satu dokumen menggandakan memori; hancurkan view lama alih-alih menyembunyikannya.
Jika editor benar-benar dibutuhkan di dalam viewer, CodeMirror 6 cocok untuk "read-only, file besar, penyorotan lokal," sementara Monaco cocok untuk completion, diagnostik, dan multi-cursor editing. Ukur waktu first-paint dan memori residen sebelum membaca daftar fitur. Untuk perbandingan editor dan alat CLI yang lebih dalam, lihat Editor, Alat Command-Line, dan Otomasi.
8. Batas Worker: pindahkan parsing dan pembangunan baris dari main thread
Separuh lain dari sendatan rendering adalah parsing di main thread. Untuk JSON di atas 1MB, parsing, format, dan pembangunan baris datar sebaiknya berada di Web Worker; main thread hanya menerima baris dan melukis viewport.
Worker tidak gratis: postMessage memakai structured clone, jadi mengirim object graph 50MB kembali ke main thread membangun salinan kedua dan menggandakan puncak. Batas yang benar adalah parse dan flatten di dalam Worker, kembalikan hanya baris yang dibutuhkan renderer:
worker.onmessage = (event: MessageEvent<{ rows: TreeRow[] }>) => {
rows = event.data.rows;
requestAnimationFrame(() => renderViewport());
};
worker.postMessage({ text });
Jika data baris masih besar, kompres baris menjadi array paralel (path, type, offset value), tempatkan di ArrayBuffer, dan transfer kepemilikannya agar main thread tidak membayar salinan. Biayanya adalah menulis serializer sendiri, yang hanya layak pada jutaan baris.
Aturan untuk batas itu sederhana: main thread seharusnya hanya melakukan pekerjaan yang wajib menyentuh DOM. Parsing, pencarian, pengurutan, format, dan diff semuanya bisa dipindahkan. Rendering, scroll, input, dan fokus tetap di main thread, dan setiap frame harus muat dalam anggaran kerja.
9. Metrik dan urutan implementasi
Optimasi tidak bisa berhenti pada "terasa lebih cepat." Himpunan berikut bisa langsung masuk checklist penerimaan:
| Metrik | Target | Cara mengukur |
|---|---|---|
| Frame pertama terlihat | Di bawah 300ms | performance.now() dari buka hingga paint pertama |
| Long task | Tidak ada yang di atas 50ms | PerformanceObserver mengawasi longtask |
| Waktu frame scroll | Stabil di bawah 16,7ms | Perekaman Performance DevTools |
| Elemen DOM di viewport | Di bawah 2.000 | document.querySelectorAll('*').length |
| Respons expand / collapse | Di bawah 50ms | Timestamp di sekitar interaksi |
| Pencarian atas 100.000 baris | Di bawah 300ms, di dalam Worker | Waktu di Worker, kembalikan durasinya |
| Heap residen | Di bawah 3x ukuran teks | Heap snapshot Memory DevTools |
Ukur dengan file nyata alih-alih object kecil yang dihasilkan seragam; bentuk data penting, dan string panjang, nesting dalam, serta array sangat lebar semuanya menghasilkan kurva berbeda. Untuk neraca object graph itu sendiri, lihat Neraca Memori JSON.
Implementasikan dalam urutan ini:
- Ukur baseline: waktu render pertama, jumlah node, waktu frame scroll;
- Collapse secara default: perluas dua level, tampilkan tipe dan jumlah di ringkasan;
- Virtual list: tinggi baris tetap, key stabil, viewport plus buffer kecil;
- Pindahkan komputasi: parsing, pencarian, dan flattening ke Worker;
- Batasi penyorotan: beri style hanya baris terlihat, hindari melukis ulang seluruh tree;
- Tambahkan guardrail: di atas ambang tertentu, tawarkan "tampilkan N baris pertama" atau wajibkan filter pencarian.
Setiap langkah berbagi satu tujuan: kurangi node dan object yang harus ada pada saat bersamaan, alih-alih meminta browser bekerja lebih keras pada mereka. Latar belakang lengkap tentang strategi berlapis ada di Parsing, Rendering, dan Streaming JSON Besar.
Bisakah virtual scrolling memperbaiki sendatan saat parsing?
Tidak. Virtual scrolling hanya mengurangi node DOM. Jika parsing masih terjadi di main thread, halaman tetap tersendat. Pindahkan parsing ke Worker untuk dokumen di atas 1MB dan tangani kedua masalah secara terpisah.
Semuanya sudah diperluas dan masih tersendat. Apa yang salah?
Entah virtual list sebenarnya tidak aktif, atau setiap perluasan memindai ulang seluruh tree. Periksa apakah baris di luar layar masih membuat DOM, dan apakah perluasan hanya menghitung ulang subtree yang terpengaruh.
CodeMirror atau Monaco untuk JSON viewer?
Utamakan CodeMirror 6 untuk read-only, file besar, dan penyorotan lokal; pemuatan modular sesuai permintaan membuatnya lebih ringan. Pilih Monaco ketika Anda butuh completion, diagnostik, dan multi-cursor editing. Dengan keduanya, hindari mount tree view dan editor untuk dokumen yang sama sekaligus.
Mengapa posisi scroll salah setelah lompatan pencarian?
Biasanya karena tampilan digulir sebelum memperluas. Perluas semua ancestor dulu, lalu tetapkan posisi scroll dari indeks baris baru, dan sorot terakhir. Beri key baris berdasarkan path, bukan indeks, agar perluasan tidak menggeser setiap indeks.
FAQ
Bagaimana cara mengetahui tahap mana asal sendatan?
Pecah "lambat" menjadi parsing dan pembangunan object, pembuatan node DOM, layout dan paint, serta respons interaksi, dan ukur setiap tahap dengan performance.now() dari buka hingga paint pertama. Lalu awasi longtask dengan PerformanceObserver — tugas apa pun di atas 50ms menjatuhkan frame, dan perekaman Performance DevTools menunjukkan apakah waktu frame scroll tetap di bawah 16,7ms.
Mengapa merender seluruh tree sekaligus membuat layar kosong?
Dokumen 5MB kira-kira 125.000 baris, dan tree view biasanya mengubah setiap baris menjadi 3 sampai 6 node DOM — sekitar 500.000 elemen sekaligus. Setiap elemen juga membawa aturan style, kotak layout, dan instruksi paint, sehingga beberapa tugas main thread di atas 50ms berturut-turut membuat halaman tampak mati. Node yang disembunyikan induk yang di-collapse tetap memakan kerja layout, jadi "render semuanya, lalu sembunyikan" bukan optimasi.
Haruskah saya mengirim kembali object graph 50MB dari Worker?
Tidak. postMessage memakai structured clone, sehingga sisi penerima membangun salinan kedua dan puncak menggandakan. Batas yang benar adalah parse dan flatten di dalam Worker dan kembalikan hanya baris yang dibutuhkan renderer; pada jutaan baris Anda bisa mengemas array paralel (path, type, offset value) ke dalam ArrayBuffer dan mentransfer kepemilikannya.