Node.js once 的注销时机:回调里再次 emit,为什么不会再执行自己

前天 3阅读

初始化通知的处理器里,又触发了一次同名事件。如果用on注册并打算在函数末尾off,第二次通知可能在清理之前重新进入函数。把它换成once后,同样的递归emit却找不到这个监听了,区别在于注销发生在哪一步。

EventEmitter.once会在调用用户回调之前移除这次注册。同步重入时,新一轮emit看到的已经是移除后的监听清单。因此“一次”并不等价于“执行完了再移除”;这两个顺序在只有一次普通通知时看不出差异。

保存为demo.mjs,执行node demo.mjs。下面在Node.js v24.19.0实跑,没有网络、计时器或异步任务。手动对照组只允许一次嵌套触发,避免为了复现重入而制造无限递归。

Node.js once 的注销时机:回调里再次 emit,为什么不会再执行自己

AI模型生成的概念插图:铃铛已离开挂钩,回环轨道再次抵达时只剩空钩,表示在回调开始前解除这次订阅;不是运行截图。

完整可运行程序

import assert from 'node:assert/strict';
import {EventEmitter} from 'node:events';

const bus = new EventEmitter();
const trace = [];
bus.once('ready', () => {
  trace.push(`inside count=${bus.listenerCount('ready')}`);
  trace.push(`nested emit=${bus.emit('ready')}`);
});
const outer = bus.emit('ready');
assert.equal(outer, true);
assert.deepEqual(trace, ['inside count=0', 'nested emit=false']);
console.log(trace.join(' | '));
console.log(`outer emit=${outer}; after count=${bus.listenerCount('ready')}`);

const manual = new EventEmitter();
let calls = 0;
function removeAfter() {
  calls++;
  if (calls === 1) manual.emit('ready');
  manual.off('ready', removeAfter);
}
manual.on('ready', removeAfter);
manual.emit('ready');
assert.equal(calls, 2);
assert.equal(manual.listenerCount('ready'), 0);
console.log(`remove after callback: calls=${calls}`);

const failing = new EventEmitter();
failing.once('ready', () => {throw new Error('planned');});
assert.throws(() => failing.emit('ready'), /planned/);
assert.equal(failing.listenerCount('ready'), 0);
assert.equal(failing.emit('ready'), false);
console.log('throwing once listener: already removed');

本次实际输出

inside count=0 | nested emit=false
outer emit=true; after count=0
remove after callback: calls=2
throwing once listener: already removed

进入回调时,订阅已经消失

第一行inside count=0发生在once回调内部,说明用户代码刚开始运行,ready的监听数量已为零。随后nested emit=false表示内层触发没有找到监听;本例没有另一个ready处理器,因此不会产生其他业务动作。

外层emit返回true,结束后数量仍为零。这并不矛盾:外层开始触发时确实有监听,所以曾执行回调;内层开始时它已经被移除。emit的布尔返回值描述有没有监听被触发,不能把它当作回调返回值或业务成功标记。

末尾off无法保护前面的重入

手动组calls为二。第一次removeAfter先增加计数,再发出内层ready;这时函数还没走到off,所以同一个处理器会再次执行。内层不继续发事件,而是移除监听并返回,外层随后也调用off,最终数量虽然为零,处理器已经多运行了一次。

只在实验结束后检查listenerCount会让两组看起来一样,必须同时记录调用次数或轨迹。若确实要自己实现某种一次性包装,应在调用用户逻辑之前设置状态并解除对应注册,再安排错误处理;已有once能表达需求时,使用它更容易验证。

一次调用不承诺一次成功

最后一组回调主动抛出planned错误。assert.throws确认异常到达调用者,随后监听数量仍为零,再次emit返回false。因为移除早于执行,异常不会自动把监听注册回来;希望失败后重试,需要另外定义何时重新订阅。

同一个函数若被分别注册多次,每次注册都有自己的生命周期,once并不提供按业务编号去重。处理器里面如果又显式注册新的once,未来事件也可以再次执行它。排查重复动作时应核对注册次数,而不只搜索函数是否使用了once。

这里验证的是同步EventEmitter的监听行为。回调启动异步工作后,解除订阅不会撤销已启动的工作,也不会证明那项工作完成。初始化流程应分别记录通知是否消费、动作是否完成以及失败如何恢复,避免把一次调用次数当成完整事务保证。

官方资料核验日期:2026年10月2日。上述输出来自本文完整程序的本地执行,全部断言通过,退出状态为零。

参考资料

Node.js 24文档:emitter.once先移除再调用

文章版权声明:除非注明,否则均为云鹊BLOG原创文章,转载或复制请以超链接形式并注明出处。