Pada tiga bagian sebelumnya, perubahan state dipicu panggilan demo yang terukur. Di browser pemicunya berbeda sifatnya: event input menyala setiap kali satu karakter masuk, sehingga mengetik javascript pemula berarti tujuh belas event. Bila setiap event langsung merender ulang seluruh kartu, browser mengerjakan tujuh belas kali render untuk satu frasa — sementara enam belas render pertama berbasis kata kunci yang sudah usang digantikan ketikan berikutnya.

Bagian terakhir ini menyelesaikannya dengan tiga keputusan yang saling melengkapi: debounce menunda render sampai ketikan benar-benar berhenti, penghitung render memberi angka pasti tentang seberapa sering daftar dirender, dan DocumentFragment menjaga setiap render tetap satu operasi penempelan meski isinya dua puluh dua kartu. Semuanya ditopang antarmuka yang menghubungkan empat kontrol ke module logika teruji — dan tiga uji debounce baru menutup suite pengujian proyek menjadi dua puluh enam.

Debounce: Menunda Render sampai Ketikan Berhenti #

Aturan debounce: sebuah function dibungkus sehingga function aslinya hanya dijalankan setelah pemanggil berhenti memanggilnya selama jeda tertentu, dan setiap panggilan baru membatalkan jeda sebelumnya. Dua bahan penyusunnya sudah kita pelajari: closure untuk menyimpan id timer, dan pasangan setTimeout–clearTimeout dari chapter Timers. Berikut fungsinya di src/daftar.mjs:

js src/daftar.mjs — debounce
// Debounce: bungkus fn agar hanya dijalankan setelah pemanggil berhenti
// memanggilnya selama jedaMs. Panggilan baru membatalkan timer sebelumnya.
export function debounce(fn, jedaMs) {
  let idTimer = null;
  return function terjadwal(...args) {
    clearTimeout(idTimer);
    idTimer = setTimeout(() => fn(...args), jedaMs);
  };
}

Mari kita bedah perannya satu per satu:

  1. Baris 4 (let idTimer = null): idTimer hidup di dalam closure debounce, bukan di dalam terjadwal. Karena itulah nilainya bertahan antar panggilan: panggilan kedua masih mengingat id timer panggilan pertama.
  2. Baris 5 (return function terjadwal(...args)): debounce tidak menjalankan fn — ia mengembalikan function pengganti. ...args mengumpulkan seluruh argumen agar diteruskan utuh ke fn saat waktunya tiba, dan nama terjadwal membuatnya jelas di stack trace.
  3. Baris 6 (clearTimeout(idTimer)): timer panggilan sebelumnya dibatalkan sebelum sempat berjalan. Inilah seluruh kunci debounce: callback lama tidak pernah dieksekusi begitu ada ketikan baru.
  4. Baris 7 (idTimer = setTimeout(() => fn(...args), jedaMs)): panggilan terakhir yang dijadwalkan, membawa argumen terakhir. Setelah jedaMs berlalu tanpa panggilan lanjutan, fn berjalan sekali.

Membuktikan Debounce dari Terminal #

Perilaku debounce bisa dibuktikan dengan angka pasti — asalkan timer yang dipakai pendek. Demo berikut memakai jeda 60 milidetik dan menghitung setiap eksekusi:

// demo-debounce.mjs — membuktikan perilaku debounce dengan menghitung panggilan.
import { debounce } from './daftar.mjs';

let jumlahPanggilan = 0;
const renderPalsu = debounce((teks) => {
  jumlahPanggilan += 1;
  console.log(`  render #${jumlahPanggilan} untuk kata kunci "${teks}"`);
}, 60);

console.log('Jumlah render awal         :', jumlahPanggilan);
renderPalsu('jav');
renderPalsu('java');
renderPalsu('javascript');
console.log('Selesai tiga kali ketikan  :', jumlahPanggilan, 'render');

setTimeout(() => {
  console.log('Setelah jeda 200ms berlalu :', jumlahPanggilan, 'render');
}, 200);
bash
node demo-debounce.mjs

