Собираем EventEmitter: on, once, off, emit и его ловушки

потоки≈ 14 минут

Здесь будет встроен видео-разбор темы (5–7 минут, YouTube / RuTube / VK Видео) — тем, кто лучше воспринимает на слух, текст ниже не обязателен.

В прошлой главе планировщик решал, чей колбэк выполнить следующим. Но колбэк сокета не знает, кому нужны пришедшие байты: в Node их раздаёт излучатель событий. На нём стоят сервер с событием request, сокет с data и error, поток и сам процесс. Устроен он проще, чем кажется: таблица «имя события → список функций», и собирается она в двадцать с небольшим строк. Интересное — в мелочах. Слушатель снимает соседа посреди прохода. off не находит подписку, сделанную через once. От одного error падает весь процесс. А на одиннадцатом слушателе Node предупреждает об утечке. Каждая ловушка ниже воспроизведена запуском, а в конце те же проверки пройдут на настоящем node:events.

Разделы темы:·

С подписками вы встречались в браузере: addEventListener кладёт функцию в список элемента, а событие зовёт этот список по порядку (несколько обработчиков на одном элементе). В Node то же делает класс EventEmitter из модуля node:events, только глаголы короче: on подписывает, off снимает, emit испускает событие. Объекта события нет: всё, что передали в emit после имени, слушатель получает аргументами.

Своя версия — Map «имя события → массив функций». Похожий класс уже был в веб-модуле Expo, но подписки там лежали в Set. Здесь массив, как в самом Node, а почему не Set — в заметке в конце раздела. on дописывает функцию в конец массива, off вырезает её, emit проходит по массиву и зовёт каждую. Зовёт через apply(this, args): если слушатель — обычная функция, а не стрелка, this в нём указывает на сам излучатель, как и в Node. Прогноз: в каком порядке появятся шесть строк и что вернёт emit для события, на которое никто не подписан?

js
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
разбор вывода
emit — не очередь и не отложенный вызов, а обычный цикл. Оба слушателя выполнились внутри вызова — раньше, чем console.log получил его результат. Поэтому «журнал» и «ответ» стоят до строки «emit вернул true». Асинхронность событиям Node придаёт не излучатель, а цикл событий. Цикл решает, когда колбэк сокета получит байты, а внутри колбэка вызов emit раздаёт данные слушателям сразу, на том же стеке. Отсюда практическое следствие: долгий слушатель задерживает и соседей по списку, и код, который вызвал emit.

emit возвращает true, если слушатели были, и false, если нет. Событие без слушателей — не ошибка: его просто некому услышать. Почти всегда: у одного имени правило другое, до него дойдём.

Одну и ту же функцию EventEmitter разрешает подписать дважды, и позовут её дважды: браузер такой дубликат отбрасывал. Поэтому off ищет с конца — из двух одинаковых подписок снимается последняя.

Первое, что захочется дописать, — слушатель, который срабатывает один раз. Без специального метода он пишется руками: слушатель вызывает off и передаёт в него себя. Излучатель из прошлого примера лежит в подложке без изменений. У двери два слушателя: «первый гость» снимает себя, «звонок» остаётся. Прогноз: что напечатают два emit подряд?

js
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

В первом проходе звонка нет, хотя его никто не снимал. Цикл for...of идёт по массиву по номерам: выполнил элемент 0 — возьмёт элемент 1. Пока выполнялся элемент 0, off его вырезал, и звонок съехал на место 0. А цикл уже шагнул на место 1, где ничего нет. Ни ошибки, ни исключения — одного слушателя просто не позвали. Так ломается любой одноразовый слушатель в наивном излучателе: сработав, он лишает события соседа.

Лечение — одна пара скобок: emit проходит не по живому массиву, а по его копии [...list] (spread в литерале массива). Копия снята до первого вызова, и что бы слушатели ни делали со списком, этот проход идёт по ней до конца. Ниже тот же класс с одной правкой в emit. Прогноз: что изменится в выводе?

js
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.

Звонок звучит в обоих проходах. Правка списка теперь касается только следующих вызовов emit.

У копии есть обратная сторона, и её стоит проверить. Пусть первый слушатель снимает не себя, а соседа, и заодно подписывает третьего. Прогоним этот сценарий на своём излучателе и на EventTarget — том самом классе, что стоит за addEventListener: в браузере его можно создать и без элемента. Прогноз: что напечатает каждый?

js
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Вывод разошёлся. Свой излучатель позвал снятого второго: копию сняли до прохода, и второй в ней был. EventTarget второго пропустил. А позднего не позвал никто.

