Masalah dan Konteks yang Berlaku
Implementasikan kelas EventEmitter dengan empat metode: on(eventName, listener) mendaftarkan listener persisten, once(eventName, listener) mendaftarkan listener sekali pakai, off(eventName, listener) menghapus satu pendaftaran yang cocok, dan emit(eventName, ...args) mengirimkan (dispatch) sebuah event secara sinkron. Nama event dapat berupa string atau Symbol. Fungsi yang sama dapat didaftarkan lebih dari satu kali untuk satu event.
Pertanyaan ini menggunakan subset eksplisit dari semantik bergaya Node. Listener berjalan secara sinkron sesuai urutan pendaftaran. this di dalam fungsi listener biasa adalah emitter itu sendiri. emit mengembalikan true jika event memiliki listener dan false jika tidak, dan nilai kembalian dari listener diabaikan. Pendaftaran once harus dihapus sebelum listener-nya dipanggil sehingga emit yang bersifat reentrant tidak dapat memanggilnya kembali.
Daftar listener untuk satu dispatch ditentukan saat dispatch tersebut dimulai. Jika salah satu listener menghapus listener lain selama dispatch berlangsung, listener yang dihapus tetap berpartisipasi dalam dispatch yang sedang berjalan, tetapi tidak dalam dispatch berikutnya. Listener yang baru ditambahkan akan menunggu hingga dispatch berikutnya. Implementasi ini tidak mereproduksi event khusus error milik Node.js, peringatan jumlah listener, prependListener, iterasi asinkron, atau penangkapan penolakan Promise; hal-hal tersebut termasuk dalam tugas kompatibilitas yang lebih luas.
Apa yang Dinilai oleh Pewawancara
Jawaban yang kuat mendefinisikan kontrak event sebelum memilih struktur penampung (container). Sebuah Map<eventName, entries[]> menemukan array untuk suatu event, dan array tersebut menjaga urutan pendaftaran. Setiap entri menyimpan fungsi asli, apakah itu pendaftaran once, apakah sudah dijalankan (fired), dan apakah masih aktif. Sebuah Set memang membuat penghapusan menjadi mudah tetapi secara diam-diam menggabungkan fungsi-fungsi duplikat, sehingga melanggar aturan pendaftaran duplikat yang telah ditetapkan.
Sinyal kedua adalah membedakan array langsung (live array) dari snapshot dispatch. Mengiterasi live array secara langsung memungkinkan panggilan off dari listener sebelumnya menggeser indeks dan melompati item berikutnya. Panggilan on juga dapat menambahkan listener yang secara tak terduga bergabung ke dalam dispatch saat ini. Menyalin entri-entri yang aktif terlebih dahulu akan menetapkan batasan mutasi pada emit berikutnya.
Sinyal ketiga adalah perilaku once yang bersifat reentrant. Menghapus listener sekali pakai setelah callback-nya selesai dieksekusi adalah hal yang terlambat: callback tersebut dapat memicu (emit) event yang sama saat pendaftarannya masih ada. Entri tersebut harus ditandai tidak aktif dan sudah dijalankan sebelum pemanggilan. Flag yang menandakan sudah dijalankan (fired) juga mencegah snapshot terluar memanggil entri tersebut setelah dispatch bertingkat (nested) telah melakukannya.
Kompleksitas harus dinyatakan secara akurat. Jika suatu event memiliki k listener, penambahan array pada on dan once adalah teramortisasi O(1). off memindai mundur dan menghapus elemen array, sehingga kompleksitasnya adalah O(k). Pembuatan snapshot, pemanggilan, dan pembersihan membuat emit bernilai O(k), ditambah waktu eksekusi listener itu sendiri. Pencarian event biasanya dianggap sebagai rata-rata O(1) untuk implementasi Map, tetapi ECMAScript hanya mensyaratkan akses rata-rata yang lebih baik daripada linear. Sebuah Map tidak membuat keempat operasi menjadi tanpa syarat bernilai O(1).
Pertanyaan yang Perlu Diklarifikasi Sebelum Menjawab
- Metode dan nilai kembalian apa saja yang diperlukan? Jika hanya ada
on,off, danemit, tidak ada status sekali pakai yang perlu disimpan. Menambahkanoncememerlukan titik penghapusan yang tepat. Mengembalikan hasil listener dariemitakan mengubah aliran data; kontrak ini hanya mengembalikan apakah listener ada atau tidak. - Bolehkah fungsi yang sama didaftarkan lebih dari satu kali, dan apakah
offmenghapus satu atau semua pendaftaran? Kontrak ini mengizinkan duplikat dan menghapus pendaftaran cocok yang paling baru. Set yang melakukan deduplikasi akan menerapkan kontrak yang berbeda. - Kapan penambahan dan penghapusan selama dispatch mulai berlaku? Kontrak ini menggunakan semantik snapshot: listener yang ada pada awal dispatch akan bergabung dengan dispatch saat ini, sementara penambahan dan penghapusan memengaruhi dispatch berikutnya. Kontrak yang memerlukan penghapusan langsung akan membutuhkan pemeriksaan status aktif sebelum setiap pemanggilan.
- Apakah listener bersifat sinkron, dan apa yang terjadi jika salah satunya melempar error (throw)? Listener berjalan secara sinkron sesuai urutan. Error yang dilempar akan merambat (propagate) dari
emit, sehingga listener berikutnya tidak akan berjalan. Pembersihan entri yang tidak aktif harus tetap dilakukan difinally. - Apakah kompatibilitas penuh Node.js diperlukan? Jika ya, perilaku khusus
error, meta-event, batas listener, dan API tambahan lainnya akan masuk ke dalam cakupan. Implementasi ini hanya mencakup subset inti yang dideklarasikan; berbagi nama kelas yang sama tidak berarti memiliki kompatibilitas penuh.
Jawaban-jawaban ini mengubah model penyimpanan, kondisi loop, nilai kembalian, dan batasan error, jadi tentukan hal tersebut sebelum menulis kode.
Kerangka Jawaban 30 Detik
“Saya akan menyimpan array entri per event di dalam Map, mengirimkan event secara sinkron sesuai urutan pendaftaran, dan mengizinkan duplikat. off menghapus kecocokan terbaru; mutasi dispatch berlaku pada pemanggilan berikutnya. emit mengambil snapshot dari entri-entri yang aktif. Sebelum memanggil listener once, ia menandai entri sebagai tidak aktif dan sudah dijalankan untuk memblokir reentrancy, lalu melakukan pembersihan di finally. Berdasarkan rata-rata pencarian Map, pendaftaran bernilai teramortisasi O(1), sedangkan off dan emit bernilai O(k).”
Penjelasan Mendalam Langkah demi Langkah
Implementasi paling minimal memetakan setiap event ke array fungsi dan memanggilnya secara berurutan. Ini berhasil melewati contoh sederhana yang mendaftarkan dua fungsi dan melakukan emit sekali, tetapi meninggalkan empat pertanyaan pengubah hasil yang belum terjawab: bagaimana fungsi duplikat dihapus, apa yang terjadi ketika array berubah selama dispatch, kapan listener sekali pakai dihapus, dan bagaimana status dibersihkan setelah listener melempar error.
Implementasi yang direkomendasikan memisahkan catatan pendaftaran dari fungsinya. Dua pendaftaran dari fungsi yang sama tetap menjadi entri yang berbeda, dan once tidak perlu menyembunyikan fungsi asli di balik wrapper:
class EventEmitter {
constructor() {
this.events = new Map();
}
on(eventName, listener) {
return this._add(eventName, listener, false);
}
once(eventName, listener) {
return this._add(eventName, listener, true);
}
off(eventName, listener) {
const entries = this.events.get(eventName);
if (!entries) return this;
for (let index = entries.length - 1; index >= 0; index -= 1) {
const entry = entries[index];
if (entry.active && entry.listener === listener) {
entry.active = false;
entries.splice(index, 1);
break;
}
}
if (entries.length === 0) {
this.events.delete(eventName);
}
return this;
}
emit(eventName, ...args) {
const entries = this.events.get(eventName);
if (!entries) return false;
const snapshot = entries.filter((entry) => entry.active);
if (snapshot.length === 0) return false;
try {
for (const entry of snapshot) {
if (entry.once) {
if (entry.fired) continue;
entry.fired = true;
entry.active = false;
}
entry.listener.apply(this, args);
}
} finally {
const currentEntries = this.events.get(eventName);
if (currentEntries) {
const activeEntries = currentEntries.filter((entry) => entry.active);
if (activeEntries.length === 0) {
this.events.delete(eventName);
} else if (activeEntries.length !== currentEntries.length) {
this.events.set(eventName, activeEntries);
}
}
}
return true;
}
_add(eventName, listener, once) {
if (typeof listener !== "function") {
throw new TypeError("listener must be a function");
}
const entries = this.events.get(eventName);
const entry = { listener, once, fired: false, active: true };
if (entries) {
entries.push(entry);
} else {
this.events.set(eventName, [entry]);
}
return this;
}
}Snapshot menetapkan anggota dan urutan untuk dispatch saat ini, tetapi memegang referensi ke entri-entri tersebut. Entri normal yang dihapus oleh off selama dispatch masih dipanggil dari snapshot saat ini; entri tersebut akan hilang saat snapshot berikutnya dibuat. Entri baru hanya ditambahkan ke live array, sehingga tidak dapat masuk ke snapshot lama.
Entri sekali pakai memerlukan dua kolom status. active = false menyembunyikannya dari emit bertingkat. fired = true menangani urutan reentrant yang kurang terlihat: dispatch bertingkat dapat memanggil entri terlebih dahulu sementara snapshot luar masih memegang referensi yang sama. Loop luar nantinya melihat fired dan melewati pemanggilan kedua. Kedua penandaan ini terjadi sebelum pemanggilan listener, sehingga error yang dilempar tidak dapat menghidupkan kembali pendaftaran sekali pakai tersebut.
Blok finally memadatkan entri-entri yang tidak aktif. Tanpa blok ini, listener yang melempar error dapat meninggalkan entri once yang secara logis telah dihapus di dalam live array. Eksepsi tetap merambat ke pemanggil emit; finally mempertahankan invarian internal tanpa menelan kegagalan tersebut.
Minimal, uji batasan-batasan berikut: tidak ada listener mengembalikan false; argumen dan this diteruskan; pendaftaran duplikat menghasilkan panggilan duplikat dan off hanya menghapus yang terbaru; dispatch berulang memanggil listener once sekali; menghapus listener berikutnya selama dispatch tidak menghapusnya dari dispatch tersebut; listener yang ditambahkan menunggu dispatch berikutnya; dispatch bertingkat tidak dapat memanggil entri once dua kali; dan listener yang melempar error tetap membuat entri sekali pakai dihapus.
Contoh Jawaban Berkualitas Tinggi
“Saya akan membatasi ini pada kontrak inti bergaya Node daripada kompatibilitas penuh. Nama event dapat berupa string atau Symbol. Listener berjalan secara sinkron sesuai urutan pendaftaran, duplikat diizinkan, dan off menghapus pendaftaran cocok yang paling baru. Dispatch menggunakan snapshot, sehingga penambahan dan penghapusan selama satu dispatch memengaruhi dispatch berikutnya. emit melaporkan apakah ia menemukan listener, dan eksepsi listener merambat ke pemanggilnya.
Saya akan menyimpan array entri per event di dalam Map. Array menjaga urutan, sementara setiap entri mempertahankan fungsi asli dan status sekali pakainya. emit pertama-tama memfilter snapshot saat ini. Sebelum memanggil entri sekali pakai, metode ini menandainya sebagai tidak aktif dan sudah dijalankan: tidak aktif mencegah dispatch bertingkat memilihnya, dan sudah dijalankan mencegah snapshot luar memanggilnya setelah dispatch bertingkat melakukannya. Blok try...finally di sekitar loop menjamin pembersihan setelah listener melempar error tanpa menangkap eksepsi tersebut.
Model ini mempertahankan pendaftaran duplikat dan semantik snapshot. Berdasarkan asumsi pencarian Map rata-rata konvensional, penambahan di on dan once adalah teramortisasi O(1). off memindai dan menggeser array, dan emit menyalin, memanggil, dan membersihkan entri, sehingga keduanya adalah O(k). Jika off harus bernilai O(1), saya akan menggunakan entri linked-list ditambah indeks, dengan konsekuensi kode dan memori yang lebih besar; batasan saat ini tidak membenarkan biaya tersebut.”
Kesalahan Umum
- Menyimpan listener dalam
Set→ mendaftarkan fungsi yang sama dua kali hanya menyisakan satu item dan mengubah semantik duplikat → gunakan array entri sehingga setiap pendaftaran tetap terpisah. - Mengiterasi live array secara langsung →
offmenggeser indeks danondapat menarik item baru ke dalam dispatch saat ini → ambil snapshot dari entri-entri aktif saat dispatch dimulai. - Menghapus
oncesetelah callback kembali → callback dapat memicu kembali event sebelum penghapusan, dan error yang dilempar dapat mencegah penghapusan sama sekali → aturactive = falsedanfired = truesebelum pemanggilan. - Mencatat status tidak aktif tanpa status fired → setelah dispatch bertingkat memanggil sekali, snapshot luar yang lebih lama dapat memanggil entri yang sama lagi → simpan guard fired pada entri bersama tersebut.
- Menggunakan
filterdioffuntuk menghapus setiap fungsi yang cocok → satu panggilan menghapus semua pendaftaran duplikat → pindai mundur dan hapus hanya entri aktif terbaru. - Menangkap error listener dan melanjutkan eksekusi → pemanggil kehilangan informasi kegagalan dan batasan error berubah secara diam-diam → bersihkan status di
finallydan biarkan eksepsi merambat. - Mengklaim setiap operasi adalah
O(1)→ Map tidak menjanjikan pencarian konstan yang ketat, dan pencarian array, penghapusan, pembuatan snapshot, serta traversal bergantung pada jumlah listener → nyatakan asumsi Map, lalu berikan teramortisasion/once O(1)danoff/emit O(k)secara terpisah.
Pertanyaan Lanjutan dan Tanggapan
Pertanyaan lanjutan 1: Mengapa listener yang dihapus selama dispatch tetap harus menyelesaikan dispatch tersebut?
Snapshot menentukan partisipan saat emit dimulai dan mencegah listener sebelumnya mengubah indeks berikutnya. Implementasi ini menyimpan referensi entri di dalam snapshot tetapi tidak memeriksa ulang active sebelum memanggil listener normal, sehingga listener tersebut tetap berjalan pada dispatch saat ini. Snapshot berikutnya akan mengecualikannya. Sebuah produk mungkin memilih penghapusan langsung dengan memeriksa active sebelum setiap pemanggilan, tetapi itu adalah kontrak yang berbeda dan membutuhkan pengujian yang berbeda pula.
Pertanyaan lanjutan 2: Bagaimana Anda membuktikan bahwa once berjalan paling banyak satu kali di bawah kondisi reentrancy?
Entri sekali pakai hanya dapat memasuki jalur pemanggilannya selama fired bernilai false. Dispatch pertama yang mencapainya menyetel fired ke true sebelum memanggil listener. Snapshot bertingkat dan snapshot luar memegang referensi entri yang sama, sehingga setiap upaya berikutnya mengamati nilai true dan melewatinya. Ini menetapkan perilaku paling banyak satu kali (at-most-once) terlepas dari urutan nesting. Uji urutan paling sulit dengan menempatkan listener normal terlebih dahulu yang memicu secara rekursif hanya pada panggilan pertamanya, diikuti oleh listener once.
Pertanyaan lanjutan 3: Apa yang berubah jika off harus bernilai O(1)?
Array tidak dapat menyediakan penghapusan entri arbitrer dalam O(1) sekaligus mempertahankan urutan yang stabil. Pertahankan doubly linked list per event dan sebuah Map<listener, nodes[]> yang menemukan node yang paling baru didaftarkan untuk suatu fungsi. Memutuskan tautan (unlinking) node tersebut adalah O(1), sementara emit tetap O(k). Biayanya adalah dua pointer per pendaftaran, pemeliharaan indeks untuk fungsi duplikat, dan perilaku snapshot yang lebih rumit. Untuk set listener yang kecil dengan frekuensi penghapusan yang jarang, array lebih mudah untuk diverifikasi.
Pertanyaan lanjutan 4: Bagaimana jika listener mengembalikan Promise atau melempar error?
emit saat ini bersifat sinkron dan mengabaikan nilai kembalian. Lemparan error yang sinkron akan menghentikan dispatch dan merambat ke pemanggil; finally hanya membersihkan status. Promise yang dikembalikan tidak di-await. Jika pemanggil membutuhkan penyelesaian tugas, tentukan kontrak emitAsync terpisah dan pilih eksekusi serial atau paralel ditambah penanganan error fail-fast atau all-results. Menambahkan await ke loop yang ada tanpa keputusan-keputusan tersebut akan membuat API menjadi ambigu.
Pertanyaan lanjutan 5: Bagaimana Anda memverifikasi bahwa listener yang ditambahkan selama dispatch menunggu hingga dispatch berikutnya?
Daftarkan A dan buat A mendaftarkan B saat dipanggil. Snapshot emit pertama hanya berisi A, jadi hanya A yang muncul. Snapshot kedua berisi A dan B, menghasilkan A kemudian B. Selain itu, buat A menghapus C setelah C masuk ke dalam snapshot pertama: dispatch pertama harus tetap menghasilkan A lalu C, sedangkan yang kedua mengecualikan C. Bersama-sama, asersi-asersi tersebut mencakup kedua sisi batasan mutasi.