Output yang Dihasilkan:

text
Jumlah render awal         : 0
Selesai tiga kali ketikan  : 0 render
  render #1 untuk kata kunci "javascript"
Setelah jeda 200ms berlalu : 1 render

Baris kedua adalah inti buktinya: tiga panggilan berurutan menghasilkan nol render, karena dua timer pertama selalu dibatalkan timer berikutnya. render #1 baru tercetak setelahnya — membawa kata kunci terakhir javascript, bukan jav yang pertama. Urutan cetaknya juga mengajarkan satu hal tentang event loop: callback debounce dijadwalkan 60 milidetik di masa depan, sehingga script utama menyelesaikan log ketiganya lebih dulu; callback berjalan saat event loop sampai ke timer. Mari kita telusuri demonya:

  1. Baris 4–8 (renderPalsu): function tiruan yang berperan sebagai render — satu penghitung dan satu log. Jeda 60 milidetik sengaja pendek supaya demo selesai cepat; bentuk pemanggilannya sama dengan yang dipakai antarmuka.
  2. Baris 11–13 (renderPalsu('jav') sampai renderPalsu('javascript')): simulasi tiga ketikan beruntun tanpa jeda — keadaan pengguna yang mengetik cepat.
  3. Baris 14 (console.log('Selesai tiga kali ketikan ...')): dijalankan segera setelah panggilan ketiga, jauh sebelum jeda berlalu, sehingga angkanya 0.
  4. Baris 16–18 (setTimeout(..., 200)): pemeriksaan akhir setelah jeda nyata. Angkanya 1 — bukan tiga.

Jumlah Render: Ukuran Keberhasilan yang Deterministik #

Untuk menyimpulkan “antarmuka ini sudah efisien”, kita butuh angka yang bisa dibandingkan. Mengukur durasi render dengan performance.now() menghasilkan angka yang berubah-ubah antar mesin — persis seperti kolom duration_ms pada node --test. Ukuran yang lebih jujur adalah hitungan: berapa kali daftar dirender. Satu variabel jumlahRender bertambah setiap kali function render berjalan, dan nilainya ditampilkan di baris status. Angkanya sama di mesin mana pun: tiga ketikan beruntun diikuti satu jeda harus berarti tepat dua render — bukan tujuh belas. Variabel ini hidup di antarmuka, jadi sekarang saatnya memasang papan kendalinya.

Antarmuka Lengkap: index.html dan styles.css #

Berkas src/index.html.txt adalah salinan halaman katalog lengkap: panel kontrol, baris status, dan daftar hasil. Untuk mencobanya, salin berkas ini menjadi index.html di samping data.mjs dan daftar.mjs, lalu buka lewat server lokal:

html index.html.txt
<!DOCTYPE html>
<html lang="id">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Katalog Buku — Pencarian dan Filter</title>
  <link rel="stylesheet" href="../styles/styles.css">
