Tema representativo de entrevista

Implementar un Event Emitter en JavaScript

FrontendIntermedio
Equipo editorial de Offer.ccPublicado Actualizado

Pregunta

Implementa un EventEmitter de JavaScript con on, off, once y emit, y explica las reglas para el orden de los listeners, la mutación durante el despacho y los registros duplicados.

Problema y contexto aplicable

Implementa una clase EventEmitter con cuatro métodos: on(eventName, listener) registra un listener persistente, once(eventName, listener) registra un listener de una sola vez, off(eventName, listener) elimina un registro coincidente y emit(eventName, ...args) despacha un evento de forma síncrona. El nombre de un evento puede ser una cadena o un Symbol. La misma función puede registrarse más de una vez para un mismo evento.

Esta pregunta utiliza un subconjunto explícito de la semántica al estilo de Node. Los listeners se ejecutan de forma síncrona en el orden de registro. this dentro de una función de listener ordinaria es el emisor. emit devuelve true cuando el evento tiene listeners y false en caso contrario, y los valores de retorno de los listeners se ignoran. Un registro once debe eliminarse antes de que se llame a su listener para que un emit reentrante no pueda invocarlo de nuevo.

Los listeners para un despacho quedan fijados cuando comienza dicho despacho. Si un listener elimina a otro durante el despacho, el listener eliminado aún participa en el despacho que ya está en curso, pero no en los posteriores. Un listener recién agregado espera hasta un despacho posterior. La implementación no reproduce el evento especial error de Node.js, las advertencias sobre el conteo de listeners, prependListener, la iteración asíncrona ni la captura de rechazos de Promises; estos pertenecen a una asignación de compatibilidad más amplia.

Qué evalúa el entrevistador

Una respuesta sólida define el contrato del evento antes de elegir un contenedor. Un Map<eventName, entries[]> localiza el arreglo para un evento, y el arreglo preserva el orden de registro. Cada entrada almacena la función original, si es un registro once, si se ha disparado y si permanece activa. Un Set hace que la eliminación sea conveniente, pero fusiona silenciosamente las funciones duplicadas, violando la regla establecida de registros duplicados.

La segunda señal es distinguir el arreglo activo de la instantánea (snapshot) de despacho. Iterar directamente sobre el arreglo activo permite que la llamada a off de un listener anterior desplace los índices y omita el siguiente elemento. Una llamada a on también puede agregar un listener que se incorpore inesperadamente al despacho actual. Copiar primero las entradas activas fija el límite de mutación en el siguiente emit.

La tercera señal es el comportamiento reentrante de once. Eliminar un listener de una sola vez después de que su callback retorna es demasiado tarde: el callback puede emitir el mismo evento mientras su registro aún está presente. La entrada debe marcarse como inactiva y disparada antes de la invocación. El flag de disparado también evita que una instantánea externa invoque la entrada después de que un despacho anidado ya lo haya hecho.

La complejidad debe indicarse con precisión. Si un evento tiene k listeners, la inserción en el arreglo en on y once es O(1) amortizado. off escanea hacia atrás y elimina un elemento del arreglo, por lo que es O(k). La creación de la instantánea, la invocación y la limpieza hacen que emit sea O(k), más el tiempo de ejecución de los propios listeners. La búsqueda de eventos se trata comúnmente como un promedio de O(1) para implementaciones de Map, pero ECMAScript solo requiere un acceso promedio mejor que lineal. Un Map no hace que las cuatro operaciones sean incondicionalmente O(1).

Preguntas para aclarar antes de responder

  • ¿Qué métodos y valores de retorno se requieren? Solo con on, off y emit, no hay estado de una sola vez que retener. Agregar once requiere un punto de eliminación exacto. Devolver los resultados de los listeners desde emit cambiaría el flujo de datos; este contrato solo devuelve si existían listeners.
  • ¿Puede registrarse la misma función más de una vez y off elimina uno o todos los registros? Este contrato permite duplicados y elimina el registro coincidente más reciente. Un Set con deduplicación implementaría un contrato diferente.
  • ¿Cuándo surten efecto las adiciones y eliminaciones durante el despacho? Este contrato utiliza semántica de instantánea: los listeners presentes al inicio se unen al despacho actual, mientras que las adiciones y eliminaciones afectan a los despachos posteriores. Un contrato que requiera eliminación inmediata necesitaría una verificación de estado activo antes de cada llamada.
  • ¿Los listeners son síncronos y qué sucede cuando uno lanza un error? Los listeners se ejecutan de forma síncrona en orden. Un error lanzado se propaga desde emit, por lo que los listeners posteriores no se ejecutan. La limpieza de las entradas inactivas aún debe realizarse en finally.
  • ¿Se requiere compatibilidad total con Node.js? Si es así, el comportamiento especial de error, los meta-eventos, los límites de listeners y más APIs entran en el alcance. Esta implementación cubre solo el subconjunto central declarado; compartir el nombre de una clase no establece compatibilidad total.

