Saat pelanggan menambah produk ke keranjang, tiga bagian aplikasi tertarik pada kejadian itu: modul log mencatat aktivitas, modul stok memeriksa ketersediaan, dan modul email menyiapkan notifikasi. Cara paling gamblang adalah membuat keranjang memanggil ketiganya satu per satu. Caranya bekerja — tetapi sekarang keranjang harus tahu keberadaan ketiga modul itu, dan setiap modul baru wajib disuntikkan ke dalam kode keranjang. Komponen yang semestinya sederhana menjadi pusat keramaian yang sulit diubah.
Observer pattern menawarkan arah sebaliknya: keranjang tidak memanggil siapa pun; ia hanya menerbitkan kejadian bernama (“produk ditambahkan”), dan bagian aplikasi yang berminat berlangganan kejadian itu sendiri. Kita sudah memakai pola ini tanpa sadar di section Browser Events — addEventListener("click", ...) persis bentuknya. Chapter ini membangun versi mini dari semuanya, dengan on, emit, dan off, lalu memakainya untuk notifikasi keranjang.
Dua Peran: Penerbit dan Pelanggan #
Pola ini hanya punya dua peran. Penerbit (sering disebut subject atau publisher) adalah object yang menerbitkan kejadian — ia tahu kapan sesuatu terjadi, tetapi tidak tahu siapa yang peduli. Pelanggan (disebut observer atau subscriber) adalah function yang mendaftar untuk menerima kejadian tertentu.
Pertemuan keduanya diatur oleh tiga method yang kini menjadi konvensi hampir di semua bahasa dan framework:
| Method | Tugas | Padanan yang sudah kita pakai |
|---|---|---|
on(nama, fn) | Mendaftarkan pelanggan | addEventListener |
emit(nama, data) | Menerbitkan kejadian | — |
off(nama, fn) | Mencabut pelanggan | removeEventListener |
Panggilan emit dijalankan secara sinkron: pelanggan dipanggil satu per satu, selesai dari atas ke bawah, sebelum baris berikutnya berjalan. Node.js punya class EventEmitter bawaan di modul node:events dengan method-method ini, dan banyak library memakainya — kita membangun versi mini sendiri agar mekanismenya tidak terasa seperti benda yang diberikan begitu saja.
Membangun EventEmitter Mini #
Berkas berikut membangun emitter dari Map dan Set, lalu memakainya sebagai notifikasi keranjang dengan dua pelanggan:
// pemberitahu-keranjang.js — EventEmitter mini dengan on, off, dan emit.
// Jalankan di terminal: node pemberitahu-keranjang.js
function buatEmitter() {
const langganan = new Map(); // nama event -> Set function pendengar
return {
// Mendaftarkan pendengar untuk satu nama event.
on(namaEvent, fn) {
if (!langganan.has(namaEvent)) {
langganan.set(namaEvent, new Set());
}
langganan.get(namaEvent).add(fn);
// Mengembalikan cara berhenti berlangganan tanpa mengulang nama event.
return () => this.off(namaEvent, fn);
},
// Mencabut satu pendengar dari satu event.
off(namaEvent, fn) {
langganan.get(namaEvent)?.delete(fn);
},
// Menerbitkan event: semua pendengar event itu dipanggil berurutan.
emit(namaEvent, data) {
const daftar = langganan.get(namaEvent) ?? new Set();
for (const fn of daftar) {
fn(data);
}
},
};
}
// ===== Penerbit: keranjang menerbitkan event, tanpa tahu siapa yang mendengar =====
const keranjang = buatEmitter();
function tambahProduk(emitter, produk) {
console.log(`Menambahkan ${produk.nama}...`);
emitter.emit("produk-ditambah", produk);
}
// ===== Pelanggan 1: mencatat log =====
const catat = (produk) => console.log(` [log] ${produk.nama} masuk keranjang`);
// ===== Pelanggan 2: memeriksa stok =====
const cekStok = (produk) => {
if (produk.stok <= 0) {
console.log(` [stok] ${produk.nama} habis, pesanan ditunda`);
}
};
keranjang.on("produk-ditambah", catat);
keranjang.on("produk-ditambah", cekStok);
tambahProduk(keranjang, { nama: "Mouse", stok: 5 });
tambahProduk(keranjang, { nama: "Kabel USB", stok: 0 });
// Pelanggan log berhenti berlangganan; pemeriksa stok tetap mendengar.
keranjang.off("produk-ditambah", catat);
tambahProduk(keranjang, { nama: "Keyboard", stok: 3 });Output yang Dihasilkan:
Menambahkan Mouse...
[log] Mouse masuk keranjang
Menambahkan Kabel USB...
[log] Kabel USB masuk keranjang
[stok] Kabel USB habis, pesanan ditunda
Menambahkan Keyboard...Mari kita telaah baris-baris pentingnya:
- Baris 5 (
const langganan = new Map();): struktur datanya satu Map yang memetakan nama event ke daftar pelanggannya. Kuncinya string bebas —"produk-ditambah","pesanan-selesai"— sehingga satu emitter bisa melayani banyak jenis kejadian sekaligus. - Baris 12 (
langganan.get(namaEvent).add(fn);): pelanggan per nama event disimpan dalam Set. Sama seperti store pada chapter sebelumnya, Set otomatis mencegah satu function terdaftar dua kali untuk event yang sama. - Baris 14 (
return () => this.off(namaEvent, fn);):onmengembalikan function berhenti-langganan yang sudah membawa nama event dan pelanggannya. Pola ini menghilangkan pengulangan: pendaftar tidak perlu menyimpan pasangan nama–function sendiri untuk nanti dicabut. - Baris 18 (
langganan.get(namaEvent)?.delete(fn);):offpada event yang tak pernah didaftarkan tidak boleh melempar galat, maka optional chaining membuat pencabutan aman dalam keadaan apa pun. - Baris 22 (
const daftar = langganan.get(namaEvent) ?? new Set();):emituntuk event tanpa pelanggan bukanlah kesalahan — ia hanya tidak mengerjakan apa pun. Perilaku ini penting: penerbit bekerja normal sebelum ada satu pun pelanggan. - Baris 33–36 (
tambahProduk): penerbit hanya memanggilemitdan menyerahkan datanya. Ia tidak tahu ada log, tidak tahu ada pemeriksa stok — dan tidak perlu tahu. - Baris 55 (
keranjang.off("produk-ditambah", catat);): setelah baris ini,catatdicabut. Pada penambahan Keyboard, baris[log]tidak muncul lagi, sementaracekStoktetap terpasang — bukti pencabutan hanya menjangkau satu pelanggan, bukan seluruh event.
Ketergantungan yang Ikut Berkurang #
Keluaran di atas memperlihatkan manfaat terbesarnya: tambahProduk tetap tiga baris meskipun sekarang ada dua pihak yang menanggapi. Bila minggu depan tim keuangan minta ikut diberi tahu, kita cukup menambah satu baris keranjang.on(...) di tempat lain — kode keranjang tidak disentuh sama sekali. Perbandingan berikut merangkum perbedaannya:
| Memanggil langsung | Observer pattern | |
|---|---|---|
| Penerbit tahu pelanggan | Ya, satu per satu | Tidak sama sekali |
| Menambah pelanggan baru | Menyunting kode penerbit | Menambah satu on(...) |
| Menghapus pelanggan | Menyunting kode penerbit | Satu panggilan off(...) |
| Kejadian tanpa pelanggan | Butuh pemeriksaan if | Berjalan normal, tanpa pendengar |
Pola ini juga menjelaskan perilaku DOM yang sudah kita pakai lama: elemen tidak tahu daftar pendengarnya dari dalam logikanya, dan dua pemanggilan addEventListener pada satu tombol berarti dua pelanggan yang sama-sama dipanggil — persis perilaku emitter mini kita.
Menata Nama Event dan Payload #
Dua keputusan kecil menjaga emitter tetap mudah dirawat saat event-nya bertambah. Pertama, nama event ditulis sebagai frasa kejadian yang sudah terjadi: produk-ditambah, pesanan-selesai, bukan tambah-produk. Alasannya, pelanggan bereaksi pada kejadian yang sudah selesai; waktu lampau membuat urutannya jelas dan konsisten dengan DOMContentLoaded pada section Browser Events. Kedua, isi data dikirim sebagai object dengan bentuk yang stabil — { nama, stok } pada contoh di atas. Menambah field baru tidak merusak pelanggan lama, sedangkan mengubah arti field lama akan merusak semua pelanggannya sekaligus; karena itu field baru lebih aman daripada makna baru.
Satu perilaku emit juga patut dipegang: kejadian tidak antri. Bila emit berjalan saat belum ada pelanggan, kejadian itu hilang tanpa jejak — tidak ada yang menyimpannya untuk pemanggil yang mendaftar belakangan. Untuk kejadian yang wajib diterima (misalnya hasil permintaan yang datang sekali), pastikan pendaftaran on(...) terjadi sebelum pemanggilan yang menembakkan event. Pada contoh kita urutannya sudah benar: pendaftaran di baris 48–49, pemanggilan tambahProduk mulai baris 51.
Dengan emitter, komponen bisa saling memberi tahu tanpa saling mengenal. Pertanyaan berikutnya muncul di sisi pembuatan object: ketika satu jenis object punya beberapa varian dengan bentuk berbeda, di mana keputusan varian sebaiknya ditempatkan? Chapter Factory Pattern menjawabnya dengan satu function pembuat.
Berkas Sumber
Berkas pendukung halaman ini dapat diunduh dan dijalankan langsung sesuai penjelasan di atas.
pemberitahu-keranjang.js
Unduh berkas// pemberitahu-keranjang.js — EventEmitter mini dengan on, off, dan emit.
// Jalankan di terminal: node pemberitahu-keranjang.js
function buatEmitter() {
const langganan = new Map(); // nama event -> Set function pendengar
return {
// Mendaftarkan pendengar untuk satu nama event.
on(namaEvent, fn) {
if (!langganan.has(namaEvent)) {
langganan.set(namaEvent, new Set());
}
langganan.get(namaEvent).add(fn);
// Mengembalikan cara berhenti berlangganan tanpa mengulang nama event.
return () => this.off(namaEvent, fn);
},
// Mencabut satu pendengar dari satu event.
off(namaEvent, fn) {
langganan.get(namaEvent)?.delete(fn);
},
// Menerbitkan event: semua pendengar event itu dipanggil berurutan.
emit(namaEvent, data) {
const daftar = langganan.get(namaEvent) ?? new Set();
for (const fn of daftar) {
fn(data);
}
},
};
}
// ===== Penerbit: keranjang menerbitkan event, tanpa tahu siapa yang mendengar =====
const keranjang = buatEmitter();
function tambahProduk(emitter, produk) {
console.log(`Menambahkan ${produk.nama}...`);
emitter.emit("produk-ditambah", produk);
}
// ===== Pelanggan 1: mencatat log =====
const catat = (produk) => console.log(` [log] ${produk.nama} masuk keranjang`);
// ===== Pelanggan 2: memeriksa stok =====
const cekStok = (produk) => {
if (produk.stok <= 0) {
console.log(` [stok] ${produk.nama} habis, pesanan ditunda`);
}
};
keranjang.on("produk-ditambah", catat);
keranjang.on("produk-ditambah", cekStok);
tambahProduk(keranjang, { nama: "Mouse", stok: 5 });
tambahProduk(keranjang, { nama: "Kabel USB", stok: 0 });
// Pelanggan log berhenti berlangganan; pemeriksa stok tetap mendengar.
keranjang.off("produk-ditambah", catat);
tambahProduk(keranjang, { nama: "Keyboard", stok: 3 });