</head>
<body>
  <main class="katalog">
    <h1>Katalog Buku</h1>

    <section class="panel-kontrol">
      <label class="kolom-cari">
        <span>Cari judul atau penulis</span>
        <input id="input-cari" type="search" placeholder="misalnya: javascript pemula">
      </label>

      <label>
        <span>Kategori</span>
        <select id="pilih-kategori">
          <option value="semua">Semua</option>
          <option value="Pemrograman">Pemrograman</option>
          <option value="Novel">Novel</option>
          <option value="Sejarah">Sejarah</option>
          <option value="Bisnis">Bisnis</option>
          <option value="Anak">Anak</option>
        </select>
      </label>

      <label>
        <span>Urutkan</span>
        <select id="pilih-urutan">
          <option value="judul-asc">Judul A→Z</option>
          <option value="judul-desc">Judul Z→A</option>
          <option value="harga-asc">Harga termurah</option>
          <option value="harga-desc">Harga termahal</option>
          <option value="rating-desc">Rating tertinggi</option>
          <option value="rating-asc">Rating terendah</option>
        </select>
      </label>

      <label>
        <span>Harga sampai</span>
        <input id="input-harga" type="range" min="35000" max="150000" step="5000" value="150000">
        <output id="label-harga">Rp150.000</output>
      </label>
    </section>

    <p id="status" class="status"></p>
    <ul id="hasil" class="hasil"></ul>
  </main>
  <script type="module">
    import { BUKU } from './data.mjs';
    import {
      buatState,
      debounce,
      prosesDaftar,
      ubahHargaMaks,
      ubahKataKunci,
      ubahKategori,
      ubahUrutan,
    } from './daftar.mjs';

    const inputCari = document.querySelector('#input-cari');
    const pilihKategori = document.querySelector('#pilih-kategori');
    const pilihUrutan = document.querySelector('#pilih-urutan');
    const inputHarga = document.querySelector('#input-harga');
    const labelHarga = document.querySelector('#label-harga');
    const status = document.querySelector('#status');
    const hasil = document.querySelector('#hasil');

    // Slider harga punya nilai awal 150000; state mengikuti nilai itu.
    let state = ubahHargaMaks(buatState(), 150000);
    let jumlahRender = 0;

    function renderDaftar() {
      jumlahRender += 1;
      const data = prosesDaftar(state, BUKU);

      // Bangun seluruh item di dalam DocumentFragment, lalu pasang sekaligus.
      const fragmen = document.createDocumentFragment();
      for (const buku of data) {
        const item = document.createElement('li');
        item.className = 'kartu-buku';

        const judul = document.createElement('h2');
        judul.textContent = buku.judul;

        const keterangan = document.createElement('p');
        keterangan.className = 'keterangan';
        keterangan.textContent = `${buku.penulis} · ${buku.kategori} · rating ${buku.rating}`;

        const harga = document.createElement('p');
        harga.className = 'harga';
        harga.textContent = `Rp${buku.harga.toLocaleString('id-ID')}`;

        item.append(judul, keterangan, harga);
        fragmen.append(item);
      }
      hasil.replaceChildren(fragmen);
      status.textContent = `${data.length} buku ditemukan · dirender ${jumlahRender} kali`;
    }

    // Kolom pencarian adalah input yang paling sering berubah, jadi render
    // ditunda lewat debounce: baru berjalan setelah 250ms tanpa ketikan.
    const renderSetelahMengetik = debounce(renderDaftar, 250);

    inputCari.addEventListener('input', () => {
      state = ubahKataKunci(state, inputCari.value);
      renderSetelahMengetik();
    });

    pilihKategori.addEventListener('change', () => {
      state = ubahKategori(state, pilihKategori.value);
      renderDaftar();
    });

    pilihUrutan.addEventListener('change', () => {
      state = ubahUrutan(state, pilihUrutan.value);
      renderDaftar();
    });

    inputHarga.addEventListener('input', () => {
      state = ubahHargaMaks(state, Number(inputHarga.value));
      labelHarga.textContent = `Rp${Number(inputHarga.value).toLocaleString('id-ID')}`;
      renderDaftar();
    });

    renderDaftar();
  </script>
</body>
</html>

