Daftar buku di halaman toko Budi tidak hanya diubah oleh script miliknya sendiri. Widget rekomendasi, ekstensi browser, dan script pihak ketiga menambah atau menghapus item di daftar yang sama — dan setiap kali itu terjadi, badge jumlah judul di header harus ikut diperbarui. Karena kita tidak mengontrol kode pengubahnya, memasang pembaruan di setiap titik perubahan tidak mungkin dilakukan; memeriksa daftar berulang-ulang dengan timer justru memboroskan kerja mesin.

MutationObserver menyelesaikan situasi ini: kita mendaftarkan pengamat pada sebuah elemen, dan callback kita dipanggil secara asinkron setiap kali DOM di dalam cakupannya berubah — siapa pun pengubahnya. Yang bisa diamati meliputi penambahan/penghapusan anak (childList), perubahan atribut (attributes), dan perubahan teks (characterData).

Satu sifat desain perlu dipahami sejak awal: callback tidak dijalankan seketika di tengah kode yang melakukan perubahan. Browser mengumpulkan seluruh record perubahan dari satu tugas, lalu mengirimkannya sebagai satu panggilan callback setelah tugas selesai — pada microtask, sama seperti penjadwalan promise. Desain ini mencegah callback beruntun saat satu operasi mengubah banyak hal sekaligus, dan menjaga kode pengamat tidak mengganggu kode pengubah di tengah pekerjaannya.

Membuat Observer dan Membaca MutationRecord #

Aturannya dua langkah: new MutationObserver(callback) lalu pengamat.observe(target, opsi). Callback menerima array MutationRecord — satu record untuk setiap perubahan yang tercatat sejak panggilan terakhir. Berkas berikut mengamati daftar buku dan menyesuaikan jumlah judulnya:

// awasi-daftar.js — MutationObserver: merespons perubahan daftar buku

const daftar = document.querySelector("#daftar-buku");

const pengamat = new MutationObserver((mutasi) => {
  for (const m of mutasi) {
    console.log(
      `tipe ${m.type}: +${m.addedNodes.length} -${m.removedNodes.length}` +
        (m.attributeName ? ` (atribut: ${m.attributeName})` : "")
    );
  }
  console.log(`total judul: ${daftar.children.length}`);
});

pengamat.observe(daftar, {
  childList: true,
  attributes: true,
  subtree: true,
});

// --- perubahan pada daftar (di aplikasi nyata: dari script lain/pengunjung) ---
const item1 = document.createElement("li");
item1.textContent = "Kamus Mini Bahasa";
daftar.appendChild(item1);

setTimeout(() => {
  item1.classList.add("ditandai"); // perubahan atribut pada anak butuh subtree
  const item2 = document.createElement("li");
  item2.textContent = "Cerita di Sekitar Kita";
  daftar.appendChild(item2);
}, 10);

setTimeout(() => {
  daftar.firstElementChild.remove();
}, 40);

setTimeout(() => {
  pengamat.disconnect(); // berhenti mengamati
  daftar.appendChild(document.createElement("li")); // tidak lagi terpantau
  console.log("disconnect dipanggil");
}, 80);

Perubahan pada contoh ini sengaja dipisah ke beberapa setTimeout karena pengiriman record bersifat asinkron: perubahan dalam satu tugas dikumpulkan lalu dilaporkan sebagai satu panggilan callback setelah tugas selesai. Pola itu meniru aplikasi nyata, di mana perubahan datang dari event pengunjung dan script lain yang berjalan di tugas berbeda. Keluaran berikut dicatat dari eksekusi nyata berkas ini di lingkungan DOM jsdom; console browser menghasilkan deretan yang sama:

Output yang Dihasilkan (console browser):

text
tipe childList: +1 -0
total judul: 1
tipe attributes: +0 -0 (atribut: class)
tipe childList: +1 -0
total judul: 2
tipe childList: +0 -1
total judul: 1
disconnect dipanggil