Это два разных договора, а не случайность реализации. Спецификация DOM тоже копирует список перед проходом — потому поздний не выполнился и в браузере. Но у каждой подписки там есть отметка «снята», и перед вызовом браузер её проверяет. У EventEmitter отметки нет: что попало в копию, то и будет вызвано. Отсюда правило для кода на Node: off посреди emit этот проход не отменяет. Если слушателю нельзя работать после какого-то события — скажем, соединение уже закрыто, — он проверяет это сам, а не рассчитывает, что его успели снять.

В самом Node копия устроена хитрее, но договор тот же. По Node 24.14 и 25.8 включительно emit копировал массив при каждом вызове, как миниатюра. С 24.15 и 25.9 копии на каждый emit нет: пока идёт проход, массив помечен счётчиком, и on с off, увидев метку, правят не его, а свежую копию. Лишний массив появляется, только когда список правят посреди прохода, а снаружи разницы не видно.

Теперь одноразовую подписку можно сделать методом. once подписывает не саму функцию, а обёртку: обёртка снимает себя и зовёт настоящую. Снимает до вызова — так же браузер обходится с опцией once. Излучатель с копией списка лежит в подложке, once дописан в наследнике. Человек подписал greet через once, передумал и снял её через off. Прогноз: что напечатают два emit?

js
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

off промахнулся, и greet сработала. В списке лежит обёртка, а lastIndexOf(greet) ищет саму greet и не находит. Второй emit вернул false, потому что обёртка, отработав, сняла себя сама. Тот, кто пишет once(greet), а потом off(greet), про обёртку не знает и знать не должен.

Поэтому обёртка хранит исходную функцию в свойстве listener, а off сравнивает искомую функцию и с элементом списка, и с тем, что элемент помнит. Так сделано и в Node: rawListeners() отдаёт обёртки, listeners() — исходные функции, и подписку через once снимает off с исходной. Прогноз: что напечатается теперь?

js
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

В списке по-прежнему обёртка, но off её нашёл — по свойству listener, и emit звать больше некого. findLastIndex ищет с конца по той же причине, что lastIndexOf раньше: из двух одинаковых подписок снимается последняя.

Событие без слушателей обычно ничего не значит: emit вернул false, и всё. Одно имя — исключение. Если испущен error, а слушателей у него нет, emit бросает ту самую ошибку, которую ему передали. Проверка стоит в начале emit, до прохода по списку. В миниатюре это наследник, который после проверки зовёт родительский emit через super. Прогноз: какие строки напечатаются, а какая нет?

js
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Строки «после error» нет: исключение вылетело из emit и перескочило её. А с подписанным слушателем то же событие стало обычным: слушатель получил ошибку, emit вернул true.

Зачем такое правило? Ошибка, которую никто не слушает, — это ошибка, которую никто не обработал. Промолчать значило бы потерять её: сокет оборвался, запрос остался без ответа, а в логе пусто. Node выбирает громкий вариант. И громкий всерьёз: error сокета испускается из колбэка, который запустил цикл событий, когда try вокруг кода, открывшего соединение, давно закрыт (где try..catch промахивается). Ловить исключение некому, и процесс завершается. А один процесс Node держит все соединения сервера разом, так что без слушателя error одно оборванное соединение роняет сервер вместе со всеми остальными. Отсюда правило: у каждого излучателя, который может испустить error, — сервера, сокета, потока — слушатель error есть всегда.

Исключение из самого слушателя тоже вылетает из emit наружу, и соседи, стоящие в списке после него, в этом проходе не выполнятся: цикл внутри emit ничего не ловит.

Тот же error на настоящем node:events, только ловить его некому. Прогноз: напечатается ли «после error» и с каким кодом завершится процесс?

прогон node 26.5.0 · 15 сентября 2026
Тот же error на настоящем node:events, без try; внутренние кадры node и номер версии закрыты масками
import { EventEmitter } from "node:events";

const socket = new EventEmitter();
socket.on("data", (chunk) => console.log("данные:", chunk));

socket.emit("data", "GET /health HTTP/1.1");
socket.emit("error", new Error("read ECONNRESET"));
console.log("после error");
node crash.mjs

Данные дошли, потом — стек ошибки и код выхода 1, до строки «после error» дело не дошло. Под стеком ошибки Node дописывает второй: после Emitted 'error' event at: идёт стек того вызова emit, который испустил событие. Здесь оба указывают на седьмую строку: ошибку создали там же, где испустили.