Bagian yang paling menentukan cara kerja antarmuka:

  1. Baris 13–48 (<section class="panel-kontrol">): empat kontrol dengan id berbeda — kolom pencarian, dua select, dan slider harga berpasangan dengan <output>. Nilai value tiap opsi ditulis persis seperti nama state, sehingga listener tidak perlu memetakan ulang pilihan ke kata lain.
  2. Baris 53 (<script type="module">): module ES berjalan di browser juga. daftar.mjs yang diimpor di sini adalah berkas yang sama dengan yang diuji node --test — satu sumber logika untuk dua lingkungan.
  3. Baris 74–75 (let state = ubahHargaMaks(...) dan let jumlahRender = 0): state awal mengikuti nilai slider yang memang berdiri di 150000, dan penghitung render mulai dari nol.
  4. Baris 107 (const renderSetelahMengetik = debounce(renderDaftar, 250)): satu-satunya jalur yang didebounce adalah input pencarian — pemanggil yang paling sering berubah.
  5. Baris 109–112 (inputCari.addEventListener('input', ...)): pemisahan yang penting — state diperbarui sekarang pada setiap ketikan, rendernya ditunda. Bila state ikut ditunda, klik cepat pada select kategori bisa merender daftar dengan kata kunci lama; dengan pemisahan ini state selalu akurat.
  6. Baris 114–128 (listener lain): kategori dan urutan memakai event change yang menyala sekali per pilihan; slider memakai input dengan render langsung — menggeser slider sambil melihat jumlah hasil berubah memang bagian dari rasa katalog, dan setiap langkahnya satu perubahan step 5000.
  7. Baris 130 (renderDaftar()): render pertama dijalankan tanpa menunggu interaksi, sehingga halaman tidak pernah tampil kosong.

Tampilannya dikerjakan styles/styles.css: panel kontrol berbentuk grid dua kolom (kolom pencarian membentang dua kolom lewat .kolom-cari), kartu buku tersusun sebagai daftar berbayangan tipis, harga ditandai hijau tebal, dan baris status tampil abu-abu kecil. Salinan berkasnya ikut dapat diunduh:

css styles.css
/* styles.css — tampilan katalog buku pencarian dan filter */

* {
  box-sizing: border-box;
}

body {
  margin: 0;
  padding: 24px 16px;
  background-color: #f3f4f6;
  color: #1f2937;
  font-family: system-ui, sans-serif;
}

.katalog {
  max-width: 640px;
  margin: 0 auto;
}

h1 {
  margin: 0 0 16px;
  font-size: 1.6rem;
}

.panel-kontrol {
  display: grid;
  grid-template-columns: repeat(2, 1fr);
  gap: 12px;
  padding: 16px;
  background-color: #ffffff;
  border-radius: 12px;
  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
}

.panel-kontrol label {
  display: flex;
  flex-direction: column;
  gap: 4px;
  font-size: 0.85rem;
  font-weight: 600;
}

.panel-kontrol input,
.panel-kontrol select {
  padding: 8px 10px;
  font-size: 1rem;
  border: 1px solid #d1d5db;
  border-radius: 8px;
  background-color: #ffffff;
  font-family: inherit;
}

.kolom-cari {
  grid-column: span 2;
}

.status {
  margin: 16px 0 8px;
  font-size: 0.9rem;
  color: #6b7280;
}

.hasil {
  list-style: none;
  margin: 0;
  padding: 0;
  display: grid;
  gap: 10px;
}

.kartu-buku {
  padding: 12px 16px;
  background-color: #ffffff;
  border-radius: 10px;
  box-shadow: 0 1px 4px rgba(0, 0, 0, 0.08);
}

.kartu-buku h2 {
  margin: 0 0 4px;
  font-size: 1.05rem;
}

.kartu-buku .keterangan {
  margin: 0 0 6px;
  font-size: 0.85rem;
  color: #6b7280;
}

.kartu-buku .harga {
  margin: 0;
  font-weight: 700;
  color: #047857;
}

Render Batch dengan DocumentFragment #

