Однажды на собеседовании мне предложили решить одну интересную задачу, которая для меня была довольно необычной на тот момент.
Позже я обнаружил, что задача была не особо уникальной, но с высоты моего опыта тогда, она показалась довольно будоражащей.

Условие задачи

Создайте класс EventEmitter, который позволяет:

  • подписываться на события (on) с любым количеством функций на одно событие;

  • отписываться от конкретной функции (off), даже если функция анонимная;

  • вызывать все функции для события (emit) с передачей аргументов.

Код задачи:

class EventEmitter {
  events = {};

  on(name, fn) {
    // здесь будет логика подписки
  }

  off(name, fn) {
    // здесь будет логика отписки
  }

  emit(name, ...args) {
    // здесь будет логика вызова всех функций события
  }
}

const ee = new EventEmitter();

// Example of using:
ee.on("login", () => {
  console.log("login 1");
});

ee.on("login", () => {
  console.log("login 2");
});

ee.on("login", () => {
  console.log("login 3");
});

ee.emit("login1");
ee.emit("login2");
ee.emit("dude", "Bob");

Мой вариант решения.

Начнём с функции on. Она должна давать возможность создавать подписку со специальным именем. Для начала (если его ещё нет) мы должны добавить поле с именем, которое получаем из аргумента name, и поместить туда массив функций.

on(name, fn) {
  if (!this.events[name]) {
    this.events[name] = [];
  }

  this.events[name].push(fn);
}

Выглядит неплохо, но мы помним, что функция может быть анонимная, а нам нужно уметь отписываться именно от неё.

Так как функция — это объект (ссылочный тип), нам нужно хранить ссылку на каждую конкретную функцию. Решить это можно, если метод on будет возвращать функцию отписки:

on(name, fn) {
  if (!this.events[name]) {
    this.events[name] = [];
  }

  this.events[name].push(fn);

  return () => {
    this.off(name, fn);
  };
}

Хорошо, но всё-таки можно улучшить. Мы должны учитывать, что:

  1. может быть несколько одинаковых функций для одного события;

  2. нам нужно уметь отписываться от конкретного экземпляра подписки.

С текущим решением может случиться следующее:

const emitter = new EventEmitter();

function handler() {
  console.log("hi");
}

const off1 = emitter.on("event", handler);
const off2 = emitter.on("event", handler);

// вызов off2 снимет первую подписку, а не вторую

Чтобы это исправить — вместо хранения «чистых» функций будем хранить объекты с полем fn.

on(name, fn) {
  if (!this.events[name]) {
    this.events[name] = [];
  }

  const listener = { fn };
  this.events[name].push(listener);

  return () => {
    this.off(name, listener);
  };
}

Также, такой подход позволит в будущем легко расширять функциональность (например, добавить once, priority и т.д.).


Метод off

Для отписки нам нужно убрать элемент из массива. Так как теперь мы можем передать в off как функцию, так и объект listener, нужно учесть оба варианта:

off(name, listenerOrFn) {
  if (!this.events[name]) return;

  const predicate = (listener) =>
    listener === listenerOrFn || listener.fn === listenerOrFn;

  this.events[name] = this.events[name].filter((l) => !predicate(l));
}

Метод emit

Метод emit вызывает все подписчики события с переданными аргументами.

emit(name, ...args) {
  if (!this.events[name]) return;

  this.events[name].forEach((listener) => listener.fn(...args));
}

Но здесь есть подводный камень: если в процессе выполнения один из обработчиков удалит себя (off), мы изменим массив прямо во время обхода. Это может привести к ошибкам. Поэтому нужно обходить копию массива (например, через spread оператор, или, как мне больше нравится - с помощью метода массива slice()):

emit(name, ...args) {
  const listeners = this.events[name];
  if (!listeners) return;

  listeners.slice().forEach((listener) => {
    listener.fn(...args);
  });
}

Финальное решение

class EventEmitter {
  events = {};

  // подписка на событие
  on(name, fn) {
    if (!this.events[name]) {
      this.events[name] = [];
    }

    const listener = { fn };
    this.events[name].push(listener);

    // возвращаем функцию отписки
    return () => {
      this.off(name, listener);
    };
  }

  // отписка от события
  off(name, listenerOrFn) {
    if (!this.events[name]) return;

    const predicate = (listener) =>
      listener === listenerOrFn || listener.fn === listenerOrFn;

    this.events[name] = this.events[name].filter((l) => !predicate(l));
  }

  // вызов всех функций события
  emit(name, ...args) {
    const listeners = this.events[name];
    if (!listeners) return;

    listeners.slice().forEach((listener) => {
      listener.fn(...args);
    });
  }
}

Спасибо что прочитали, буду рад комментариям и советам по возможному улучшению!

Комментарии (6)


  1. apevzner
    21.08.2025 08:08

    может быть несколько одинаковых функций для одного события;

    Вот кому может понадобиться дважды подписать одну функцию? Если кто-то так сделает, это ж явная ошибка. Не лучше ли это контролировать, бросаться там исключениями, если такое произойдет, чем городить огород, пытаясь это поддержать.

    И второй вопрос, что будет, если обработчик событий сам позовёт emit?


    1. frontend-nikolai-maslov Автор
      21.08.2025 08:08

      по второму вопросу не понял, раскрой пожалуйста подробнее


      1. apevzner
        21.08.2025 08:08

        Вы "подписали" функци на событие. Произошло событие. Её позвали. Она сама сгенерировала еще одно событие. Её опять позвали. И так по кругу, пока стек не кончится.


        1. frontend-nikolai-maslov Автор
          21.08.2025 08:08

          В текущей реализации - конечно будет переполнение.
          Теоретически наверное можно было бы пофиксить например дополнительным условием с переменной inProgress с выходом если он true.
          Но вообще в некоторых реальных EventEmitter такие кейсы просто перекладываются под ответственность разработчика, могу ошибаться, но насколько помню - EventEmitter в Node.js не имеет защиты от такого


    1. frontend-nikolai-maslov Автор
      21.08.2025 08:08

      Вот кому может понадобиться дважды подписать одну функцию?

      Вообще это просто условие задачи, которое дали на конкретном собеседовании.
      Но от себя навскидку могу предположить такие кейсы:
      1) если в дальнейшем планируется расширение функционала с возможностью задания дополнительных флагов объекту listener (once/priority и тд) - может возникнуть необходимость хранения таких разных listener с одной и той же функцией
      2) возможность передать разные варианты контекста, вроде такого:

      emitter.on("update", handler.bind(moduleA));
      emitter.on("update", handler.bind(moduleB));

      Конечно возможно удобнее выбрасывать ошибку, но потенциально в реальной жизни это может зависеть от конкретных целей.


  1. DmitryOlkhovoi
    21.08.2025 08:08

    Если вам так нравятся такие задачки, попробуйте написать свой bind. Куда познавательней, чем ивент емитер))))