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):
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 dipanggilMari kita telaah baris-baris pentingnya:
- Baris 5–13 (callback observer): setiap record dibaca dengan
m.typesebagai penandanya. RecordchildListmemuataddedNodesdanremovedNodes— keduanyaNodeList, jadilength-nya0bila tidak ada node pada sisi itu. RecordattributesmembawaattributeName. - Baris 15–19 (
pengamat.observe(daftar, opsi)): opsi menentukan apa yang diamati. Tanpaattributes: true, perubahanclasstidak dilaporkan; tanpasubtree: true, perubahan atribut padaitem1(anak daridaftar) juga lolos —subtreememperluas pengamatan ke seluruh keturunan. - Baris 22–24 (perubahan pertama): satu
liditambahkan. Callback berjalan setelah tugas selesai dengan satu recordchildList: +1 -0, lalu total diperbarui. - Baris 26–31 (dua perubahan, satu panggilan):
classList.adddanappendChildterjadi dalam tugas yang sama, sehingga callback menerima dua record sekaligus — recordattributesdulu, kemudianchildList, sesuai urutan kejadiannya. Inilah penggabungan per tugas yang menyelamatkan kita dari callback beruntun. - Baris 33–35 (penghapusan):
remove()menghasilkan recordchildList: +0 -1, dan total kembali ke satu judul. - Baris 37–41 (
disconnect): setelahdisconnect()dipanggil,appendChilddi baris berikutnya tidak memicu apa pun — pengamat sudah dilepas, dan keluaran berhenti tepat setelahdisconnect dipanggil.
Opsi observe dan Isi Record #
Opsi observe bersifat aditif — setiap bendera membuka satu jenis pengamatan:
| Opsi | Jenis perubahan yang dilaporkan |
|---|---|
childList: true | node anak ditambah atau dihapus pada target |
attributes: true | nilai atribut target berubah |
characterData: true | isi teks node teks berubah |
subtree: true | seluruh opsi di atas diperluas ke keturunan target |
attributeOldValue: true | record attributes menyertakan oldValue |
characterDataOldValue: true | record 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:
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// 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);