Topik temu duga representatif

Melaksanakan Event Emitter dalam JavaScript

FrontendSederhana
Pasukan Editorial Offer.ccDiterbitkan Dikemas kini

Soalan

Laksanakan EventEmitter JavaScript dengan on, off, once, dan emit, serta jelaskan peraturan untuk susunan pendengar, mutasi semasa penghantaran (dispatch), dan pendaftaran pendua.

Masalah dan Konteks yang Berkenaan

Laksanakan kelas EventEmitter dengan empat kaedah: on(eventName, listener) mendaftarkan pendengar berterusan, once(eventName, listener) mendaftarkan pendengar sekali sahaja, off(eventName, listener) membuang satu pendaftaran yang sepadan, dan emit(eventName, ...args) menghantar (dispatch) peristiwa secara segerak. Nama peristiwa boleh berupa rentetan (string) atau Simbol. Fungsi yang sama boleh didaftarkan lebih daripada sekali untuk satu peristiwa.

Soalan ini menggunakan subset eksplisit semantik gaya Node. Pendengar berjalan secara segerak mengikut susunan pendaftaran. this di dalam fungsi pendengar biasa ialah pemancar (emitter) itu sendiri. emit mengembalikan true apabila peristiwa mempunyai pendengar dan false sebaliknya, dan nilai pulangan pendengar diabaikan. Pendaftaran once mesti dibuang sebelum pendengarnya dipanggil supaya emit yang bersifat reentrant tidak dapat memanggilnya semula.

Pendengar untuk satu dispatch ditetapkan apabila dispatch itu bermula. Jika satu pendengar membuang pendengar lain semasa dispatch, pendengar yang dibuang masih mengambil bahagian dalam dispatch yang sedang berjalan tetapi tidak dalam dispatch yang seterusnya. Pendengar yang baru ditambah menunggu sehingga dispatch kemudian. Pelaksanaan ini tidak menghasilkan semula peristiwa khas error milik Node.js, amaran bilangan pendengar, prependListener, lelaran tak segerak, atau penangkapan penolakan Promise; perkara tersebut tergolong dalam tugasan keserasian yang lebih luas.

Perkara yang Dinilai oleh Penemu Duga

Jawapan yang kukuh mentakrifkan kontrak peristiwa sebelum memilih bekas (container). Map<eventName, entries[]> mencari tatasusunan (array) bagi sesuatu peristiwa, dan array tersebut mengekalkan susunan pendaftaran. Setiap entri menyimpan fungsi asal, sama ada ia pendaftaran once, sama ada ia telah dicetuskan (fired), dan sama ada ia kekal aktif. Set memudahkan pembuangan tetapi menggabungkan fungsi pendua secara senyap, melanggar peraturan pendaftaran pendua yang dinyatakan.

Isyarat kedua ialah membezakan array langsung (live array) daripada snapshot dispatch. Menglelar live array secara terus membolehkan panggilan off pendengar terdahulu mengalihkan indeks dan melangkau item seterusnya. Panggilan on juga mungkin menambah pendengar yang secara tidak dijangka menyertai dispatch semasa. Menyalin entri aktif terlebih dahulu menetapkan sempadan mutasi pada emit yang seterusnya.

Isyarat ketiga ialah tingkah laku once yang bersifat reentrant. Membuang pendengar sekali sahaja selepas panggilan baliknya (callback) kembali adalah terlalu lewat: callback boleh memancarkan peristiwa yang sama semasa pendaftarannya masih ada. Entri tersebut harus ditandakan tidak aktif dan telah dicetuskan sebelum pemanggilan. Bendera (flag) dicetuskan juga menghalang snapshot luar daripada memanggil entri selepas dispatch bersarang (nested) telah berbuat demikian.

Kerumitan harus dinyatakan dengan tepat. Jika sesuatu peristiwa mempunyai k pendengar, penambahan array dalam on dan once adalah terpelunas (amortized) O(1). off mengimbas ke belakang dan membuang elemen array, jadi ia adalah O(k). Penciptaan snapshot, pemanggilan, dan pembersihan menjadikan emit bernilai O(k), ditambah masa jalanan pendengar itu sendiri. Carian peristiwa lazimnya dianggap sebagai purata O(1) untuk pelaksanaan Map, tetapi ECMAScript hanya memerlukan purata capaian yang lebih baik daripada linear. Map tidak menjadikan kesemua empat operasi bernilai O(1) tanpa syarat.