Последняя ловушка сидит не в излучателе, а в том, как им пользуются. Подписка живёт, пока её не сняли, а с ней живёт всё, что слушатель замкнул. В браузере это было на подписках к document (забытая отписка и утечки). На сервере долгожитель — любой общий объект: соединение с базой, общая шина событий, сам процесс. Подписываются на него часто в обработчике запроса. Если запрос не отписался, когда ответил, каждый следующий добавляет по слушателю.

Какая подписка лишняя, Node знать не может, зато может считать. Как только у одного события одного излучателя становится больше десяти слушателей, он печатает предупреждение — не на каждой следующей подписке, а один раз. Порог по умолчанию — 10. Миниатюра ниже считает так же. Двенадцать запросов подписываются на правки в базе и не отписываются. Прогноз: где в выводе встанет предупреждение, сколько слушателей будет в конце и скольких разбудит одна правка?

js
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

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

Лечат отпиской, а не порогом. Запрос снимает свой слушатель, когда ответил: off там, где отправлен ответ, или once, если нужно ровно одно событие. Поднять порог через setMaxListeners(n) правильно, только когда много слушателей — норма: скажем, у комнаты чата по слушателю на участника. setMaxListeners(0) снимает счётчик совсем, а с ним и единственный сигнал, что подписки текут.

Сверим миниатюру с оригиналом. В файле ниже пять проверок статьи на EventEmitter из node:events: снятие и подписка посреди прохода, once и off с исходной функцией, error без слушателя, двенадцать подписок на одно событие. Прогноз: совпадёт ли вывод с тем, что печатали примеры, и где встанет предупреждение об утечке?

прогон node 26.5.0 · 15 сентября 2026
Те же пять проверок на настоящем node:events; номер процесса в выводе закрыт маской
import { EventEmitter } from "node:events";

// 1 и 2: снятие и подписка посреди прохода
const room = new EventEmitter();
function first() {
  console.log("первый");
  room.off("tick", second);
  room.on("tick", late);
}
function second() {
  console.log("второй");
}
function late() {
  console.log("поздний");
}
room.on("tick", first).on("tick", second);
room.emit("tick");
console.log("слушателей tick после прохода:", room.listenerCount("tick"));

// 3: once, потом off с исходной функцией
const chat = new EventEmitter();
function greet(who) {
  console.log("привет,", who);
}
chat.once("join", greet);
console.log("в списке сама greet:", chat.rawListeners("join")[0] === greet);
chat.off("join", greet);
console.log("emit после off:", chat.emit("join", "Аня"));

// 4: error без слушателя
const socket = new EventEmitter();
console.log("close без слушателей:", socket.emit("close"));
try {
  socket.emit("error", new Error("read ECONNRESET"));
  console.log("после error");
} catch (err) {
  console.log("вылетело из emit:", err.message);
}

// 5: двенадцать подписок на одно событие
const db = new EventEmitter();
for (let id = 1; id <= 12; id++) db.on("change", () => {});
console.log("слушателей change:", db.listenerCount("change"));
console.log("конец синхронного кода");
node events.mjs

Первые девять строк — ровно то, что обещали примеры: снятый второй выполнился, поздний нет; в списке лежит обёртка, но off с greet её снял; error вылетел из emit; слушателей двенадцать. А предупреждение пришло последним, после «конец синхронного кода». Node не печатает его внутри on: он отдаёт его в process.emitWarning, а тот выводит предупреждение в поток ошибок, когда синхронный код уже закончился. (node:<pid>) в начале — номер процесса. Подсказку про --trace-warnings Node добавляет к первому предупреждению: запущенный с этим флагом, он печатает стек, и в нём видна строка с одиннадцатым on.

Чем ещё оригинал отличается от миниатюры — в таблице.

Миниатюраnode:events 26.5.0
копия списка[...list] при каждом emitтолько когда список правят посреди прохода; до 24.15 и 25.9 — при каждом emit
onceобёртка со свойством listenerтак же; rawListeners() отдаёт обёртки, listeners() — исходные функции
error без слушателябросает переданную ошибкутак же; значение, которое не Error, заворачивает в ошибку с кодом ERR_UNHANDLED_ERROR; слушатель errorMonitor видит ошибку, но бросок не отменяет
больше 10 слушателейconsole.warn внутри onprocess.emitWarning, вывод после синхронного кода; порог — setMaxListeners() и events.defaultMaxListeners
async-слушатель бросилотказ промиса никто не ловитпо умолчанию так же, и процесс падает; с captureRejections: true отказ становится событием error
чего в миниатюре нет—prependListener, события newListener и removeListener, events.once() с промисом

Все правки статьи одним классом: копия списка, once с исходной функцией, error и счётчик слушателей. Внизу — те же пять проверок, что в events.mjs. Прогноз: какая строка встанет не туда, где её напечатал Node?

