Virtual scrolling (disebut juga windowing) adalah teknik merender cuma baris yang kelihatan dari daftar yang panjang, walaupun datanya ribuan. Bayangin naik kereta: pemandangan di luar panjangnya berkilo-kilometer, tapi yang lewat di jendela cuma sepotong. Jendela gak perlu memuat seluruh pemandangan, cukup potongan yang ada di depannya sekarang. Waktu kereta jalan, potongannya berganti.
🧪 Tebakan cepat: list berisi 1.000 baris, tiap baris tingginya 50 px, dan kotak list-nya setinggi 500 px. Dengan virtual scrolling, berapa elemen baris yang ada di DOM: 10, 11, atau 1.000? Jawabannya ada di bagian "Menghitung baris yang dirender".
Masalahnya: semua baris masuk DOM
Kalau 10.000 baris dirender semua, browser harus membuat 10.000 elemen, memberi style, menghitung layout, dan menyimpan semuanya di memori. Padahal di kotak 500 px, yang kelihatan cuma sekitar 10 baris. Halaman jadi lambat muncul, boros memori, dan tiap kali layout dihitung ulang, 10.000 elemen itu ikut dihitung.
Cara kerjanya
Virtual scrolling memisahkan dua hal: tinggi yang dirasakan scrollbar, dan baris yang benar-benar ada.
- Kotak scroll punya tinggi tetap (500 px) dan
overflow-y: auto. Posisi scroll-nya dibaca dariscrollTop, yaitu berapa px isinya sudah tergulung ke atas. - Spacer, elemen kosong setinggi semua baris: 1.000 × 50 = 50.000 px. Berkat dia, scrollbar-nya terasa seperti list penuh.
- Baris yang kelihatan saja, dibungkus satu elemen yang digeser turun sejauh
offsetYdengantransform: translateY(...), supaya baris itu muncul tepat di posisi scroll. - Tiap event
scroll, hitung ulang barisnya dariscrollTop, lalu ganti isinya.
Menghitung baris yang dirender
startIndex = Math.floor(scrollTop / itemHeight): baris pertama yang kelihatan.Math.ceil(containerHeight / itemHeight) + 1: banyak baris yang dirender. 500 / 50 = 10 baris pas memenuhi kotak, ditambah 1 baris cadangan. WaktuscrollTopbukan kelipatan 50, baris paling atas terpotong dan satu baris lagi mengintip di bawah. Tanpa cadangan itu, bagian bawah kotak kosong.endIndex = Math.min(startIndex + baris, totalItems): index terakhir ditambah 1, jadi barisendIndexsendiri gak ikut.Math.minmenjaga supaya gak melewati jumlah data.offsetY = startIndex * itemHeightdanvisibleCount = endIndex - startIndex.
scrollTop |
startIndex |
endIndex |
offsetY |
visibleCount |
Baris di DOM |
|---|---|---|---|---|---|
| 0 | 0 | 11 | 0 | 11 | 0 sampai 10 |
| 2500 | 50 | 61 | 2500 | 11 | 50 sampai 60 |
| 2525 | 50 | 61 | 2500 | 11 | 50 sampai 60 |
| 49500 | 990 | 1000 | 49500 | 10 | 990 sampai 999 |
Dari 1.000 data, cuma 11 baris yang ada di DOM. Itu jawaban tebakannya: 11. Dengan 10.000 data pun tetap 11. Di baris terakhir tabel, 990 + 11 = 1001 melewati 1.000, jadi Math.min memotongnya ke 1000 dan cuma 10 baris yang dirender.
function getVisibleItems(scrollTop, containerHeight, itemHeight, totalItems) {
const startIndex = Math.floor(scrollTop / itemHeight);
const rows = Math.ceil(containerHeight / itemHeight) + 1; // +1 baris cadangan
const endIndex = Math.min(startIndex + rows, totalItems);
return {
startIndex,
endIndex,
offsetY: startIndex * itemHeight,
visibleCount: endIndex - startIndex,
};
}
console.log(getVisibleItems(2500, 500, 50, 1000));
// { startIndex: 50, endIndex: 61, offsetY: 2500, visibleCount: 11 }
Math.floormembulatkan ke bawah:scrollTop2525 tetap mulai di baris 50.Math.ceilmembulatkan ke atas, supaya kotak yang tingginya gak pas kelipatan baris tetap penuh.- Object-nya ditulis dengan urutan key
startIndex,endIndex,offsetY,visibleCount.
Menyambungkannya ke halaman
const list = document.querySelector("#list"); // tinggi 500px, overflow-y: auto
const rows = document.querySelector("#rows"); // di dalam spacer setinggi 50.000px
function render() {
const { startIndex, endIndex, offsetY } = getVisibleItems(list.scrollTop, 500, 50, 1000);
const fragment = document.createDocumentFragment();
for (let i = startIndex; i < endIndex; i++) {
const row = document.createElement("div");
row.className = "row"; // height: 50px
row.textContent = "Baris " + i;
fragment.appendChild(row);
}
rows.replaceChildren(fragment);
rows.style.transform = `translateY(${offsetY}px)`;
}
list.addEventListener("scroll", render);
render();
replaceChildren(fragment)membuang baris lama dan memasukkan yang baru sekali jalan.translateY(offsetY)menaruh baris pertama tepat di posisi baris ke-startIndexdalam spacer.- Di React, library seperti
@tanstack/react-virtualataureact-windowmengerjakan hitungan yang sama untukmu.
Kapan dipakai
Pakai untuk daftar ratusan sampai jutaan baris: tabel log, feed, riwayat chat, spreadsheet. Untuk 30 atau 50 baris, render biasa sudah cukup. Virtual scrolling menambah kerumitan, dan baris yang gak ada di DOM gak bisa ditemukan lewat Ctrl+F browser.
⚠️ Jebakan yang sering ketemu
- Tinggi baris yang beda-beda. Rumus
scrollTop / itemHeightcuma tepat kalau semua baris sama tingginya. Kalau tingginya bervariasi, posisi tiap baris harus diukur atau ditebak dulu, dan itulah yang dikerjakan library. - Lupa baris cadangan. Tanpa
+ 1,scrollTop2525 menyisakan celah kosong 25 px di bawah. endIndexmelewati jumlah data. DiscrollTop49500, tanpaMath.minhasilnya 1001. Harus 1000, denganvisibleCount10.- Key yang beda nama atau beda urutan. Latihan membandingkan object-nya persis:
startIndex,endIndex,offsetY,visibleCount, dengan urutan itu.
TL;DR: Virtual scrolling cuma merender baris yang kelihatan, ditambah sedikit cadangan, di dalam spacer setinggi semua baris supaya scrollbar-nya tetap benar. Rumusnya: startIndex = Math.floor(scrollTop / itemHeight), endIndex = Math.min(startIndex + Math.ceil(containerHeight / itemHeight) + 1, totalItems), offsetY = startIndex * itemHeight, visibleCount = endIndex - startIndex. Kotak 500 px dengan baris 50 px cuma butuh 11 baris di DOM, mau datanya 1.000 atau 10.000. Syaratnya, tinggi tiap baris konsisten atau bisa diukur. Untuk list pendek, render biasa saja.