Soalan untuk Dijelaskan Sebelum Menjawab

  • Kaedah dan nilai pulangan manakah yang diperlukan? Dengan hanya on, off, dan emit, tiada keadaan sekali sahaja yang perlu dikekalkan. Menambah once memerlukan titik pembuangan yang tepat. Mengembalikan hasil pendengar daripada emit akan mengubah aliran data; kontrak ini hanya mengembalikan sama ada pendengar wujud atau tidak.
  • Bolehkah fungsi yang sama didaftarkan lebih daripada sekali, dan adakah off membuang satu atau semua pendaftaran? Kontrak ini membenarkan pendua dan membuang pendaftaran sepadan yang paling terkini. Set yang menyahduplikasi akan melaksanakan kontrak yang berbeza.
  • Bilakah penambahan dan pembuangan semasa dispatch mula berkuat kuasa? Kontrak ini menggunakan semantik snapshot: pendengar yang hadir pada permulaan menyertai dispatch semasa, manakala penambahan dan pembuangan mempengaruhi dispatch kemudian. Kontrak yang memerlukan pembuangan serta-merta memerlukan semakan keadaan aktif sebelum setiap panggilan.
  • Adakah pendengar bersifat segerak, dan apakah yang berlaku apabila seseorang melempar ralat (throw)? Pendengar berjalan secara segerak mengikut susunan. Ralat yang dilempar disebarkan dari emit, jadi pendengar kemudian tidak akan berjalan. Pembersihan entri yang tidak aktif mesti tetap berlaku dalam finally.
  • Adakah keserasian penuh Node.js diperlukan? Jika ya, tingkah laku khas error, meta-events, had pendengar, dan lebih banyak API akan memasuki skop. Pelaksanaan ini hanya merangkumi subset teras yang diisytiharkan; berkongsi nama kelas tidak mewujudkan keserasian penuh.

Jawapan ini mengubah model storan, syarat gelung (loop), nilai pulangan, dan sempadan ralat, jadi selesaikan ia sebelum menulis kod.

Rangka Jawapan 30 Saat

“Saya akan menyimpan array entri bagi setiap peristiwa dalam Map, menghantar secara segerak mengikut susunan pendaftaran, dan membenarkan pendua. off membuang padanan terbaharu; mutasi dispatch berkuat kuasa pada kali seterusnya. emit mengambil snapshot entri aktif. Sebelum memanggil once, ia menandakan entri tidak aktif dan telah dicetuskan untuk menyekat reentrancy, kemudian membersihkan dalam finally. Di bawah purata carian Map, pendaftaran adalah terpelunas O(1), manakala off dan emit adalah O(k).”

Penjelasan Terperinci Langkah demi Langkah

Pelaksanaan terkecil memetakan setiap peristiwa kepada array fungsi dan memanggilnya mengikut susunan. Ia melepasi sampel yang mendaftarkan dua fungsi dan memancarkan sekali, tetapi membiarkan empat soalan yang mengubah hasil tidak terjawab: bagaimana fungsi pendua dibuang, apa yang berlaku apabila array berubah semasa dispatch, bilakah pendengar sekali sahaja dibuang, dan bagaimana keadaan dibersihkan selepas pendengar melempar ralat.

Pelaksanaan yang disyorkan memisahkan rekod pendaftaran daripada fungsinya. Dua pendaftaran fungsi yang sama kekal sebagai entri yang berbeza, dan once tidak perlu menyembunyikan fungsi asal di sebalik pembalut (wrapper):

javascript
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 keahlian dan susunan untuk dispatch semasa, tetapi memegang rujukan kepada entri tersebut. Entri biasa yang dibuang oleh off semasa dispatch masih dipanggil daripada snapshot semasa; ia hilang apabila snapshot seterusnya dibina. Entri baharu hanya ditambah pada live array, jadi ia tidak boleh memasuki snapshot lama.

