Однажды на собеседовании мне предложили решить одну интересную задачу, которая для меня была довольно необычной на тот момент.
Позже я обнаружил, что задача была не особо уникальной, но с высоты моего опыта тогда, она показалась довольно будоражащей.
Условие задачи
Создайте класс 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);
};
}
Хорошо, но всё-таки можно улучшить. Мы должны учитывать, что:
может быть несколько одинаковых функций для одного события;
нам нужно уметь отписываться от конкретного экземпляра подписки.
С текущим решением может случиться следующее:
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)
DmitryOlkhovoi
21.08.2025 08:08Если вам так нравятся такие задачки, попробуйте написать свой bind. Куда познавательней, чем ивент емитер))))
apevzner
Вот кому может понадобиться дважды подписать одну функцию? Если кто-то так сделает, это ж явная ошибка. Не лучше ли это контролировать, бросаться там исключениями, если такое произойдет, чем городить огород, пытаясь это поддержать.
И второй вопрос, что будет, если обработчик событий сам позовёт emit?
frontend-nikolai-maslov Автор
по второму вопросу не понял, раскрой пожалуйста подробнее
apevzner
Вы "подписали" функци на событие. Произошло событие. Её позвали. Она сама сгенерировала еще одно событие. Её опять позвали. И так по кругу, пока стек не кончится.
frontend-nikolai-maslov Автор
В текущей реализации - конечно будет переполнение.
Теоретически наверное можно было бы пофиксить например дополнительным условием с переменной inProgress с выходом если он true.
Но вообще в некоторых реальных EventEmitter такие кейсы просто перекладываются под ответственность разработчика, могу ошибаться, но насколько помню - EventEmitter в Node.js не имеет защиты от такого
frontend-nikolai-maslov Автор
Вообще это просто условие задачи, которое дали на конкретном собеседовании.
Но от себя навскидку могу предположить такие кейсы:
1) если в дальнейшем планируется расширение функционала с возможностью задания дополнительных флагов объекту listener (once/priority и тд) - может возникнуть необходимость хранения таких разных listener с одной и той же функцией
2) возможность передать разные варианты контекста, вроде такого:
Конечно возможно удобнее выбрасывать ошибку, но потенциально в реальной жизни это может зависеть от конкретных целей.