Function renderDaftar mengganti seluruh isi daftar setiap kali berjalan — dan debounce menjamin pergantiannya tidak lebih sering dari yang perlu. Pertanyaan tersisa: bagaimana menggantinya dengan murah. Jawabannya sudah kita pelajari di chapter DOM Fragments: menyusun node di luar pohon dokumen, lalu menempelkannya sekali. Mari kita bedah function ini:

  1. Baris 79 (const data = prosesDaftar(state, BUKU)): pipeline murni dari Bagian 3 menyaring dan mengurutkan katalog berdasarkan state saat ini. Tidak ada logika yang ditulis ulang di sini.
  2. Baris 82 (const fragmen = document.createDocumentFragment()): wadah di luar pohon dokumen, dibuat sekali per render.
  3. Baris 83–100 (for (const buku of data) { ... }): setiap buku menjadi satu <li> berisi judul, keterangan, dan harga — diisi lewat textContent sesuai aturan Safe DOM Manipulation. Karena tujuannya fragmen, dua puluh dua iterasi tidak menyentuh halaman yang sedang tampil.
  4. Baris 101 (hasil.replaceChildren(fragmen)): satu operasi mengganti seluruh anak #hasil — kartu lama pergi dan kartu baru masuk dalam satu langkah, tanpa jeda tampil kosong di antaranya.
  5. Baris 102 (status.textContent = ...): baris status mencantumkan jumlah hasil dan jumlah render — dua angka yang bisa dilihat pengguna.

Bila kartu langsung di-append satu per satu ke #hasil, halaman menerima dua puluh dua perubahan terpisah per render; lewat fragment, penyusunan ditunda menjadi satu penempelan. jsdom tidak punya mesin layout sehingga penghematan reflow tidak bisa diukur di terminal — tetapi jumlah operasinya bisa dihitung.

Verifikasi Render di Terminal dengan jsdom #

Untuk membuktikan seluruh jalur interaksi — event input, debounce, penghitung, dan fragment — kita jalankan skrip verifikasi jsdom yang disimpan di luar bundle course. Skrip ini menyalin persis logika render dari index.html.txt di atas, memasang penghitung sementara pada document.createDocumentFragment, lalu memeriksa sembilan hal tentang render awal, tiga event input tanpa jeda, dan keadaan setelah jeda. Keluaran console yang dicatat:

text
OK    render awal: jumlah kartu
OK    render awal: satu DocumentFragment
OK    render awal: status
OK    render awal: judul kartu pertama
OK    sebelum jeda: debounce menahan render
OK    setelah jeda: render bertambah tepat satu
OK    hasil "javascript": jumlah kartu
OK    hasil "javascript": status
OK    hasil "javascript": tetap satu batch fragmen

Selesai: 9 kasus, 0 gagal.

Dua baris di tengah membawa kesimpulan utamanya. sebelum jeda: debounce menahan render — tiga event input untuk jav, java, javascript hanya memperbarui state; jumlahRender masih 1 dan fragment masih 1. setelah jeda: render bertambah tepat satu — setelah 400 milidetik render menjadi tepat 2, kartu tersisa 2 hasil untuk javascript, dan statusnya 2 buku ditemukan · dirender 2 kali. Tidak ada satu pun angka di keluaran ini yang berubah antar mesin — semuanya hitungan, bukan pengukuran waktu.

Uji Debounce dan Rekap Uji Proyek #

Berikut daftar.mjs lengkap pada akhir proyek — seluruh logika dari tiga bagian, kini termasuk debounce:

js daftar.mjs
// daftar.mjs — logika inti pencarian dan filter katalog buku.
// Semua fungsi murni: menerima data atau state, mengembalikan hasil baru,
// tanpa menyentuh DOM sehingga dapat diuji dengan node --test.

export function buatState() {
  return {
    kataKunci: '',
    kategori: 'semua',
    urutan: 'judul-asc',
    hargaMin: null,
    hargaMaks: null,
  };
}

export function ubahKataKunci(state, teks) {
  return { ...state, kataKunci: teks };
}

export function ubahKategori(state, kategori) {
  return { ...state, kategori };
}

export function ubahUrutan(state, urutan) {
  return { ...state, urutan };
}

export function ubahHargaMaks(state, nilai) {
  return { ...state, hargaMaks: nilai };
}

