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

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

Создайте класс 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);
    });
  }
}

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

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


  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. domix32
            21.08.2025 08:08

            Как разработчик, ловивший такие ситуации, крайне рекомендую иметь для такого кейса хотя бы двойную буфферизацию - то что "в работе" и то что будет обновляться на следующей итерации.

            Отдельно стоит заметить, что не очень понятно как нормально отписываться если несколько слушателей хотят получать одно и то же сообщение. То есть есть классы Foo и Bar, которые хотят слушать событие baz , через некоторое время Foo решает, что больше не хочет слушать это сообщение. Пока выглядит, что ваш код не позволяет нормально отписываться таким объектам и помимо этого похоже надо отдельно держать реестр с функциями, по которым можно было бы отписываться.

            Выглядит, что немного не хватает тестов, чтобы понимать проблемы с эргономикой этого класса, да и вцелом корректности.


          1. apevzner
            21.08.2025 08:08

            Вы не с той стороны заходите.

            Тут есть разные варианты. Но отталкиваться надо от того, что вы хотите получить. От желаемого поведения с учётом всех нюансов, а не от деталей реализации.

            А то получится в итоге, "что выросло, то выросло".

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

            Если предполагается какой-то из вариантов отложенной доставки событий, каковы гарантии в плане порядка их доставки?

            Итоговая модель, которая у вас получится, понятна ли она программистам, насколько сложно её описать, удобно ли ей пользоваться?

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

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


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

              А то получится в итоге, "что выросло, то выросло".


              Ну так... Да.. Это же просто описание моего опыта решения конкретной задачи на конкретном собеседовании, статья ведь кажется не называется "Я написал лучший EventEmitter", или "Научу писать лучший EventEmitter"? Вроде нет.
              Возможно мы по разному понимаем цель её написания - я просто поделился опытом, и не стремился научить кого-то работать по best practices, или моему пониманию прекрасного.
              Вы нашли какие-то недостатки? Супер, спасибо что поделились! Я согласен, что код не идеален, оно как бы изначально и не подразумевалось как пример идеального решения :)


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

              Опять же - вы ведь можете написать свой вариант с идеальным (на ваш взгляд решением), это будет очень круто, я с удовольствием ознакомлюсь, наверное даже научусь чему-то новому!


    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. apevzner
        21.08.2025 08:08

        В языке, в котором есть лямбды и замыкания, вроде нет проблем приложить к вызову функции дополнительный контекст...


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

          Мы сейчас перебираем варианты решения абстрактной задачи с какого-то конкретного собеседования, в поисках того решения, который понравится конкретно вам, правильно я понимаю? :)


  1. DmitryOlkhovoi
    21.08.2025 08:08

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


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

      Да, вполне можно пробнуть, надо ознакомиться, спасибо за рекомендацию!


  1. winkyBrain
    21.08.2025 08:08

    Код изначальной задачи вызывает вопросы) во-первых для events было бы явно удобнее использовать Map, во-вторых зачем здесь класс в принципе? Со всем вышеуказанным справится стрелочная(божечки, даже без this) функция, которая вернёт объект с указанными методами, а результат её вызова будет так же записан в константу, с которой дальше проходят все операции


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

      Думаю, что вопросы релевантны, согласен с ними, но задача была поставлена таким образом и на тот момент я просто "работал с чем дали" :)


  1. frostsumonner
    21.08.2025 08:08

    Как может быть отписка в процессе emita? У вас же все синхронно.