Estas respuestas cambian el modelo de almacenamiento, las condiciones del bucle, el valor de retorno y el límite de errores, así que acláralas antes de programar.

Estructura de respuesta en 30 segundos

“Almacenaré un arreglo de entradas por evento en un Map, despacharé sincrónicamente en orden de registro y permitiré duplicados. off elimina la coincidencia más reciente; las mutaciones en el despacho surten efecto la próxima vez. emit toma una instantánea de las entradas activas. Antes de llamar a once, marca la entrada como inactiva y disparada para bloquear la reentrancia, luego limpia en finally. Bajo una búsqueda promedio en Map, el registro es O(1) amortizado, mientras que off y emit son O(k).”

Análisis paso a paso a profundidad

La implementación más pequeña mapea cada evento a un arreglo de funciones y las llama en orden. Pasa un ejemplo que registra dos funciones y emite una vez, pero deja sin respuesta cuatro preguntas que cambian el resultado: cómo se eliminan las funciones duplicadas, qué sucede cuando el arreglo cambia durante el despacho, cuándo se elimina un listener de una sola vez y cómo se limpia el estado después de que un listener lanza un error.

La implementación recomendada separa el registro de su función. Dos registros de la misma función siguen siendo entradas distintas, y once no necesita ocultar la función original detrás de un 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;
  }
}

La instantánea fija la pertenencia y el orden para el despacho actual, pero mantiene referencias a las entradas. Una entrada normal eliminada por off durante el despacho todavía se invoca desde la instantánea actual; desaparece cuando se construye la siguiente instantánea. Una nueva entrada se añade solo al arreglo activo, por lo que no puede entrar en la instantánea anterior.

Una entrada de una sola vez necesita dos campos de estado. active = false la oculta de un emit anidado. fired = true maneja un orden reentrante menos obvio: un despacho anidado puede invocar la entrada primero mientras la instantánea externa aún mantiene la misma referencia. El bucle externo ve más tarde fired y omite la segunda invocación. Ambas marcas ocurren antes de la llamada al listener, por lo que un error lanzado no puede revivir el registro de una sola vez.

El bloque finally compacta las entradas inactivas. Sin él, un listener que lance una excepción podría dejar una entrada once lógicamente eliminada en el arreglo activo. La excepción aún se propaga al llamador de emit; finally preserva la invariante interna sin silenciar la falla.

Como mínimo, prueba estos límites: sin listeners devuelve false; los argumentos y this se reenvían; los registros duplicados producen llamadas duplicadas y off elimina solo la más reciente; el despacho repetido llama a un listener once una vez; eliminar un listener posterior durante el despacho no lo elimina de ese despacho; un listener agregado espera al siguiente despacho; el despacho anidado no puede llamar a una entrada once dos veces; y un listener que lanza un error aún deja la entrada de una sola vez eliminada.

Respuesta de ejemplo de alta calidad

“Limitaré esto a un contrato central al estilo de Node en lugar de compatibilidad total. Los nombres de eventos pueden ser cadenas o Symbols. Los listeners se ejecutan sincrónicamente en orden de registro, se permiten duplicados y off elimina el registro coincidente más reciente. El despacho utiliza una instantánea, por lo que las adiciones y eliminaciones durante un despacho afectan a los despachos posteriores. emit informa si encontró listeners, y las excepciones de los listeners se propagan a su llamador.

Almacenaré un arreglo de entradas por evento en un Map. El arreglo preserva el orden, mientras que cada entrada retiene la función original y el estado de una sola vez. emit primero filtra la instantánea actual. Antes de invocar una entrada de una sola vez, la marca como inactiva y disparada: inactiva evita que un despacho anidado la seleccione, y disparada evita que una instantánea externa la invoque después de que un despacho anidado ya lo haya hecho. Un try...finally alrededor del bucle garantiza la limpieza después de que un listener lance un error sin capturar la excepción.

Este modelo preserva los registros duplicados y la semántica de instantáneas. Bajo la suposición convencional de búsqueda promedio en Map, la inserción en on y once es O(1) amortizado. off escanea y desplaza un arreglo, y emit copia, invoca y limpia entradas, por lo que ambos son O(k). Si off tuviera que ser O(1), usaría entradas de lista doblemente enlazada más un índice, aceptando más código y memoria; las restricciones actuales no justifican ese costo.”

