Aplikasi cuaca kita kini berperilaku baik pada satu pencarian — tetapi perhatikan pengguna yang sesungguhnya. Pengguna salah eja lalu memperbaikinya: itu dua permintaan. Pengguna menelusuri Bandung, Surabaya, lalu kembali ke Bandung: tiga permintaan, dan permintaan terakhir sebenarnya sudah pernah dijawab. Pengguna yang mengetik pada kolom pencarian menghasilkan satu permintaan per tombol bila setiap ketikan langsung dikirim. Di sisi lain, dua permintaan yang berjalan bersamaan bisa berbalas urutan: jawaban yang lambat tiba belakangan dan menimpa jawaban yang lebih baru di layar.
Bagian penutup proyek ini menambahkan tiga teknik yang mengatasi ketiganya, dan ketiganya bertumpu pada konsep yang sudah kita pelajari: cache berbasis Map dengan masa kedaluwarsa (TTL) menghemat permintaan berulang, debounce menunda pengiriman sampai pengguna berhenti mengetik, dan AbortController membatalkan permintaan lama yang usang. Ketiganya ditulis sebagai fungsi murni di cuaca.mjs, diuji dengan node --test, lalu dirangkai di antarmuka.
Cache: Tidak Bertanya Ulang pada Server #
Data cuaca tidak berubah setiap detik; menjawab Bandung dua kali dalam satu menit dengan permintaan jaringan yang sama adalah pekerjaan yang bisa dihemat. Cache kita menyimpan hasil permintaan selama jangka waktu tertentu — melewatinya, entri dianggap kedaluwarsa dan permintaan dilakukan lagi:
// Cache sederhana berbasis Map: kota -> { data, waktu }. Entri dianggap
// kedaluwarsa bila usianya melewati ttlMs. Fungsi sekarang disuntikkan
// (dependency injection) agar uji bisa memajukan waktu tanpa menunggu.
export function buatCache({ ttlMs = 300_000, sekarang = () => Date.now() } = {}) {
const simpanan = new Map();
return {
simpan(kota, data) {
simpanan.set(kota, { data, waktu: sekarang() });
},
ambil(kota) {
const entri = simpanan.get(kota);
if (entri === undefined) {
return null;
}
if (sekarang() - entri.waktu > ttlMs) {
simpanan.delete(kota);
return null;
}
return entri.data;
},
};
}Mari kita bedah baris demi baris:
- Baris 4 (
export function buatCache({ ttlMs = 300_000, sekarang = () => Date.now() } = {})): cache dibuat lewat factory, bukan sebagai objek global. Default TTL-nya lima menit —300_000milidetik, dipisah garis bawah agar angkanya terbaca. - Baris 4 (
sekarang = () => Date.now()): sumber waktu ikut disuntikkan lewat parameter. Ini pola dependency injection: kode produksi memakai jam sungguhan, sedangkan uji dan demo menyuntikkan jam tiruan yang geraknya dikendalikan, sehingga uji tidak perlu menunggu lima menit. - Baris 5 (
const simpanan = new Map();): penyimpanannya sebuahMaptertutup di dalam closure — hanyasimpandanambilyang punya akses, dan tidak ada pihak luar yang bisa mengubah isinya langsung. - Baris 9 (
simpanan.set(kota, { data, waktu: sekarang() });): entri menyimpan sepasang nilai: datanya dan waktu penyimpanannya. Usia entri dihitung belakangan dari selisih waktu, bukan dicatat sebagai “sisa umur” yang harus dikurangi terus-menerus. - Baris 16–18 (
if (sekarang() - entri.waktu > ttlMs) { simpanan.delete(kota); return null; }): bila usia entri melewatittlMs, entri dihapus dariMapdannulldikembalikan. Pemeriksaambilyang menghapus entri kedaluwarsa — cache menata dirinya sendiri secara bertahap, tanpa timer pembersih. - Baris 20 (
return entri.data;): entri yang masih segar mengembalikan datanya. Pemanggil dibuat konvensinya sederhana:nullberarti “harus memuat ulang”.
Debounce: Menunggu Sampai Pengguna Berhenti Mengetik #
Mengirim permintaan untuk setiap ketikan boros: ketikan Bandung yang terdiri atas tujuh tombol tidak perlu tujuh permintaan — cukup satu, setelah pengguna selesai. debounce membungkus fungsi apa pun dengan aturan itu: eksekusi ditunda, dan setiap pemanggilan baru di tengah jeda membatalkan penundaan sebelumnya:
// Menunda eksekusi fn sampai jedaMs berlalu tanpa panggilan baru;
// panggilan di tengah jeda membatalkan timer sebelumnya.
export function debounce(fn, jedaMs) {
let idTimer = null;
return (...argumen) => {
clearTimeout(idTimer);
idTimer = setTimeout(() => fn(...argumen), jedaMs);
};
}Mari kita bedah mekanismenya:
- Baris 3 (
export function debounce(fn, jedaMs)): menerima fungsi yang akan ditunda dan panjang jeda dalam milidetik. Fungsi aslinya tidak diubah sama sekali —debouncemengembalikan pembungkus baru. - Baris 4 (
let idTimer = null;): pengenal timer terakhir disimpan dalam closure yang dibagi semua pemanggilan pembungkus. Variabel inilah ingatan bersama yang membuat pemanggilan saling membatalkan. - Baris 7 (
clearTimeout(idTimer);): setiap pemanggilan baru menghapus timer yang masih menunggu. Baris inilah inti debounce: pemanggilan yang datang sebelum jeda selesai membuat pemanggilan sebelumnya batal. - Baris 8 (
idTimer = setTimeout(() => fn(...argumen), jedaMs);): timer baru dipasang. Argumen diproses lewat spread...argumen, sehingga pemanggilan terakhir — dengan argumen terakhirnya — yang benar-benar dieksekusi setelah jeda selesai.
Hasilnya terasa seperti pengguna yang sabar: dari serangkaian ketikan cepat, hanya yang terakhir yang memicu permintaan.
AbortController: Membatalkan yang Usang #
Cache dan debounce mengurangi jumlah permintaan; keduanya tidak menutup satu kemungkinan lagi: dua permintaan tetap bisa berjalan hampir bersamaan, misalnya ketika pengguna menekan Enter dua kali cepat. Bila jawaban permintaan pertama tiba setelah permintaan kedua, jawaban yang lebih tua justru menimpa yang lebih baru. Solusinya adalah pembatalan: permintaan lama tidak perlu dijawab lagi begitu ada pencarian baru.
fetch mendukung pembatalan lewat opsi signal yang berasal dari AbortController. Perubahan pada muatCuaca hanya dua baris — sisanya persis versi Bagian 3:
// Mengambil data cuaca satu kota dari server. Bila permintaan perlu
// dibatalkan, pemanggil menyisipkan signal dari AbortController.
export async function muatCuaca(kota, { baseUrl = URL_DASAR, signal } = {}) {
const url = `${baseUrl}/cuaca?kota=${encodeURIComponent(kota)}`;
const respons = await fetch(url, { signal });- Baris 3 (
{ baseUrl = URL_DASAR, signal } = {}): opsisignalditambahkan ke parameter kedua. Pemanggil yang tidak mengirim apa pun tetap bekerja seperti sebelumnya —fetchmenerimasignal: undefinedtanpa protes. - Baris 5 (
const respons = await fetch(url, { signal });): signal diteruskan kefetch. Begitupengendali.abort()dipanggil, janji darifetchlangsung ditolak dengan galat bernamaAbortError— meskipun server belum menjawab apa pun.
Pembatalan bersifat kerja sama dua sisi: klien berhenti menunggu, sedangkan server tetap saja memproses permintaannya sampai selesai. Permintaan yang dibatalkan bukan kegagalan yang perlu ditunjukkan ke pengguna — kita akan melihat cara menyaringnya di bagian antarmuka.
Demo: Tiga Optimasi dalam Satu Berkas #
Berkas demo-optimasi.mjs mempraktikkan ketiganya berurutan: cache dengan jam tiruan, debounce dengan timer pendek, lalu pembatalan terhadap server tiruan yang sengaja lambat — semuanya tanpa internet:
// demo-optimasi.mjs — cache, debounce, dan AbortController tanpa internet.
import http from 'node:http';
import { buatCache, debounce, muatCuaca } from './cuaca.mjs';
// 1. Cache dengan jam tiruan (dependency injection): waktu hanya bergerak
// ketika demo memajukannya, sehingga keluarannya selalu sama.
let waktuSekarang = 0;
const cache = buatCache({ ttlMs: 60_000, sekarang: () => waktuSekarang });
cache.simpan('Bandung', { kota: 'Bandung', suhu: 24.6 });
console.log('t=0 detik ->', cache.ambil('Bandung') === null ? 'meleset (miss)' : 'kena (hit)');
waktuSekarang = 30_000;
console.log('t=30 detik ->', cache.ambil('Bandung') === null ? 'meleset (miss)' : 'kena (hit)');
waktuSekarang = 61_000;
console.log('t=61 detik ->', cache.ambil('Bandung') === null ? 'meleset (miss)' : 'kena (hit)');
console.log('t=61 detik ->', cache.ambil('Bandung') === null ? 'entri kedaluwarsa sudah dihapus' : 'masih ada');
// 2. Debounce: empat ketikan dalam jendela 50 ms, hanya satu eksekusi.
let jumlahEksekusi = 0;
let argumenTerakhir = null;
const cari = debounce((kota) => {
jumlahEksekusi += 1;
argumenTerakhir = kota;
}, 50);
cari('Ban');
cari('Bandu');
cari('Bandun');
cari('Bandung');
await new Promise((selesai) => setTimeout(selesai, 120));
console.log('4 ketikan cepat ->', jumlahEksekusi, 'eksekusi, argumen terakhir:', argumenTerakhir);
// 3. AbortController: server tiruan sengaja lambat (300 ms), lalu
// permintaan dibatalkan pada 30 ms — jauh sebelum jawaban datang.
const server = http.createServer((permintaan, jawaban) => {
setTimeout(() => {
if (jawaban.destroyed) return; // klien sudah pergi, jangan ditulisi
jawaban.setHeader('Content-Type', 'application/json');
jawaban.end(JSON.stringify({ kota: 'Bandung', suhu: 24.6, kelembapan: 78, deskripsi: 'Berawan sebagian' }));
}, 300);
});
await new Promise((selesai) => server.listen(18080, selesai));
const pengendali = new AbortController();
const janji = muatCuaca('Bandung', { signal: pengendali.signal });
setTimeout(() => pengendali.abort(), 30);
try {
await janji;
} catch (galat) {
console.log('permintaan dibatalkan ->', galat.name);
}
server.closeAllConnections();
await new Promise((selesai) => server.close(selesai));Jalankan dari folder bagian ini:
node demo-optimasi.mjsOutput yang Dihasilkan:
t=0 detik -> kena (hit)
t=30 detik -> kena (hit)
t=61 detik -> meleset (miss)
t=61 detik -> entri kedaluwarsa sudah dihapus
4 ketikan cepat -> 1 eksekusi, argumen terakhir: Bandung
permintaan dibatalkan -> AbortErrorEnam baris keluaran untuk tiga teknik. Empat baris pertama membuktikan perilaku cache: t=0 dan t=30 masih kena (hit) karena di bawah TTL 60 detik, sedangkan t=61 menjadi meleset (miss) — dan baris keempat menegaskan entri kedaluwarsa benar-benar sudah dihapus dari Map, bukan hanya dilaporkan kedaluwarsa. Baris kelima membuktikan debounce: empat pemanggilan cari hanya menghasilkan satu eksekusi, dengan argumen Bandung dari ketikan terakhir. Baris keenam membuktikan pembatalan: muatCuaca ditolak dengan AbortError, bukan ApiError maupun TypeError.
Ada dua detail demo yang layak digarisbawahi:
- Baris 7–8 (
let waktuSekarang = 0;danconst cache = buatCache({ ..., sekarang: () => waktuSekarang });): jam tiruan berupa satu variabel yang dibaca fungsisekarang. Demo memajukan waktu dengan menugaskanwaktuSekarang = 61_000— satu baris pengganti satu menit penantian. - Baris 37 (
if (jawaban.destroyed) return;): server lambat tetap mencoba menjawab setelah 300 ms, padahal klien sudah membatalkan pada 30 ms. Pemeriksaanjawaban.destroyedmencegah server menulisi koneksi yang sudah ditutup — pembatalan memang kerja sama dua sisi. - Baris 53–54 (
server.closeAllConnections(); ... server.close(selesai);):closesaja tidak cukup karena koneksi pembatalan mungkin masih menggantung;closeAllConnectionsmemutuskannya, dan proses Node berakhir dengan rapi tanpa menggantung.
Antarmuka Utuh #
Ketiga teknik dirangkai di index.html.txt versi akhir. Fungsi renderKeadaan tidak berubah dari Bagian 3; yang berubah adalah alur pencariannya:
<!DOCTYPE html>
<html lang="id">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Aplikasi Cuaca</title>
<link rel="stylesheet" href="../styles/styles.css">
</head>
<body>
<main class="panel">
<h1>Cuaca Kota</h1>
<form id="formulir-cari">
<input
id="kolom-kota"
name="kota"
type="text"
placeholder="Contoh: Bandung"
autocomplete="off"
>
<button type="submit">Lihat</button>
</form>
<section id="kartu-cuaca" class="kartu">
<p>Ketik nama kota untuk melihat cuacanya.</p>
</section>
</main>
<script type="module">
import {
buatCache,
debounce,
formatCuaca,
keadaanAwal,
keadaanBerikut,
muatCuaca,
pesanRamah,
} from './cuaca.mjs';
const formulir = document.querySelector('#formulir-cari');
const kolomKota = document.querySelector('#kolom-kota');
const kartu = document.querySelector('#kartu-cuaca');
// Data yang masih segar disimpan di cache, jadi pencarian ulang
// untuk kota yang sama tidak perlu menyentuh server lagi.
const cache = buatCache({ ttlMs: 60_000 });
let keadaan = keadaanAwal();
let pengendali = null;
function renderKeadaan() {
kartu.textContent = '';
if (keadaan.nama === 'kosong') {
const teks = document.createElement('p');
teks.textContent = 'Ketik nama kota untuk melihat cuacanya.';
kartu.append(teks);
return;
}
if (keadaan.nama === 'memuat') {
const teks = document.createElement('p');
teks.className = 'status status--memuat';
teks.textContent = `Memuat data cuaca untuk ${keadaan.kota}...`;
kartu.append(teks);
return;
}
if (keadaan.nama === 'galat') {
const teks = document.createElement('p');
teks.className = 'status status--galat';
teks.textContent = keadaan.pesan;
kartu.append(teks);
return;
}
const tampilan = formatCuaca(keadaan.data);
const judul = document.createElement('h2');
judul.textContent = tampilan.judul;
const daftar = document.createElement('ul');
for (const [label, nilai] of [
['Suhu', tampilan.suhu],
['Kelembapan', tampilan.kelembapan],
['Kondisi', tampilan.deskripsi],
]) {
const item = document.createElement('li');
item.textContent = `${label}: ${nilai}`;
daftar.append(item);
}
kartu.append(judul, daftar);
}
async function jalankanPencarian(kota) {
// Permintaan sebelumnya tidak relevan lagi: batalkan bila masih berjalan.
if (pengendali !== null) {
pengendali.abort();
}
pengendali = new AbortController();
// Cek cache dulu: data yang masih segar tidak perlu dimuat ulang.
const dataTersimpan = cache.ambil(kota);
if (dataTersimpan !== null) {
keadaan = keadaanBerikut(keadaan, { tipe: 'sukses', data: dataTersimpan });
renderKeadaan();
return;
}
keadaan = keadaanBerikut(keadaan, { tipe: 'cari', kota });
renderKeadaan();
try {
const data = await muatCuaca(kota, { signal: pengendali.signal });
cache.simpan(kota, data);
keadaan = keadaanBerikut(keadaan, { tipe: 'sukses', data });
} catch (galat) {
// Pencarian baru yang membatalkan permintaan ini bukan galat:
// jangan tampilkan apa pun, penggantinya sedang berjalan.
if (galat.name === 'AbortError') return;
keadaan = keadaanBerikut(keadaan, { tipe: 'galat', pesan: pesanRamah(galat) });
}
renderKeadaan();
}
// Ketikan pengguna ditunda 300 ms: permintaan baru hanya dikirim
// ketika pengguna berhenti mengetik.
const cariDenganDebounce = debounce(jalankanPencarian, 300);
kolomKota.addEventListener('input', () => {
const kota = kolomKota.value.trim();
if (kota !== '') {
cariDenganDebounce(kota);
}
});
formulir.addEventListener('submit', (event) => {
event.preventDefault();
const kota = kolomKota.value.trim();
if (kota !== '') {
jalankanPencarian(kota); // tombol/Enter: langsung, tanpa menunggu debounce
}
});
renderKeadaan();
</script>
</body>
</html>Fungsi baru jalankanPencarian menampung seluruh kebijakan pencarian:
- Baris 93–96 (
if (pengendali !== null) { pengendali.abort(); } ... pengendali = new AbortController();): permintaan sebelumnya dibatalkan lebih dulu, lalu pengendali baru disiapkan untuk permintaan ini. Urutannya penting: pembatalan selalu terjadi sebelum permintaan baru dikirim. - Baris 99–104 (
const dataTersimpan = cache.ambil(kota); ... return;): cache diperiksa sebelum keadaanmemuatdipasang. Bila datanya masih segar, keadaan langsung melompat kesukses— pengguna bahkan tidak sempat melihat teks memuat, dan tidak ada permintaan jaringan yang keluar. - Baris 110 (
const data = await muatCuaca(kota, { signal: pengendali.signal });): signal pengendali aktif disertakan pada permintaan. Bila pengguna mencari kota lain selagi permintaan ini berjalan, permintaan ini langsung ditolak. - Baris 111 (
cache.simpan(kota, data);): jawaban yang valid disimpan sebelum keadaan diperbarui. Pencarian berikutnya ke kota yang sama akan memanen hasilnya selama TTL 60 detik. - Baris 116 (
if (galat.name === 'AbortError') return;): galat pembatalan disaring dan fungsi keluar tanpa mengubah keadaan — pencarian yang membatalkan permintaan ini sudah memasang keadaannya sendiri. Tanpa filter ini, pengguna yang mengetik cepat akan sempat melihat pesan galat palsu. - Baris 124 (
const cariDenganDebounce = debounce(jalankanPencarian, 300);):jalankanPencariandibungkus debounce 300 ms untuk jalur ketikan. Dua pendengar memakai fungsi yang sama dengan ritme berbeda:inputlewat pembungkus yang menunda,submitlangsung tanpa jeda.
Kunci Seluruh Perilaku dengan Uji #
Tiga kelompok uji terakhir mengunci perilaku optimasi. Uji cache memajukan waktu lewat jam tiruan, uji debounce memakai timer 50 ms yang cukup pendek untuk uji, dan uji pembatalan menyiapkan server lambat kedua di port 18082:
// Import diperluas dengan buatCache dan debounce; di bagian atas berkas
// juga ditambahkan: const URL_LAMBAT = 'http://localhost:18082'.
// Server tiruan port 18081 serta kelompok uji sebelumnya tetap sama ...
describe('buatCache', () => {
test('menyimpan dan mengambil data sebelum TTL habis', () => {
let waktu = 0;
const cache = buatCache({ ttlMs: 60_000, sekarang: () => waktu });
cache.simpan('Bandung', DATA_CUACA.bandung);
waktu = 30_000;
assert.strictEqual(cache.ambil('Bandung'), DATA_CUACA.bandung);
});
test('mengembalikan null dan menghapus entri setelah TTL lewat', () => {
let waktu = 0;
const cache = buatCache({ ttlMs: 60_000, sekarang: () => waktu });
cache.simpan('Bandung', DATA_CUACA.bandung);
waktu = 60_001;
assert.strictEqual(cache.ambil('Bandung'), null);
waktu = 60_002;
// Entri yang kedaluwarsa sudah dihapus; hasilnya tetap null selamanya.
assert.strictEqual(cache.ambil('Bandung'), null);
});
test('mengembalikan null untuk kota yang belum tersimpan', () => {
const cache = buatCache({ sekarang: () => 0 });
assert.strictEqual(cache.ambil('Surabaya'), null);
});
});
describe('debounce', () => {
test('menjalankan sekali dengan argumen terakhir meski dipanggil berkali-kali', async () => {
let jumlah = 0;
let kotaTerakhir = null;
const cari = debounce((kota) => {
jumlah += 1;
kotaTerakhir = kota;
}, 50);
cari('Ban');
cari('Bandu');
cari('Bandung');
await new Promise((selesai) => setTimeout(selesai, 120));
assert.strictEqual(jumlah, 1);
assert.strictEqual(kotaTerakhir, 'Bandung');
});
test('menjalankan kembali setelah jendela jeda selesai', async () => {
let jumlah = 0;
const cari = debounce(() => {
jumlah += 1;
}, 50);
cari();
await new Promise((selesai) => setTimeout(selesai, 80));
cari();
await new Promise((selesai) => setTimeout(selesai, 80));
assert.strictEqual(jumlah, 2);
});
});
describe('muatCuaca dengan pembatalan', () => {
let serverLambat;
before(async () => {
serverLambat = http.createServer((permintaan, jawaban) => {
// Sengaja lambat: respons baru terkirim setelah 200 ms.
setTimeout(() => {
if (jawaban.destroyed) return; // klien sudah pergi, jangan ditulisi
jawaban.setHeader('Content-Type', 'application/json');
jawaban.end(JSON.stringify(DATA_CUACA.bandung));
}, 200);
});
await new Promise((selesai) => serverLambat.listen(18082, selesai));
});
after(async () => {
serverLambat.closeAllConnections();
await new Promise((selesai) => serverLambat.close(selesai));
});
test('permintaan yang dibatalkan ditolak dengan AbortError', async () => {
const pengendali = new AbortController();
const janji = muatCuaca('Bandung', { baseUrl: URL_LAMBAT, signal: pengendali.signal });
setTimeout(() => pengendali.abort(), 20);
await assert.rejects(janji, (galat) => {
assert.strictEqual(galat.name, 'AbortError');
return true;
});
});
});Jalankan dari folder proyek. Ini keluaran lengkap seluruh proyek — tujuh kelompok, dua puluh uji:
node --testOutput yang Dihasilkan:
▶ muatCuaca
✔ mengembalikan data JSON untuk kota yang tersedia (29.174893ms)
✔ melempar ApiError berstatus 404 untuk kota yang tidak ditemukan (6.577665ms)
✔ mengodekan nama kota menjadi URL yang aman (2.959061ms)
✔ muatCuaca (41.287831ms)
▶ formatCuaca
✔ memformat suhu, kelembapan, dan deskripsi (0.440976ms)
✔ membatasi suhu ke satu tempat desimal (0.229386ms)
✔ menyusun baris teks siap tampilkan (0.186586ms)
✔ formatCuaca (1.109476ms)
▶ keadaanBerikut
✔ aksi cari memindahkan keadaan awal ke memuat (0.202806ms)
✔ aksi sukses menyimpan data dan mengosongkan pesan (1.56798ms)
✔ aksi galat menyimpan pesan dan membuang data (1.510713ms)
✔ pencarian baru dari keadaan sukses kembali ke memuat (0.24759ms)
✔ aksi yang tidak dikenal tidak mengubah keadaan (0.237885ms)
✔ keadaanBerikut (7.963048ms)
▶ pesanRamah
✔ ApiError 404 menjadi pesan kota tidak ditemukan (0.403563ms)
✔ ApiError selain 404 menjadi pesan server bermasalah (0.757609ms)
✔ galat jaringan menjadi pesan koneksi (0.32718ms)
✔ pesanRamah (1.674444ms)
▶ buatCache
✔ menyimpan dan mengambil data sebelum TTL habis (0.202315ms)
✔ mengembalikan null dan menghapus entri setelah TTL lewat (0.077023ms)
✔ mengembalikan null untuk kota yang belum tersimpan (0.053055ms)
✔ buatCache (0.437277ms)
▶ debounce
✔ menjalankan sekali dengan argumen terakhir meski dipanggil berkali-kali (120.629514ms)
✔ menjalankan kembali setelah jendela jeda selesai (160.721708ms)
✔ debounce (281.771998ms)
▶ muatCuaca dengan pembatalan
✔ permintaan yang dibatalkan ditolak dengan AbortError (23.948378ms)
✔ muatCuaca dengan pembatalan (25.273857ms)
ℹ tests 20
ℹ suites 7
ℹ pass 20
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 644.115217Dua puluh uji lulus, fail 0. Uji cache menegaskan dua perilaku sekaligus: entri yang masih segar dikembalikan, dan entri kedaluwarsa tidak hanya melaporkan null tetapi benar-benar dihapus — uji kedua memanggil ambil dua kali dan menuntut null pada keduanya. Uji debounce menuntut tepat satu eksekusi dengan argumen terakhir; durasinya sekitar 280 ms karena memang menunggu timer sungguhan, tetapi jeda 50–120 ms-nya jauh lebih pendek daripada jeda produksi 300 ms. Uji pembatalan membuktikan AbortError datang dari muatCuaca yang nyata — server tiruannya sendiri yang lambat, bukan tiruan fetch.
Penutup Proyek #
Aplikasi cuaca utuh di ujung proyek ini: datanya diambil dari server dengan fetch yang memeriksa response.ok, ditampilkan lewat formatter Intl yang menghormati locale, tidak pernah membuat pengguna menebak keadaannya, dan tidak pernah meminta hal yang sama dua kali. Ringkasannya dalam empat keputusan desain yang sama pentingnya dengan kode:
- Logika terpisah dari tampilan.
cuaca.mjstidak menyentuh DOM satu baris pun, sehingga dua puluh uji berjalan di terminal tanpa browser. - Fungsi murni sebagai bata penyusun. Formatter, state machine, cache, dan debounce semuanya menerima input dan mengembalikan output baru — mudah diuji, mudah dirangkai.
- Keadaan yang eksplisit. Empat keadaan dan satu pintu perubahan membuat “layar macet tanpa penjelasan” tidak mungkin terjadi.
- Sopan kepada server dan pengguna. Cache menghemat permintaan, debounce menahan ketikan,
AbortControllermenghentikan yang usang, dan setiap galat diterjemahkan menjadi saran yang bisa dijalankan.
Polanya tidak khusus untuk cuaca. Aplikasi apa pun yang berbicara dengan API — daftar tugas, pencarian produk, ringkasan berita — akan membutuhkan keempat keputusan yang sama, dan kini kita sudah pernah membangunnya dari nol, lengkap dengan ujinya.
Berkas Sumber
Berkas pendukung halaman ini dapat diunduh dan dijalankan langsung sesuai penjelasan di atas.
styles/styles.css
Unduh berkas/* styles.css — tampilan aplikasi cuaca */
body {
margin: 0;
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background-color: #e0f2fe;
font-family: system-ui, sans-serif;
}
.panel {
width: 300px;
padding: 20px;
background-color: #ffffff;
border-radius: 12px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
.panel h1 {
margin: 0 0 12px;
font-size: 1.25rem;
color: #0c4a6e;
}
#formulir-cari {
display: flex;
gap: 8px;
margin-bottom: 12px;
}
#kolom-kota {
flex: 1;
padding: 8px 10px;
border: 1px solid #cbd5e1;
border-radius: 8px;
font-size: 1rem;
}
#formulir-cari button {
padding: 8px 14px;
border: none;
border-radius: 8px;
background-color: #0284c7;
color: #ffffff;
font-size: 1rem;
cursor: pointer;
}
#formulir-cari button:hover {
background-color: #0369a1;
}
.kartu {
min-height: 80px;
padding: 12px;
background-color: #f8fafc;
border: 1px solid #e2e8f0;
border-radius: 8px;
font-size: 0.95rem;
color: #334155;
white-space: pre-wrap;
}
.kartu h2 {
margin: 0 0 8px;
font-size: 1.1rem;
color: #0c4a6e;
}
.kartu ul {
margin: 0;
padding-left: 18px;
}
.kartu li {
margin-bottom: 4px;
}
.status--memuat {
color: #b45309;
}
.status--galat {
color: #b91c1c;
font-weight: 600;
}