Entri sekali sahaja memerlukan dua medan keadaan. active = false menyembunyikannya daripada emit bersarang. fired = true mengendalikan susunan reentrant yang kurang ketara: dispatch bersarang mungkin memanggil entri terlebih dahulu sementara snapshot luar masih memegang rujukan yang sama. Gelung luar kemudian melihat fired dan melangkau pemanggilan kedua. Kedua-dua penandaan berlaku sebelum panggilan pendengar, jadi ralat yang dilempar tidak dapat menghidupkan semula pendaftaran sekali sahaja.

Blok finally memadatkan entri yang tidak aktif. Tanpanya, pendengar yang melempar ralat boleh meninggalkan entri once yang telah dibuang secara logik dalam live array. Pengecualian masih disebarkan kepada pemanggil emit; finally mengekalkan invarien dalaman tanpa menelan kegagalan tersebut.

Sekurang-kurangnya, uji sempadan ini: tiada pendengar mengembalikan false; hujah (arguments) dan this dimajukan; pendaftaran pendua menghasilkan panggilan pendua dan off hanya membuang yang terbaharu; dispatch berulang memanggil pendengar once sekali; membuang pendengar kemudian semasa dispatch tidak membuangnya daripada dispatch itu; pendengar yang ditambah menunggu dispatch seterusnya; dispatch bersarang tidak boleh memanggil entri once dua kali; dan pendengar yang melempar ralat tetap meninggalkan entri sekali sahaja dalam keadaan dibuang.

Contoh Jawapan Berkualiti Tinggi

“Saya akan mengehadkan ini kepada kontrak teras gaya Node dan bukannya keserasian penuh. Nama peristiwa boleh berupa rentetan atau Simbol. Pendengar berjalan secara segerak mengikut susunan pendaftaran, pendua dibenarkan, dan off membuang pendaftaran sepadan yang paling terkini. Dispatch menggunakan snapshot, jadi penambahan dan pembuangan semasa satu dispatch mempengaruhi dispatch kemudian. emit melaporkan sama ada ia menemui pendengar, dan pengecualian pendengar disebarkan kepada pemanggilnya.

Saya akan menyimpan array entri bagi setiap peristiwa dalam Map. Array mengekalkan susunan, manakala setiap entri mengekalkan fungsi asal dan keadaan sekali sahaja. emit mula-mula menapis snapshot semasa. Sebelum memanggil entri sekali sahaja, ia menandakannya tidak aktif dan telah dicetuskan: tidak aktif menghalang dispatch bersarang daripada memilihnya, dan dicetuskan menghalang snapshot luar daripada memanggilnya selepas dispatch bersarang telah berbuat demikian. Blok try...finally di sekeliling gelung menjamin pembersihan selepas pendengar melempar ralat tanpa menangkap pengecualian.

Model ini mengekalkan pendaftaran pendua dan semantik snapshot. Di bawah andaian purata carian Map konvensional, penambahan dalam on dan once adalah terpelunas O(1). off mengimbas dan mengalihkan array, dan emit menyalin, memanggil, dan membersihkan entri, jadi kedua-duanya adalah O(k). Jika off perlu menjadi O(1), saya akan menggunakan entri senarai terpaut (linked list) ditambah indeks, menerima lebih banyak kod dan memori; kekangan semasa tidak mewajarkan kos tersebut.”

Kesilapan Biasa

  • Menyimpan pendengar dalam Set mendaftarkan fungsi yang sama dua kali meninggalkan satu item dan mengubah semantik pendua → gunakan array entri supaya setiap pendaftaran kekal berbeza.
  • Menglelar live array secara terus → off mengalihkan indeks dan on mungkin menarik item baharu ke dalam dispatch semasa → ambil snapshot entri aktif apabila dispatch bermula.
  • Membuang once selepas callback kembali → callback boleh memasuki semula peristiwa sebelum pembuangan, dan ralat yang dilempar mungkin menghalang pembuangan sepenuhnya → tetapkan active = false dan fired = true sebelum pemanggilan.
  • Merekod keadaan tidak aktif tanpa keadaan dicetuskan → selepas dispatch bersarang memanggil sekali, snapshot luar yang lebih lama mungkin memanggil entri yang sama sekali lagi → simpan pengawal (guard) dicetuskan pada entri yang dikongsi.
  • Menggunakan filter dalam off untuk membuang setiap fungsi yang sepadan → satu panggilan memadamkan semua pendaftaran pendua → imbas ke belakang dan buang hanya entri aktif yang terbaharu.
  • Menangkap ralat pendengar dan meneruskan → pemanggil kehilangan maklumat kegagalan dan sempadan ralat berubah secara senyap → bersihkan keadaan dalam finally dan biarkan pengecualian disebarkan.
  • Mendakwa setiap operasi ialah O(1) Map tidak menjanjikan carian pemalar yang ketat, dan carian array, pembuangan, penciptaan snapshot, dan perlintasan bergantung pada bilangan pendengar → nyatakan andaian Map, kemudian berikan terpelunas on/once O(1) dan off/emit O(k) secara berasingan.

