Grafik tanpa membengkakkan bundel
Satu library grafik bisa lebih besar dari seluruh sisa JavaScript halamanmu. Pilihannya bukan library mana, tapi apakah kamu benar-benar membutuhkan satu.
Intisari
- Sparkline dan bar chart sederhana lebih baik ditulis sebagai SVG β nol JavaScript, terindeks, sempurna di semua ukuran.
- Library grafik hanya untuk yang benar-benar interaktif: zoom, tooltip presisi, banyak seri.
- Muat library-nya dengan
import()dinamis di dalamonMounted, bukan di atas berkas. - Grafik wajib punya tinggi tetap sebelum dirender, atau ia merusak Cumulative Layout Shift.
- Sediakan tabel data sebagai alternatif β untuk pembaca layar, dan untuk saat JavaScript gagal.
Pertanyaan pertama: apakah butuh library?
| Kebutuhan | Jawaban | Biaya JS |
|---|---|---|
| Sparkline harga saham | SVG buatan sendiri | 0 KB |
| Bar chart perbandingan sektor | SVG atau CSS grid | 0 KB |
| Donut komposisi pemegang saham | SVG buatan sendiri | 0 KB |
| Grafik harga 5 tahun dengan zoom | Library | ~60β80 KB |
| Candlestick dengan indikator teknikal | Library khusus | ~150 KB+ |
Sparkline sebagai SVG murni di Astro
---
// src/components/Sparkline.astro β tanpa JavaScript sama sekali
interface Props { nilai: number[]; lebar?: number; tinggi?: number; }
const { nilai, lebar = 120, tinggi = 32 } = Astro.props;
const min = Math.min(...nilai);
const maks = Math.max(...nilai);
const rentang = maks - min || 1;
const titik = nilai
.map((v, i) => {
const x = (i / (nilai.length - 1)) * lebar;
const y = tinggi - ((v - min) / rentang) * tinggi;
return `${x.toFixed(1)},${y.toFixed(1)}`;
})
.join(" ");
const naik = nilai[nilai.length - 1]! >= nilai[0]!;
const persen = (((nilai.at(-1)! - nilai[0]!) / nilai[0]!) * 100).toFixed(2);
---
<svg width={lebar} height={tinggi} viewBox={`0 0 ${lebar} ${tinggi}`}
role="img" aria-label={`Tren ${naik ? "naik" : "turun"} ${persen} persen`}>
<polyline points={titik} fill="none" stroke-width="1.5"
stroke={naik ? "#0a7d3f" : "#c0392b"}
stroke-linejoin="round" stroke-linecap="round" />
</svg>
Dua puluh baris, nol kilobyte JavaScript, dirender di server, ikut di HTML yang di-cache, dan terbaca
pembaca layar lewat aria-label. Untuk tabel berisi 300 emiten yang masing-masing punya
sparkline, ini bedanya antara halaman ringan dan halaman yang membekukan ponsel.
role="img" plus aria-label itu wajib, bukan tambahan. Tanpanya,
pembaca layar akan membacakan SVG sebagai elemen tanpa makna β atau mencoba membacakan isinya, yang lebih
buruk. Satu kalimat yang menjelaskan apa yang ditunjukkan grafik ini sudah cukup, dan lebih
berguna daripada grafik itu sendiri bagi sebagian pembaca.
Kalau memang butuh library: muat malas
// src/components/GrafikHarga.vue
<script setup lang="ts">
import { ref, onMounted, onUnmounted, shallowRef } from "vue";
const props = defineProps<{ kode: string }>();
const kanvas = ref<HTMLCanvasElement | null>(null);
const memuat = ref(true);
const galat = ref(false);
// shallowRef: instance Chart tidak perlu jadi reaktif dalam-dalam.
// Membungkusnya dengan ref() biasa membuat Vue menelusuri seluruh
// isi objeknya dan itu mahal sekali.
const chart = shallowRef<any>(null);
onMounted(async () => {
try {
// Dinamis: library-nya baru diunduh SEKARANG, bukan saat halaman dimuat.
const [{ Chart, registerables }, data] = await Promise.all([
import("chart.js"),
fetch(`/api/harga/${props.kode}.json`, { signal: AbortSignal.timeout(5000) })
.then((r) => { if (!r.ok) throw new Error(); return r.json(); }),
]);
Chart.register(...registerables);
chart.value = new Chart(kanvas.value!, {
type: "line",
data: {
labels: data.tanggal,
datasets: [{ data: data.harga, borderWidth: 1.5, pointRadius: 0 }],
},
options: {
responsive: true,
maintainAspectRatio: false,
animation: false, // hemat CPU di ponsel
plugins: { legend: { display: false } },
},
});
} catch {
galat.value = true;
} finally {
memuat.value = false;
}
});
onUnmounted(() => chart.value?.destroy());
</script>
<template>
<!-- Tinggi tetap SEBELUM grafik ada: tidak ada pergeseran tata letak -->
<figure class="grafik" style="height: 320px; position: relative">
<canvas ref="kanvas" v-show="!memuat && !galat"></canvas>
<p v-if="memuat" class="tengah">Memuat grafikβ¦</p>
<p v-if="galat" class="tengah">
Grafik tidak dapat dimuat. <a :href="`/perusahaan/${kode}/harga`">Lihat sebagai tabel</a>
</p>
</figure>
</template>
Tiga hal yang membuat perbedaan besar
| Praktik | Kenapa |
|---|---|
import() dinamis di onMounted | Library tidak masuk bundel utama; ia diunduh hanya oleh pembaca yang menggulir sampai grafiknya |
| Tinggi tetap di kontainer | CLS tetap nol. Ini salah satu Core Web Vitals yang dinilai Google |
animation: false | Animasi grafik memakan CPU signifikan di ponsel kelas menengah β dan tidak ada yang merindukannya |
shallowRef untuk instance chart | Reaktivitas dalam pada objek besar adalah penyebab lag yang tidak kelihatan |
destroy() di onUnmounted | Tanpa ini, canvas dan listener-nya bocor saat navigasi dengan ClientRouter |
Selalu sediakan versi non-grafik
<GrafikHarga client:visible kode={emiten.kode} />
<details class="data-mentah">
<summary>Lihat data sebagai tabel</summary>
<table>
<caption>Harga penutupan {emiten.kode}, 30 hari terakhir</caption>
<thead><tr><th>Tanggal</th><th>Penutupan</th></tr></thead>
<tbody>
{harga.map((h) => (
<tr><td>{formatTanggal(h.tanggal)}</td><td>{h.tutup.toLocaleString("id-ID")}</td></tr>
))}
</tbody>
</table>
</details>
Tabel di dalam <details> ini melayani empat hal sekaligus: pembaca layar, pembaca tanpa
JavaScript, mesin pencari β yang tidak bisa membaca canvas sama sekali β dan pembaca yang memang ingin
angkanya. Untuk portal data, angka yang bisa disalin sering lebih berharga daripada grafiknya.
Latihan: buat Sparkline.astro versi SVG dan pasang di setiap baris tabel emiten.
Ukur ukuran HTML dan JS halamannya. Lalu buat versi yang memakai library grafik untuk hal yang sama, dan
bandingkan keduanya di Lighthouse β perhatikan khususnya skor Total Blocking Time dan Cumulative Layout
Shift.
Rangkuman ini sengaja dipangkas ke bagian yang dipakai di roadmap. Buka sumber aslinya saat kamu butuh detail lengkap atau referensi parameter.