Errores comunes

  • Almacenar listeners en un Set registrar la misma función dos veces deja un solo elemento y cambia la semántica de duplicados → usa un arreglo de entradas para que cada registro permanezca diferenciado.
  • Iterar directamente sobre el arreglo activo → off desplaza los índices y on puede incorporar un nuevo elemento al despacho actual → toma una instantánea de las entradas activas cuando comience el despacho.
  • Eliminar once después de que el callback retorna → un callback puede reingresar al evento antes de la eliminación, y un error lanzado puede impedir la eliminación por completo → establece active = false y fired = true antes de la invocación.
  • Registrar el estado inactivo sin el estado disparado → después de que un despacho anidado invoque una vez, una instantánea externa más antigua puede invocar la misma entrada de nuevo → almacena una protección de disparado en la entrada compartida.
  • Usar filter en off para eliminar todas las funciones coincidentes → una sola llamada borra todos los registros duplicados → escanea hacia atrás y elimina solo la entrada activa más reciente.
  • Capturar un error de listener y continuar → el llamador pierde la falla y el límite de errores cambia silenciosamente → limpia el estado en finally y permite que la excepción se propague.
  • Afirmar que cada operación es O(1) Map no promete una búsqueda estrictamente constante, y la búsqueda en arreglos, la eliminación, la creación de instantáneas y el recorrido dependen del conteo de listeners → establece la suposición del Map, luego indica on/once O(1) amortizado y off/emit O(k) por separado.

Preguntas de seguimiento y respuestas

Pregunta de seguimiento 1: ¿Por qué un listener eliminado durante el despacho aún debería finalizar ese despacho?

Una instantánea fija a los participantes cuando comienza emit y evita que un listener anterior cambie los índices posteriores. La implementación mantiene referencias de las entradas en la instantánea pero no vuelve a verificar active antes de llamar a un listener normal, por lo que aún se ejecuta en el despacho actual. La siguiente instantánea lo excluye. Un producto puede optar por la eliminación inmediata verificando active antes de cada llamada, pero ese es un contrato diferente y necesita pruebas diferentes.

Pregunta de seguimiento 2: ¿Cómo se puede demostrar que once se ejecuta como máximo una vez bajo reentrancia?

Una entrada de una sola vez puede ingresar a su ruta de llamada solo mientras fired sea false. El primer despacho en alcanzarla establece fired en true antes de llamar al listener. Las instantáneas anidadas y externas mantienen la misma referencia de entrada, por lo que cada intento posterior observa true y la omite. Esto establece el comportamiento de como máximo una vez sin importar el orden de anidamiento. Prueba el orden más difícil colocando primero un listener normal que emita recursivamente solo en su primera llamada, seguido de un listener once.

Pregunta de seguimiento 3: ¿Qué cambia si off debe ser O(1)?

Un arreglo no puede proporcionar tanto la eliminación de entradas arbitrarias en O(1) como un orden estable. Mantén una lista doblemente enlazada por evento y un Map<listener, nodes[]> que ubique el nodo registrado más recientemente para una función. Desvincular ese nodo es O(1), mientras que emit sigue siendo O(k). El costo son dos punteros por registro, el mantenimiento del índice de funciones duplicadas y un comportamiento de instantáneas más complicado. Para conjuntos pequeños de listeners con eliminaciones poco frecuentes, el arreglo es más fácil de verificar.

Pregunta de seguimiento 4: ¿Qué pasa si un listener devuelve una Promise o lanza un error?

El emit actual es síncrono e ignora los valores de retorno. Una excepción síncrona detiene el despacho y se propaga al llamador; finally solo limpia el estado. Una Promise devuelta no se espera. Si los llamadores necesitan la finalización, define un contrato emitAsync separado y elige una ejecución en serie o paralela, además de un manejo de errores fail-fast o de recopilación de todos los resultados. Agregar await al bucle existente sin esas decisiones deja la API ambigua.

Pregunta de seguimiento 5: ¿Cómo verificarías que un listener agregado durante el despacho espera hasta el siguiente despacho?

Registra A y haz que A registre B cuando se le llame. La primera instantánea de emit contiene solo a A, por lo que solo aparece A. La segunda instantánea contiene a A y B, produciendo A y luego B. También haz que A elimine a C después de que C haya entrado en la primera instantánea: el primer despacho aún debería producir A y luego C, mientras que el segundo excluye a C. Juntas, esas aserciones cubren ambos lados del límite de mutación.

Fuentes públicas

Preguntas relacionadas