// Normalisasi teks: huruf kecil, tanda baca ringan diganti spasi,
// spasi berlebih dirapatkan. Dipakai untuk kata kunci maupun data.
export function normalisasiTeks(teks) {
  return teks
    .toLowerCase()
    .replace(/[.,;:!?"'()\-]/g, ' ')
    .trim()
    .replace(/\s+/g, ' ');
}

// Cari: kembalikan buku yang memuat SEMUA kata kunci di judul atau penulis.
// Kata kunci kosong (setelah normalisasi) mengembalikan salinan seluruh data.
export function cari(data, kataKunci) {
  const kata = normalisasiTeks(kataKunci).split(' ').filter((k) => k !== '');
  if (kata.length === 0) {
    return [...data];
  }
  return data.filter((buku) => {
    const sumber = normalisasiTeks(`${buku.judul} ${buku.penulis}`);
    return kata.every((k) => sumber.includes(k));
  });
}

// Filter kategori: 'semua' melewati semua buku, selain itu cocok persis.
export function filterKategori(data, kategori) {
  if (kategori === 'semua') {
    return [...data];
  }
  return data.filter((buku) => buku.kategori === kategori);
}

// Filter rentang harga (inklusif). Batas null berarti sisi itu tanpa batas.
export function filterHarga(data, hargaMin, hargaMaks) {
  return data.filter((buku) => {
    const lewatBatasBawah = hargaMin === null || buku.harga >= hargaMin;
    const lewatBatasAtas = hargaMaks === null || buku.harga <= hargaMaks;
    return lewatBatasBawah && lewatBatasAtas;
  });
}

// Peta urutan: nama urutan -> fungsi pembanding dua buku.
const KOMPARATOR = new Map([
  ['judul-asc', (a, b) => a.judul.localeCompare(b.judul, 'id')],
  ['judul-desc', (a, b) => b.judul.localeCompare(a.judul, 'id')],
  ['harga-asc', (a, b) => a.harga - b.harga],
  ['harga-desc', (a, b) => b.harga - a.harga],
  ['rating-desc', (a, b) => b.rating - a.rating],
  ['rating-asc', (a, b) => a.rating - b.rating],
]);

// Urutkan tanpa mengubah data asli: salin dulu, baru sort.
export function urutkan(data, urutan) {
  const komparator = KOMPARATOR.get(urutan);
  if (komparator === undefined) {
    throw new Error(`Urutan tidak dikenal: ${urutan}`);
  }
  return [...data].sort(komparator);
}

// Pipeline utuh: cari -> filter kategori -> filter harga -> urutkan.
export function prosesDaftar(state, data) {
  const hasilCari = cari(data, state.kataKunci);
  const hasilKategori = filterKategori(hasilCari, state.kategori);
  const hasilHarga = filterHarga(hasilKategori, state.hargaMin, state.hargaMaks);
  return urutkan(hasilHarga, state.urutan);
}

// Debounce: bungkus fn agar hanya dijalankan setelah pemanggil berhenti
// memanggilnya selama jedaMs. Panggilan baru membatalkan timer sebelumnya.
export function debounce(fn, jedaMs) {
  let idTimer = null;
  return function terjadwal(...args) {
    clearTimeout(idTimer);
    idTimer = setTimeout(() => fn(...args), jedaMs);
  };
}

// Ringkasan satu baris untuk demo dan tampilan sederhana.
export function ringkasBuku(buku) {
  return `${buku.judul} — ${buku.penulis} (Rp${buku.harga.toLocaleString('id-ID')})`;
}

Perilaku yang sudah dibuktikan demo kini dikunci uji. Tiga test bergabung ke kelompok debounce di tests/daftar.test.mjs, dengan debounce masuk daftar import. Ujinya menunggu timer nyata — bukan tiruan — lewat helper delay di bagian atas berkas uji, dengan jeda pendek 40 milidetik supaya suite tetap cepat:

js tests/daftar.test.mjs — tambahan bagian 4
const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));

