Собираем EventEmitter: on, once, off, emit и его ловушки
Здесь будет встроен видео-разбор темы (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 для события, на которое никто не подписан?
emit — не очередь и не отложенный вызов, а обычный цикл. Оба слушателя выполнились внутри вызова — раньше, чем console.log получил его результат. Поэтому «журнал» и «ответ» стоят до строки «emit вернул true». Асинхронность событиям Node придаёт не излучатель, а цикл событий. Цикл решает, когда колбэк сокета получит байты, а внутри колбэка вызов emit раздаёт данные слушателям сразу, на том же стеке. Отсюда практическое следствие: долгий слушатель задерживает и соседей по списку, и код, который вызвал emit.emit возвращает true, если слушатели были, и false, если нет. Событие без слушателей — не ошибка: его просто некому услышать. Почти всегда: у одного имени правило другое, до него дойдём.
Одну и ту же функцию EventEmitter разрешает подписать дважды, и позовут её дважды: браузер такой дубликат отбрасывал. Поэтому off ищет с конца — из двух одинаковых подписок снимается последняя.
Первое, что захочется дописать, — слушатель, который срабатывает один раз. Без специального метода он пишется руками: слушатель вызывает off и передаёт в него себя. Излучатель из прошлого примера лежит в подложке без изменений. У двери два слушателя: «первый гость» снимает себя, «звонок» остаётся. Прогноз: что напечатают два emit подряд?
В первом проходе звонка нет, хотя его никто не снимал. Цикл for...of идёт по массиву по номерам: выполнил элемент 0 — возьмёт элемент 1. Пока выполнялся элемент 0, off его вырезал, и звонок съехал на место 0. А цикл уже шагнул на место 1, где ничего нет. Ни ошибки, ни исключения — одного слушателя просто не позвали. Так ломается любой одноразовый слушатель в наивном излучателе: сработав, он лишает события соседа.
Лечение — одна пара скобок: emit проходит не по живому массиву, а по его копии [...list] (spread в литерале массива). Копия снята до первого вызова, и что бы слушатели ни делали со списком, этот проход идёт по ней до конца. Ниже тот же класс с одной правкой в emit. Прогноз: что изменится в выводе?
Звонок звучит в обоих проходах. Правка списка теперь касается только следующих вызовов emit.
У копии есть обратная сторона, и её стоит проверить. Пусть первый слушатель снимает не себя, а соседа, и заодно подписывает третьего. Прогоним этот сценарий на своём излучателе и на EventTarget — том самом классе, что стоит за addEventListener: в браузере его можно создать и без элемента. Прогноз: что напечатает каждый?
Вывод разошёлся. Свой излучатель позвал снятого второго: копию сняли до прохода, и второй в ней был. 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?
off промахнулся, и greet сработала. В списке лежит обёртка, а lastIndexOf(greet) ищет саму greet и не находит. Второй emit вернул false, потому что обёртка, отработав, сняла себя сама. Тот, кто пишет once(greet), а потом off(greet), про обёртку не знает и знать не должен.
Поэтому обёртка хранит исходную функцию в свойстве listener, а off сравнивает искомую функцию и с элементом списка, и с тем, что элемент помнит. Так сделано и в Node: rawListeners() отдаёт обёртки, listeners() — исходные функции, и подписку через once снимает off с исходной. Прогноз: что напечатается теперь?
В списке по-прежнему обёртка, но off её нашёл — по свойству listener, и emit звать больше некого. findLastIndex ищет с конца по той же причине, что lastIndexOf раньше: из двух одинаковых подписок снимается последняя.
Событие без слушателей обычно ничего не значит: emit вернул false, и всё. Одно имя — исключение. Если испущен error, а слушателей у него нет, emit бросает ту самую ошибку, которую ему передали. Проверка стоит в начале emit, до прохода по списку. В миниатюре это наследник, который после проверки зовёт родительский emit через super. Прогноз: какие строки напечатаются, а какая нет?
Строки «после error» нет: исключение вылетело из emit и перескочило её. А с подписанным слушателем то же событие стало обычным: слушатель получил ошибку, emit вернул true.
Зачем такое правило? Ошибка, которую никто не слушает, — это ошибка, которую никто не обработал. Промолчать значило бы потерять её: сокет оборвался, запрос остался без ответа, а в логе пусто. Node выбирает громкий вариант. И громкий всерьёз: error сокета испускается из колбэка, который запустил цикл событий, когда try вокруг кода, открывшего соединение, давно закрыт (где try..catch промахивается). Ловить исключение некому, и процесс завершается. А один процесс Node держит все соединения сервера разом, так что без слушателя error одно оборванное соединение роняет сервер вместе со всеми остальными. Отсюда правило: у каждого излучателя, который может испустить error, — сервера, сокета, потока — слушатель error есть всегда.
Исключение из самого слушателя тоже вылетает из emit наружу, и соседи, стоящие в списке после него, в этом проходе не выполнятся: цикл внутри emit ничего не ловит.
Тот же error на настоящем node:events, только ловить его некому. Прогноз: напечатается ли «после error» и с каким кодом завершится процесс?
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");
данные: GET /health HTTP/1.1
file://<пример>/crash.mjs:7
socket.emit("error", new Error("read ECONNRESET"));
^
Error: read ECONNRESET
at file://<пример>/crash.mjs:7:22
at <внутренние кадры node>
Emitted 'error' event at:
at file://<пример>/crash.mjs:7:8
at <внутренние кадры node>
Node.js v<версия>Данные дошли, потом — стек ошибки и код выхода 1, до строки «после error» дело не дошло. Под стеком ошибки Node дописывает второй: после Emitted 'error' event at: идёт стек того вызова emit, который испустил событие. Здесь оба указывают на седьмую строку: ошибку создали там же, где испустили.
Последняя ловушка сидит не в излучателе, а в том, как им пользуются. Подписка живёт, пока её не сняли, а с ней живёт всё, что слушатель замкнул. В браузере это было на подписках к document (забытая отписка и утечки). На сервере долгожитель — любой общий объект: соединение с базой, общая шина событий, сам процесс. Подписываются на него часто в обработчике запроса. Если запрос не отписался, когда ответил, каждый следующий добавляет по слушателю.
Какая подписка лишняя, Node знать не может, зато может считать. Как только у одного события одного излучателя становится больше десяти слушателей, он печатает предупреждение — не на каждой следующей подписке, а один раз. Порог по умолчанию — 10. Миниатюра ниже считает так же. Двенадцать запросов подписываются на правки в базе и не отписываются. Прогноз: где в выводе встанет предупреждение, сколько слушателей будет в конце и скольких разбудит одна правка?
Предупреждение пришло на одиннадцатом слушателе, а двенадцатый встал в список следом как ни в чём не бывало: это счётчик, а не запрет. Одна правка в базе разбудила двенадцать функций от запросов, которые давно получили ответ. На живом сервере их тысячи, каждая держит в замыкании свой запрос, и память растёт до перезапуска процесса.
Лечат отпиской, а не порогом. Запрос снимает свой слушатель, когда ответил: off там, где отправлен ответ, или once, если нужно ровно одно событие. Поднять порог через setMaxListeners(n) правильно, только когда много слушателей — норма: скажем, у комнаты чата по слушателю на участника. setMaxListeners(0) снимает счётчик совсем, а с ним и единственный сигнал, что подписки текут.
Сверим миниатюру с оригиналом. В файле ниже пять проверок статьи на EventEmitter из node:events: снятие и подписка посреди прохода, once и off с исходной функцией, error без слушателя, двенадцать подписок на одно событие. Прогноз: совпадёт ли вывод с тем, что печатали примеры, и где встанет предупреждение об утечке?
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("конец синхронного кода");
первый второй слушателей tick после прохода: 2 в списке сама greet: false emit после off: false close без слушателей: false вылетело из emit: read ECONNRESET слушателей change: 12 конец синхронного кода (node:<pid>) MaxListenersExceededWarning: Possible EventEmitter memory leak detected. 11 change listeners added to [EventEmitter]. MaxListeners is 10. Use emitter.setMaxListeners() to increase limit (Use `node --trace-warnings ...` to show where the warning was created)
Первые девять строк — ровно то, что обещали примеры: снятый второй выполнился, поздний нет; в списке лежит обёртка, но 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 внутри on | process.emitWarning, вывод после синхронного кода; порог — setMaxListeners() и events.defaultMaxListeners |
| async-слушатель бросил | отказ промиса никто не ловит | по умолчанию так же, и процесс падает; с captureRejections: true отказ становится событием error |
| чего в миниатюре нет | — | prependListener, события newListener и removeListener, events.once() с промисом |
Все правки статьи одним классом: копия списка, once с исходной функцией, error и счётчик слушателей. Внизу — те же пять проверок, что в events.mjs. Прогноз: какая строка встанет не туда, где её напечатал Node?
Только предупреждение: миниатюра печатает его внутри 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 молчит.
Подписаться нужно на оба события, и каждое, сработав, обязано снять второе, иначе слушатель проигравшего останется висеть.
Вывод:
Короткая версия без взаимной отписки — 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() слушатель продолжает тикать.
Прерванный сигнал проверяется до подписки, а на живой вешается слушатель abort, который снимает функцию обычным off:
Вывод:
Сигнал сам — EventTarget, поэтому подписка на abort — обычный addEventListener, а once: true потому, что прервать сигнал можно один раз. Метод once из подложки тоже проходит через новый on: он зовёт this.on без третьего аргумента, и значение по умолчанию = {} не даёт деструктуризации упасть.
Купон, который сняли слишком поздно
Предскажите обе строки вывода и только потом запускайте. Первый слушатель заказа снимает купон, а купон подписан через once вторым.
Купон сработал на первом заказе, хотя первый слушатель его снял. Копию списка emit снял до прохода, и обёртка купона в ней уже была. Сработав, обёртка снимает себя, но её к этому моменту в списке нет, и снимать нечего. Второй заказ купон не видит, а в списке остался один первый слушатель. Настоящий EventEmitter печатает то же самое. В браузере с { once: true } и removeEventListener купон не сработал бы вовсе: снятую подписку EventTarget пропускает. Если купон нельзя выдавать после отмены, проверяйте это в самом купоне, а не надейтесь на off.