Mari kita telaah baris-baris pentingnya:

  1. Baris 5–13 (callback observer): setiap record dibaca dengan m.type sebagai penandanya. Record childList memuat addedNodes dan removedNodes — keduanya NodeList, jadi length-nya 0 bila tidak ada node pada sisi itu. Record attributes membawa attributeName.
  2. Baris 15–19 (pengamat.observe(daftar, opsi)): opsi menentukan apa yang diamati. Tanpa attributes: true, perubahan class tidak dilaporkan; tanpa subtree: true, perubahan atribut pada item1 (anak dari daftar) juga lolos — subtree memperluas pengamatan ke seluruh keturunan.
  3. Baris 22–24 (perubahan pertama): satu li ditambahkan. Callback berjalan setelah tugas selesai dengan satu record childList: +1 -0, lalu total diperbarui.
  4. Baris 26–31 (dua perubahan, satu panggilan): classList.add dan appendChild terjadi dalam tugas yang sama, sehingga callback menerima dua record sekaligus — record attributes dulu, kemudian childList, sesuai urutan kejadiannya. Inilah penggabungan per tugas yang menyelamatkan kita dari callback beruntun.
  5. Baris 33–35 (penghapusan): remove() menghasilkan record childList: +0 -1, dan total kembali ke satu judul.
  6. Baris 37–41 (disconnect): setelah disconnect() dipanggil, appendChild di baris berikutnya tidak memicu apa pun — pengamat sudah dilepas, dan keluaran berhenti tepat setelah disconnect dipanggil.

Opsi observe dan Isi Record #

Opsi observe bersifat aditif — setiap bendera membuka satu jenis pengamatan:

OpsiJenis perubahan yang dilaporkan
childList: truenode anak ditambah atau dihapus pada target
attributes: truenilai atribut target berubah
characterData: trueisi teks node teks berubah
subtree: trueseluruh opsi di atas diperluas ke keturunan target
attributeOldValue: truerecord attributes menyertakan oldValue
characterDataOldValue: truerecord characterData menyertakan oldValue

Tanpa oldValue yang diaktifkan, record hanya memuat kondisi terbaru; dengan opsi itu, nilai sebelum perubahan ikut tersedia pada m.oldValue — berguna untuk mencatat riwayat perubahan. Perlu diingat: characterData hanya melaporkan node teks yang berubah isinya; mengganti textContent elemen menghapus dan membuat node baru, yang tercatat sebagai mutasi childList.

Berhenti, Melanjutkan, dan Membersihkan #

disconnect() menghentikan seluruh pengamatan pada semua target. Perilaku halusnya terlihat di baris 37–41: record dari perubahan yang terjadi pada tugas yang sama dengan disconnect() ikut dibuang — antrean record dikosongkan saat disconnect dipanggil, sehingga callback terakhir tidak menerima perubahan tadi. Bila pengamatan perlu dijalankan lagi, cukup panggil observe() dengan object observer yang sama; pendaftaran baru menandai ulang target dan callback kembali berjalan untuk perubahan berikutnya.

Kombinasi childList, attributes, dan subtree di chapter ini sudah menutup sebagian besar kebutuhan nyata — badge keranjang, penghitung hasil pencarian, atau sinkronisasi tampilan dengan konten yang diubah pihak ketiga. Bersama dua observer sebelumnya, MutationObserver melengkapi tiga arah pengamatan browser: perpotongan viewport, perubahan ukuran, dan perubahan struktur DOM.

oldValue dan Perubahan Isi Teks #

Dua opsi terakhir pada tabel di atas — attributeOldValue dan characterDataOldValue — membuka akses ke m.oldValue, nilai sebelum perubahan terjadi. Tanpa opsi itu, properti oldValue selalu null. Pola pemakaiannya sederhana: aktifkan opsi, lalu bandingkan nilai lama dengan kondisi terkini di dalam callback. Untuk perubahan teks, target pengamatannya adalah node teks, bukan elemen pembungkusnya:

