Tabel data yang tidak menjatuhkan browser
Tabel adalah island paling berat di portal data. Yang menentukan performanya bukan library, melainkan keputusan berapa banyak data yang boleh sampai ke browser.
Intisari
- Aturan praktis: β€ 1.000 baris boleh diproses di klien. Lebih dari itu, sortir dan filter pindah ke server.
- Mengirim 10.000 baris sebagai props berarti menambah megabita ke HTML yang di-cache untuk setiap pembaca.
v-forwajib punya:keyyang stabil β indeks array bukan key yang stabil.- Filter di klien harus di-debounce; filter di server harus dibatalkan saat ada ketikan baru.
- Tabel HTML sungguhan (
<table>), bukan<div>β pembaca layar dan mesin pencari membacanya.
Keputusan pertama: klien atau server
| Proses di klien | Proses di server | |
|---|---|---|
| Jumlah baris | β€ 1.000 | Berapa pun |
| Sortir & filter | Seketika | Satu round-trip |
| Ukuran HTML | Bertambah sebesar datanya | Tetap kecil |
| Bisa di-cache di edge | Ya, tapi mahal | Ya, dan murah |
| Jalan tanpa JavaScript | Tidak | Ya, kalau pakai tautan biasa |
Untuk daftar 300 emiten dengan lima kolom, klien menang telak β datanya kecil dan interaksinya instan. Untuk 50.000 baris data keuangan historis, server satu-satunya jawaban.
Versi klien: kecil, cepat, dan tetap terindeks
---
import TabelEmiten from "@/components/TabelEmiten.vue";
import { ambilRingkasanEmiten } from "@/lib/perusahaan";
export const prerender = false;
const emiten = await ambilRingkasanEmiten(); // ~300 baris, 5 kolom
Astro.response.headers.set("Cache-Control", "public, s-maxage=900");
---
<TabelEmiten client:visible data={emiten} />
<!-- Fallback yang terindeks dan bekerja tanpa JS -->
<noscript>
<table>
<thead><tr><th>Kode</th><th>Nama</th><th>Sektor</th></tr></thead>
<tbody>
{emiten.map((e) => (
<tr><td>{e.kode}</td><td><a href={`/perusahaan/${e.kode}`}>{e.nama}</a></td><td>{e.sektor}</td></tr>
))}
</tbody>
</table>
</noscript>
// src/components/TabelEmiten.vue
<script setup lang="ts">
import { ref, computed } from "vue";
interface Emiten {
kode: string; nama: string; sektor: string | null; kapitalisasi: number;
}
const props = defineProps<{ data: Emiten[] }>();
const cari = ref("");
const urutKolom = ref<keyof Emiten>("kapitalisasi");
const urutNaik = ref(false);
const hasil = computed(() => {
const q = cari.value.trim().toLowerCase();
const disaring = q
? props.data.filter(
(e) => e.kode.toLowerCase().includes(q) || e.nama.toLowerCase().includes(q),
)
: props.data;
// Salin dulu β sort() mengubah array aslinya, dan props tidak boleh diubah.
return [...disaring].sort((a, b) => {
const x = a[urutKolom.value];
const y = b[urutKolom.value];
const arah = urutNaik.value ? 1 : -1;
if (x === null) return 1;
if (y === null) return -1;
if (typeof x === "number" && typeof y === "number") return (x - y) * arah;
return String(x).localeCompare(String(y), "id") * arah;
});
});
function urutkan(kolom: keyof Emiten) {
if (urutKolom.value === kolom) urutNaik.value = !urutNaik.value;
else { urutKolom.value = kolom; urutNaik.value = false; }
}
</script>
<template>
<input v-model="cari" type="search" placeholder="Cari kode atau namaβ¦"
aria-label="Cari emiten" />
<table>
<thead>
<tr>
<th><button @click="urutkan('kode')">Kode</button></th>
<th><button @click="urutkan('nama')">Nama</button></th>
<th><button @click="urutkan('kapitalisasi')">Kapitalisasi</button></th>
</tr>
</thead>
<tbody>
<tr v-for="e in hasil" :key="e.kode">
<td>{{ e.kode }}</td>
<td><a :href="`/perusahaan/${e.kode}`">{{ e.nama }}</a></td>
<td class="angka">{{ e.kapitalisasi.toLocaleString("id-ID") }}</td>
</tr>
</tbody>
</table>
<p v-if="hasil.length === 0">Tidak ada emiten yang cocok.</p>
</template>
[...disaring].sort() β perhatikan salinannya. Array.sort() mengubah
array di tempat. Meng-sort props.data langsung berarti memutasi props, yang di Vue
menghasilkan peringatan dan perilaku yang sulit ditebak saat komponen dirender ulang. Ini bug yang lolos
review karena kodenya "bekerja" β sampai suatu saat tidak.
Perhatikan juga localeCompare(β¦, "id"). Tanpa locale, pengurutan nama perusahaan berbahasa
Indonesia mengikuti urutan kode karakter, bukan urutan alfabet yang diharapkan pembaca.
Versi server: URL sebagai state
---
// src/pages/perusahaan/index.astro
const SkemaKueri = z.object({
hal: z.coerce.number().int().positive().max(500).default(1),
urut: z.enum(["kode", "nama", "kapitalisasi"]).default("kapitalisasi"),
arah: z.enum(["asc", "desc"]).default("desc"),
q: z.string().max(100).default(""),
});
const kueri = SkemaKueri.parse(Object.fromEntries(Astro.url.searchParams));
const { baris, total } = await cariPerusahaan(kueri);
// Halaman hasil pencarian tidak layak masuk indeks
const noindex = kueri.q !== "" || kueri.hal > 1;
---
Menaruh state di URL adalah keputusan yang membayar dirinya berkali-kali. Hasil filter bisa dibagikan lewat tautan, tombol Kembali bekerja seperti yang diharapkan, halamannya bisa di-cache di edge per kombinasi parameter, dan tabelnya berfungsi penuh tanpa JavaScript. Tidak ada satu pun dari itu yang kamu dapat kalau state-nya hidup di dalam komponen Vue.
noindex di baris terakhir juga penting: tanpanya, kombinasi filter menghasilkan ribuan
URL nyaris identik yang akan dirayapi Google dan dianggap konten tipis.
Debounce untuk pencarian
import { ref, watch } from "vue";
const cari = ref("");
const hasil = ref<Emiten[]>([]);
let timer: ReturnType<typeof setTimeout>;
let batal: AbortController | null = null;
watch(cari, (q) => {
clearTimeout(timer);
timer = setTimeout(async () => {
batal?.abort(); // batalkan request sebelumnya
batal = new AbortController();
try {
const res = await fetch(`/api/perusahaan.json?q=${encodeURIComponent(q)}`, {
signal: batal.signal,
});
hasil.value = (await res.json()).data;
} catch (e) {
if ((e as Error).name !== "AbortError") console.error(e);
}
}, 300);
});
Dua mekanisme, dua masalah berbeda. Debounce mencegah request per ketikan. Abort mencegah jawaban lama tiba setelah jawaban baru dan menimpanya β bug "hasil pencarian tidak cocok dengan yang saya ketik" yang klasik dan sangat membingungkan pengguna.
Baris yang sangat banyak
Di atas ~2.000 baris DOM, browser mulai tersendat saat menggulir. Tiga jalan keluar, berurutan dari yang paling disarankan:
| Cara | Kapan |
|---|---|
| Paginasi server | Hampir selalu. Paling sederhana, ramah SEO, ramah tanpa-JS |
content-visibility: auto di CSS | Perbaikan satu baris untuk daftar panjang yang sudah ada |
| Virtual scrolling | Hanya kalau menggulir mulus benar-benar syarat produk |
tbody tr { content-visibility: auto; contain-intrinsic-size: auto 48px; }
Baris CSS itu menyuruh browser melewati rendering baris yang di luar layar. Untuk tabel panjang, ini sering cukup β dan jauh lebih murah daripada memasang library virtual scroll beserta bug aksesibilitasnya.
Latihan: bangun tabel emiten versi klien dengan sortir dan filter. Lalu bangun versi server yang state-nya di URL. Bandingkan dua hal: ukuran HTML halamannya, dan apakah tabelnya masih berfungsi setelah JavaScript dimatikan di browser. Terakhir, hitung berapa banyak baris di data aslimu β angka itu yang menentukan versi mana yang benar untuk portalmu.
Rangkuman ini sengaja dipangkas ke bagian yang dipakai di roadmap. Buka sumber aslinya saat kamu butuh detail lengkap atau referensi parameter.