describe('debounce', () => {
  test('menunda eksekusi sampai jeda berlalu', async () => {
    const panggilan = [];
    const kirim = debounce((teks) => panggilan.push(teks), 40);

    kirim('jav');
    assert.deepStrictEqual(panggilan, []);

    await delay(100);
    assert.deepStrictEqual(panggilan, ['jav']);
  });

  test('hanya panggilan terakhir yang dijalankan', async () => {
    const panggilan = [];
    const kirim = debounce((teks) => panggilan.push(teks), 40);

    kirim('jav');
    kirim('java');
    kirim('javascript');
    await delay(120);

    assert.deepStrictEqual(panggilan, ['javascript']);
  });

  test('tanpa panggilan lanjutan, hasil tetap sampai ke fn', async () => {
    const panggilan = [];
    const kirim = debounce((teks) => panggilan.push(teks), 40);

    kirim('novel');
    await delay(100);

    assert.strictEqual(panggilan.length, 1);
  });
});

Ketiganya mengunci satu perilaku per test: satu panggilan sampai ke fn setelah jeda, tiga panggilan beruntun hanya menjalankan yang terakhir, dan panggilan tanpa penerus tetap terkirim. Jalankan seluruh uji proyek:

bash
cd pencarian-dan-filter
node --test

Output yang Dihasilkan:

text
▶ data
  ✔ katalog berisi 20+ buku dengan lima properti (0.59213ms)
✔ data (1.327492ms)
▶ buatState
  ✔ state awal menampilkan semua kategori terurut judul (0.209859ms)
✔ buatState (0.346199ms)
▶ perubahan state
  ✔ ubahKataKunci memperbarui kata kunci (0.146522ms)
  ✔ ubahKategori dan ubahUrutan mengganti pilihan aktif (0.095462ms)
  ✔ fungsi ubah mengembalikan state baru, bukan mengubah yang lama (0.121252ms)
✔ perubahan state (0.548689ms)
▶ normalisasiTeks
  ✔ membuat huruf kecil dan memangkas spasi (0.228222ms)
  ✔ mengganti tanda baca ringan dengan spasi (0.135591ms)
✔ normalisasiTeks (0.579533ms)
▶ cari
  ✔ kata kunci kosong mengembalikan salinan seluruh data (1.013437ms)
  ✔ satu kata mencocokkan judul dan penulis (0.335185ms)
  ✔ cuplikan kata ikut cocok tanpa perlu kata utuh (0.192928ms)
  ✔ banyak kata: semua kata harus ada (0.113186ms)
  ✔ tanda baca dan spasi berlebih pada kata kunci diabaikan (0.067904ms)
  ✔ tidak ada yang cocok mengembalikan array kosong (0.558338ms)
✔ cari (2.498825ms)
▶ filterKategori
  ✔ kategori terpilih menyaring katalog (0.128164ms)
  ✔ kategori "semua" mengembalikan salinan seluruh data (0.039752ms)
✔ filterKategori (0.228844ms)
▶ filterHarga
  ✔ rentang min-maks bersifat inklusif (0.089518ms)
  ✔ batas null berarti sisi itu tanpa batas (0.037919ms)
✔ filterHarga (0.174386ms)
▶ urutkan
  ✔ judul-asc memakai localeCompare bahasa Indonesia (6.655329ms)
  ✔ harga-desc menempatkan buku termahal di awal (0.112865ms)
  ✔ rating-desc menjaga urutan asli saat rating seri (0.074326ms)
  ✔ urutan tidak dikenal ditolak (0.380912ms)
✔ urutkan (7.330928ms)
▶ prosesDaftar
  ✔ pipeline menggabungkan cari, filter, dan urutan (0.291479ms)
  ✔ state awal mengembalikan seluruh katalog terurut judul (0.070741ms)
✔ prosesDaftar (0.428177ms)
▶ debounce
  ✔ menunda eksekusi sampai jeda berlalu (99.786322ms)
  ✔ hanya panggilan terakhir yang dijalankan (119.961948ms)
  ✔ tanpa panggilan lanjutan, hasil tetap sampai ke fn (99.890177ms)
✔ debounce (320.102004ms)
ℹ tests 26
ℹ suites 10
ℹ pass 26
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 398.022449