js Mengamati perubahan isi teks dengan oldValue
const pengamat = new MutationObserver((mutasi) => {
  for (const m of mutasi) {
    console.log("dari:", m.oldValue, "->", m.target.textContent);
  }
});

pengamat.observe(nodeTeksStatus, {
  characterData: true,
  characterDataOldValue: true,
});

nodeTeksStatus di sini adalah node teks itu sendiri — misalnya hasil panel.firstChild pada elemen yang isinya murni teks. Dengan opsi di atas, setiap pergantian teks status (misalnya “Mencari posisi…” menjadi “Toko terdekat: Cabang Semporna”) dikirim lengkap dengan nilai sebelumnya, sehingga callback bisa memutuskan reaksinya berdasarkan peralihan yang terjadi, bukan hanya nilai barunya.

Yang Tidak Tertangkap dan Batas Kinerja #

MutationObserver mengamati struktur DOM — bukan segala perubahan pada halaman. Perubahan yang dihitung browser tanpa menyentuh DOM tidak menghasilkan record: posisi gulir, status fokus kolom, hover pointer, dan perubahan gaya yang dihitung (misalnya lebar panel akibat media query) semua luput dari pengamatan. Untuk kelompok itu, alat yang tepat adalah event masing-masing (scroll, focus, dan resize/observer ukuran), atau kombinasi keduanya bila keputusan butuh data dari dua sisi.

Dari sisi kinerja, dua kebiasaan menjaga callback tetap ringan. Pertama, manfaatkan penggabungan: bila satu operasi mengubah seratus item, callback hanya menerima satu array dengan seratus record — proses array itu sekali jalan, jangan memanggil kembali DOM untuk tiap record lebih dulu. Kedua, batasi cakupan: subtree: true pada elemen besar membuat setiap perubahan kecil di seluruh keturunan tercatat. Bila hanya satu bagian yang penting, daftarkan pengamat pada bagian itu.

Method terakhir yang melengkapi pengelolaan adalah takeRecords(): ia mengambil seluruh record yang sudah tercatat tetapi belum dikirim, sekaligus mengosongkan antrean — sehingga panggilan callback berikutnya tidak terjadi. Kegunaannya khusus: memeriksa perubahan seketika di tengah tugas yang sama sebelum browser sempat mengirimkannya. Pemakaian sehari-hari cukup mengandalkan pengiriman otomatisnya.

Berkas Sumber

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

awasi-daftar.js

Unduh berkas
javascript
// awasi-daftar.js — MutationObserver: merespons perubahan daftar buku

const daftar = document.querySelector("#daftar-buku");

const pengamat = new MutationObserver((mutasi) => {
  for (const m of mutasi) {
    console.log(
      `tipe ${m.type}: +${m.addedNodes.length} -${m.removedNodes.length}` +
        (m.attributeName ? ` (atribut: ${m.attributeName})` : "")
    );
  }
  console.log(`total judul: ${daftar.children.length}`);
});

pengamat.observe(daftar, {
  childList: true,
  attributes: true,
  subtree: true,
});

// --- perubahan pada daftar (di aplikasi nyata: dari script lain/pengunjung) ---
const item1 = document.createElement("li");
item1.textContent = "Kamus Mini Bahasa";
daftar.appendChild(item1);

setTimeout(() => {
  item1.classList.add("ditandai"); // perubahan atribut pada anak butuh subtree
  const item2 = document.createElement("li");
  item2.textContent = "Cerita di Sekitar Kita";
  daftar.appendChild(item2);
}, 10);

setTimeout(() => {
  daftar.firstElementChild.remove();
}, 40);

setTimeout(() => {
  pengamat.disconnect(); // berhenti mengamati
  daftar.appendChild(document.createElement("li")); // tidak lagi terpantau
  console.log("disconnect dipanggil");
}, 80);