← Semua pembelajaran / Astro Nol β†’ Portal Berita
Fase 4 Β· Island Vue & Interaktivitas

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.

Sumber asli vuejs.org Resmi Rangkuman ~9 menit baca

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-for wajib punya :key yang 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 klienProses di server
Jumlah baris≀ 1.000Berapa pun
Sortir & filterSeketikaSatu round-trip
Ukuran HTMLBertambah sebesar datanyaTetap kecil
Bisa di-cache di edgeYa, tapi mahalYa, dan murah
Jalan tanpa JavaScriptTidakYa, 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:

CaraKapan
Paginasi serverHampir selalu. Paling sederhana, ramah SEO, ramah tanpa-JS
content-visibility: auto di CSSPerbaikan satu baris untuk daftar panjang yang sudah ada
Virtual scrollingHanya 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.