Soalan Susulan dan Maklum Balas

Soalan susulan 1: Mengapakah pendengar yang dibuang semasa dispatch masih perlu menyelesaikan dispatch tersebut?

Snapshot menetapkan peserta apabila emit bermula dan menghalang pendengar terdahulu daripada mengubah indeks kemudian. Pelaksanaan memegang rujukan entri dalam snapshot tetapi tidak menyemak semula active sebelum memanggil pendengar biasa, jadi ia masih berjalan dalam dispatch semasa. Snapshot seterusnya mengecualikannya. Sesuatu produk mungkin memilih pembuangan serta-merta dengan menyemak active sebelum setiap panggilan, tetapi itu adalah kontrak yang berbeza dan memerlukan ujian yang berbeza.

Soalan susulan 2: Bagaimanakah anda boleh membuktikan bahawa once berjalan paling banyak sekali di bawah reentrancy?

Entri sekali sahaja boleh memasuki laluan panggilannya hanya semasa fired adalah false. Dispatch pertama yang mencapainya menetapkan fired kepada true sebelum memanggil pendengar. Snapshot bersarang dan luar memegang rujukan entri yang sama, jadi setiap percubaan kemudian melihat true dan melangkaunya. Ini mewujudkan tingkah laku paling banyak sekali (at-most-once) tanpa mengira susunan persarangan. Uji susunan paling sukar dengan meletakkan pendengar biasa terlebih dahulu yang memancar secara rekursif hanya pada panggilan pertamanya, diikuti oleh pendengar once.

Soalan susulan 3: Apakah yang berubah jika off mesti O(1)?

Array tidak boleh menyediakan kedua-dua pembuangan entri arbitrari dalam O(1) dan susunan yang stabil. Kekalkan senarai terpaut berganda (doubly linked list) bagi setiap peristiwa dan Map<listener, nodes[]> yang mencari nod yang paling baru didaftarkan untuk fungsi. Menyahpaut nod tersebut ialah O(1), manakala emit kekal O(k). Kosnya ialah dua penunjuk (pointers) bagi setiap pendaftaran, penyelenggaraan indeks fungsi pendua, dan tingkah laku snapshot yang lebih rumit. Untuk set pendengar kecil dengan pembuangan yang jarang berlaku, array adalah lebih mudah untuk disahkan.

Soalan susulan 4: Bagaimana jika pendengar mengembalikan Promise atau melempar ralat?

emit semasa adalah segerak dan mengabaikan nilai pulangan. Lemparan ralat segerak menghentikan dispatch dan disebarkan kepada pemanggil; finally hanya membersihkan keadaan. Promise yang dikembalikan tidak ditunggu (not awaited). Jika pemanggil memerlukan penyiapan, takrifkan kontrak emitAsync yang berasingan dan pilih pelaksanaan bersiri atau selari serta pengendalian ralat fail-fast atau all-results. Menambah await pada gelung sedia ada tanpa keputusan tersebut menyebabkan API menjadi kabur.

Soalan susulan 5: Bagaimanakah anda mengesahkan bahawa pendengar yang ditambah semasa dispatch menunggu sehingga dispatch seterusnya?

Daftarkan A dan minta A mendaftarkan B apabila dipanggil. Snapshot emit pertama mengandungi A sahaja, jadi hanya A yang muncul. Snapshot kedua mengandungi A dan B, menghasilkan A kemudian B. Minta juga A membuang C selepas C memasuki snapshot pertama: dispatch pertama masih harus menghasilkan A kemudian C, manakala yang kedua mengecualikan C. Bersama-sama, penegasan (assertions) tersebut merangkumi kedua-dua belah sempadan mutasi.

Sumber awam

Soalan berkaitan