js
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.

Только предупреждение: миниатюра печатает его внутри on, в момент одиннадцатой подписки, поэтому оно стоит перед «слушателей change: 12». Текст у него тоже свой, короче. Остальные девять строк совпали с Node.

На этом классе стоит следующая статья: читающий и пишущий поток — это излучатель с буфером и событиями data и end. А ловушки отсюда вернутся в главе про HTTP: сервер, запрос и сокет в Node — тоже излучатели.

  • Излучатель — таблица «имя события → список функций»: on дописывает, off вырезает, emit проходит по списку синхронно, на том же стеке, и возвращает, были ли слушатели.
  • emit идёт по копии списка: снятый посреди прохода слушатель в этом проходе всё равно выполнится, добавленный — нет. Без копии одноразовый слушатель лишал бы события соседа. EventTarget браузера снятого пропускает.
  • once подписывает обёртку, которая снимает себя до вызова и хранит исходную функцию в свойстве listener, — по нему off и находит такую подписку.
  • emit("error") без слушателя бросает переданную ошибку. Брошенная из колбэка цикла событий, она завершает процесс, а с ним все соединения, поэтому у сервера, сокета и потока слушатель error есть всегда.
  • Больше десяти слушателей одного события — предупреждение MaxListenersExceededWarning, а не отказ. Обычно это подписка в обработчике запроса без отписки, и лечат её снятием подписки, а не setMaxListeners.
  • Настоящий node:events держит те же договоры. Предупреждение он печатает после синхронного кода, с версий 24.15 и 25.9 копирует список только при правке посреди прохода, а сверх миниатюры у него есть errorMonitor, captureRejections и events.once().

1/4Слушатель A стоит в списке первым и вызывает off для слушателя B, который стоит вторым. Событие испущено один раз. Кто выполнится в этом проходе?

2/4Функцию подписали через once и сразу сняли через off с той же функцией. Почему в наивной миниатюре она всё равно сработала?

3/4У излучателя нет ни одного слушателя. Что сделают emit("close") и emit("error", new Error("сбой"))?

4/4Node напечатал MaxListenersExceededWarning про 11 слушателей change. Что с одиннадцатым слушателем и что делать?

Промис вместо события

Событие часто нужно дождаться один раз: сокет подключился — идём дальше, упал — ловим ошибку. Напишите waitFor(emitter, name): промис выполняется значением первого события name и отклоняется, если раньше пришёл error. Проверка печатает, сколько слушателей осталось после ожидания, и должно оставаться 0. Сейчас промис не выполняется никогда, и main молчит.

js
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

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

jsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Вывод:

jsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Короткая версия без взаимной отписки — emitter.once(name, resolve) и emitter.once("error", reject) — печатает те же «подключились» и «не подключились», но после каждого ожидания оставляет по слушателю: после успеха висит слушатель error, после отказа — слушатель connect. Позовите такую waitFor в цикле, и на одиннадцатом круге придёт предупреждение из раздела про утечку. В Node готовая функция уже есть — once(emitter, name) из node:events. Её промис выполняется массивом аргументов события, отклоняется на error и слушателей после себя не оставляет.

Отписка по сигналу

В браузере пачку подписок снимал один вызов abort() (AbortController). Научите излучатель тому же: on(name, fn, { signal }) снимает подписку, когда сигнал прерван, а с уже прерванным сигналом не подписывает вовсе. Сейчас опция не действует, и после abort() слушатель продолжает тикать.

js
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Прерванный сигнал проверяется до подписки, а на живой вешается слушатель abort, который снимает функцию обычным off:

jsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Вывод:

jsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Сигнал сам — EventTarget, поэтому подписка на abort — обычный addEventListener, а once: true потому, что прервать сигнал можно один раз. Метод once из подложки тоже проходит через новый on: он зовёт this.on без третьего аргумента, и значение по умолчанию = {} не даёт деструктуризации упасть.

Купон, который сняли слишком поздно

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

js
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера
jsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Купон сработал на первом заказе, хотя первый слушатель его снял. Копию списка emit снял до прохода, и обёртка купона в ней уже была. Сработав, обёртка снимает себя, но её к этому моменту в списке нет, и снимать нечего. Второй заказ купон не видит, а в списке остался один первый слушатель. Настоящий EventEmitter печатает то же самое. В браузере с { once: true } и removeEventListener купон не сработал бы вовсе: снятую подписку EventTarget пропускает. Если купон нельзя выдавать после отмены, проверяйте это в самом купоне, а не надейтесь на off.