Dua puluh enam uji dalam sepuluh kelompok, semuanya lulus. Kelompok debounce di akhir berdurasi sekitar 320 milidetik karena ketiga test sungguhan menunggu timer 40–120 milidetik berlalu — satu-satunya bagian suite yang sengaja menunggu. Angka durasi lainnya berbeda-beda di mesin Anda.

Ringkasan Bagian 4 dan Penutup Proyek #

Bagian ini melengkapi sisi interaksi: debounce menjadikan render sabar terhadap ketikan, penghitung jumlahRender memberi ukuran keberhasilan yang sama di mesin mana pun, DocumentFragment dengan replaceChildren menjaga setiap render tetap satu operasi penempelan, dan index.html.txt bersama styles.css menghubungkan empat kontrol ke module logika yang terkunci dua puluh enam uji.

Empat bagian proyek ini menyusun satu pola yang sama dari ujung ke ujung. Data berdiri sebagai array of objects yang diperlakukan baca saja; logika hidup sebagai fungsi murni di module bebas DOM — normalisasi, pencarian multi kata, filter, enam urutan, pipeline satu arah; antarmuka hanya dua tugas: meneruskan event menjadi pembaruan state, dan merender hasil pipeline ke layar. Karena pemisahan itu, seluruh logika diuji dari terminal dengan satu perintah dan tampilan diverifikasi lewat jsdom. Pola ini persis yang dipakai proyek kalkulator sebelumnya — dan kini Anda pernah membangunnya dua kali dari nol.

Bila ingin meneruskan katalog ini, dua latihan lanjutan mengikuti pola yang sama: menyorot cuplikan kata kunci pada setiap judul hasil, dan menampilkan jumlah buku per kategori di samping pilihan select. Keduanya dimulai dari tempat yang sama — fungsi murni baru di daftar.mjs, uji di tests/daftar.test.mjs, baru dipasang di antarmuka. Kebiasaan itu, bukan kodenya, yang merupakan hasil akhir proyek ini.

Berkas Sumber

Berkas pendukung halaman ini dapat diunduh dan dijalankan langsung sesuai penjelasan di atas.

styles.css

Unduh berkas
css
/* styles.css — tampilan katalog buku pencarian dan filter */

* {
  box-sizing: border-box;
}

body {
  margin: 0;
  padding: 24px 16px;
  background-color: #f3f4f6;
  color: #1f2937;
  font-family: system-ui, sans-serif;
}

.katalog {
  max-width: 640px;
  margin: 0 auto;
}

h1 {
  margin: 0 0 16px;
  font-size: 1.6rem;
}

.panel-kontrol {
  display: grid;
  grid-template-columns: repeat(2, 1fr);
  gap: 12px;
  padding: 16px;
  background-color: #ffffff;
  border-radius: 12px;
  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
}

.panel-kontrol label {
  display: flex;
  flex-direction: column;
  gap: 4px;
  font-size: 0.85rem;
  font-weight: 600;
}

.panel-kontrol input,
.panel-kontrol select {
  padding: 8px 10px;
  font-size: 1rem;
  border: 1px solid #d1d5db;
  border-radius: 8px;
  background-color: #ffffff;
  font-family: inherit;
}

.kolom-cari {
  grid-column: span 2;
}

.status {
  margin: 16px 0 8px;
  font-size: 0.9rem;
  color: #6b7280;
}

.hasil {
  list-style: none;
  margin: 0;
  padding: 0;
  display: grid;
  gap: 10px;
}

.kartu-buku {
  padding: 12px 16px;
  background-color: #ffffff;
  border-radius: 10px;
  box-shadow: 0 1px 4px rgba(0, 0, 0, 0.08);
}

.kartu-buku h2 {
  margin: 0 0 4px;
  font-size: 1.05rem;
}

.kartu-buku .keterangan {
  margin: 0 0 6px;
  font-size: 0.85rem;
  color: #6b7280;
}

.kartu-buku .harga {
  margin: 0;
  font-weight: 700;
  color: #047857;
}