首页
/ Socket.IO 事件基石 @socket.io/component-emitter:on/once/off/emit 完整 API 与源码级解析

Socket.IO 事件基石 @socket.io/component-emitter:on/once/off/emit 完整 API 与源码级解析

2026-09-04 23:45:55作者:戚魁泉Nursing

@socket.io/component-emitter 是 Socket.IO 各核心包共用的轻量事件发射器组件,提供 ononceoffemitlistenershasListeners 一套极简 API,并附带 Socket.IO 专属的 TypeScript 类型定义。读完本文,你能掌握该组件的三种使用模式(实例、mixin、原型扩展)、每个 API 的语义细节,以及从源码层面理解其回调存储结构、once 包装机制、防内存泄漏设计与双端(CJS/ESM)打包方式,从而在扩展 Socket.IO 生态时能够正确复用这套事件系统。

组件定位:一个带类型定义的轻量 Emitter

@socket.io/component-emitter 的定位是一枚"事件发射器组件"(Event emitter component)。根据 Readme 中的说明,它是 component-emitter 项目的 fork,区别在于内置了面向 Socket.IO 场景的 TypeScript typings——也就是说,运行时代码保留了原项目极简、零依赖的特性,而类型层面则与 Socket.IO 的"类型化事件"(typed events)体系对齐。

package.json 可以确认组件的关键元信息:

  • 包名:@socket.io/component-emitter,许可证 MIT(见 LICENSE
  • 入口:CJS 为 ./lib/cjs/index.js,ESM 为 ./lib/esm/index.js,类型声明指向 ./lib/cjs/index.d.ts
  • 测试命令:mocha --require should --reporter spec,对应测试文件为 test/emitter.js

安装方式沿用标准 npm:

npm i @socket.io/component-emitter

三种使用方式:实例、mixin 与原型扩展

Readme 的 API 部分首先介绍了 Emitter(obj) 的三种用法,这也是该组件区别于 new EventEmitter() 的典型特征。

1. 作为 Emitter 实例使用:

import { Emitter } from '@socket.io/component-emitter';

var emitter = new Emitter;
emitter.emit('something');

2. 作为 mixin 使用——一个"普通"对象可以被就地变成 emitter:

import { Emitter } from '@socket.io/component-emitter';

var user = { name: 'tobi' };
Emitter(user);

user.emit('im a user');

3. 作为原型 mixin——直接把发射器方法挂到某个类的原型上:

import { Emitter } from '@socket.io/component-emitter';

Emitter(User.prototype);

从源码看,mixin 的实现非常直接:lib/cjs/index.js 中,构造函数 Emitter(obj) 只在传入 obj 时执行 mixin(obj),而 mixin 函数遍历 Emitter.prototype 上的所有 key 并逐一复制到目标对象上,最后返回该对象。也就是说,mixin 本质是一次"原型方法浅拷贝",不会建立继承关系——方法被直接挂在目标对象自己的属性表上。

除了 Readme 中的三种用法,test/emitter.js 还验证了第四种模式——真正的原型链扩展:

function Custom() {
  Emitter.call(this)
}

Custom.prototype.__proto__ = Emitter.prototype;

var emitter = new Custom;
emitter.on('foo', done);
emitter.emit('foo');

这种写法通过 Emitter.call(this) 完成实例初始化、再通过重写 __proto__Custom 继承 Emitter.prototype,适合在自研类中复用发射器行为而不想污染实例属性。

API 逐项解析

on(event, fn):注册事件处理器

Emitter#on(event, fn)event 注册处理器 fn,并返回 this 以支持链式调用。

实现见 lib/cjs/index.js#L42-L48

Emitter.prototype.on =
Emitter.prototype.addEventListener = function(event, fn){
  this._callbacks = this._callbacks || {};
  (this._callbacks['$' + event] = this._callbacks['$' + event] || [])
    .push(fn);
  return this;
};

两个值得注意的细节:

  1. 懒加载的回调表_callbacks 在第一次注册时才创建,此前 emitter 实例上没有任何额外内存开销。
  2. $ 前缀:事件名在回调表中的实际 key 是 '$' + event。这样做是为了让事件名与 Object.prototype 上的保留属性(如 constructor__proto__)隔离。测试用例 test/emitter.js#L41-L57 专门验证了以 constructor__proto__ 作为事件名时监听器仍能正常触发,印证了这一设计的存在意图。

此外 on 同时挂载为 addEventListener 别名,与浏览器标准 API 命名对齐。

once(event, fn):一次性处理器

Emitter#once(event, fn) 注册"一次性"处理器:首次被调用后立即移除。

源码实现(lib/cjs/index.js#L60-L69)采用经典的"自摘除包装器":

Emitter.prototype.once = function(event, fn){
  function on() {
    this.off(event, on);
    fn.apply(this, arguments);
  }

  on.fn = fn;
  this.on(event, on);
  return this;
};

其中 on.fn = fn 这一句是关键:它在外层包装函数上保留了对原始 fn 的引用。这样做的直接收益是——off 移除时既可以传包装函数,也可以直接传原始 fn(匹配逻辑见下文 off 一节),使用者无需关心 once 内部的包装细节。测试 test/emitter.js#L95-L108 验证了 emitter.once('foo', one) 之后可以直接 emitter.off('foo', one) 完成移除;test/emitter.js#L60-L76 则验证了连续三次 emit('foo') 后回调只执行一次。

off(event, fn):三种粒度的移除

Emitter#off 支持三种调用形态:

  • eventfn:移除该事件的指定监听器;
  • 只传 event:移除该事件上的全部监听器;
  • 什么都不传:移除所有事件的全部监听器。

实现(lib/cjs/index.js#L81-L120)同时挂载了 removeListenerremoveAllListenersremoveEventListener 四个别名,按参数个数分支处理:

// all
if (0 == arguments.length) {
  this._callbacks = {};
  return this;
}

// specific event
var callbacks = this._callbacks['$' + event];
if (!callbacks) return this;

// remove all handlers
if (1 == arguments.length) {
  delete this._callbacks['$' + event];
  return this;
}

// remove specific handler
var cb;
for (var i = 0; i < callbacks.length; i++) {
  cb = callbacks[i];
  if (cb === fn || cb.fn === fn) {
    callbacks.splice(i, 1);
    break;
  }
}

这里有两处实现细节值得展开:

  • cb === fn || cb.fn === fn 的双重匹配:第二个条件正是为 once 的包装函数服务,使得用原始回调去移除 once 注册的监听器成为可能;
  • 空数组回收:函数末尾若 callbacks.length === 0,会 delete 掉对应事件的数组,源码注释明确说明目的是"avoid memory leak"。这一点由两个测试互相印证:test/emitter.js#L146-L156 断言最后一个监听器移除后 _callbacks 中不再存在 $footest/emitter.js#L158-L170 则断言只要还有订阅者,$foo 数组就必须保留。

emit(event, ...):发射事件

Emitter#emit(event, ...) 以可变参数发射事件。实现(lib/cjs/index.js#L130-L148)中有一个易被忽略的保护措施:

if (callbacks) {
  callbacks = callbacks.slice(0);
  for (var i = 0, len = callbacks.length; i < len ++i) {
    callbacks[i].apply(this, args);
  }
}

遍历前对回调数组做了 slice(0) 浅拷贝。这意味着即使某个回调在执行过程中通过 off 修改了原始数组,也不会影响当前这轮发射的遍历完整性。测试 test/emitter.js#L110-L125 覆盖了"在一个事件的回调中移除同一事件的其他监听器"的场景:第一次 emit 时两个回调都会执行完,第二次 emit 时只剩第一个——行为确定、可预期。

另外,emit 被原样别名为 emitReservedlib/cjs/index.js#L151),源码注释标明它是"用于保留事件(protected method)的别名"。这个别名在运行时无类型约束,但配合下面的 TypeScript 声明,只有继承 Emitter 的子类才能调用 emitReserved,从而在类型层面区分"用户事件"与"框架保留事件"(如 Socket.IO 内部的连接状态事件)。

listeners(event) 与 hasListeners(event)

对应测试见 test/emitter.js#L196-L229,分别验证了"有监听器返回回调数组 / 无监听器返回空数组"与"true / false"两组行为。

TypeScript 类型层:类型化事件体系

该 fork 相对原版 component-emitter 最重要的增量,是 lib/cjs/index.d.ts 中声明的泛型 Emitter 类:

export class Emitter<
    ListenEvents extends EventsMap,
    EmitEvents extends EventsMap,
    ReservedEvents extends EventsMap = {}
    > { ... }

三个类型参数分别约束"可监听的用户事件"、"可发射的用户事件"和"保留事件",由此派生出一整套类型工具:

  • EventsMap / DefaultEventsMap:事件名到监听器签名的映射;未提供事件表时回退到"接受任意事件名与任意数据"的默认映射;
  • EventNames<Map>:取事件表的所有键(keyof Map & (string | symbol));
  • EventParams<Map, Ev>:通过 Parameters<Map[Ev]> 推导某个事件监听器的参数元组;
  • ReservedOrUserListener:在保留事件与用户事件之间选择正确的监听器签名;
  • FallbackToUntypedListener:源码注释说明这是一个针对特定 TypeScript 问题的缓解手段——当事件在两个表中都查不到(类型为 never)时,回退为无类型的 (...args: any[]) => void | Promise<void> 签名。

在这个类型体系下,各方法的约束各不相同:on/once/off/listeners/hasListeners 接受"保留事件 ∪ 监听事件"(ReservedOrUserEventNames);emit 只接受 EmitEvents 表内的事件名;而 emitReserved 被声明为 protectedlib/cjs/index.d.ts#L133-L136),注释明确写道"只有继承 Emitter 的子类可以发射自己的保留事件"。这正是 Socket.IO 客户端/服务端类型化事件在编译期保证正确性的底层基础。

CJS 与 ESM 双端打包

该包同时发布 CommonJS 与 ES Module 两份构建,由 package.json 中的 main./lib/cjs/index.js)与 module./lib/esm/index.js)字段区分。目录结构上的细节:

两份运行时代码逻辑完全一致(lib/cjs/index.jslib/esm/index.js 仅导出语法不同),类型声明也以 CJS 目录下的 index.d.ts 为准(lib/esm/index.d.ts 为同内容副本)。对使用者而言,这意味着无论项目采用 ESM 还是 CJS 解析,都能拿到正确的模块形态,无需额外转译。

在 Socket.IO 生态中的角色

从源码引用关系看,@socket.io/component-emitter 是 Socket.IO 家族的底层依赖:

可以推断,Socket.IO 客户端 socket.on(...) 链上每一次事件注册与触发,最终都落在这套约 180 行的极简发射器之上;而客户端 TypeScript 的类型安全,则由前述泛型 Emitter 声明提供。这也解释了为何该包虽然体积极小,却被放在 monorepo 的 packages/socket.io-component-emitter 中随主项目一起维护与发版。

运行测试与许可

在包目录下执行 npm test(即 mocha --require should --reporter spec)即可运行 test/emitter.js 中的全部用例,覆盖:多个监听器按注册顺序触发、保留字事件名、一次性监听器、三种粒度的 offonceoff 的交互、空数组回收防泄漏、listeners / hasListeners 返回值以及 mixin 行为。组件采用 MIT 许可证发布(LICENSE),历史版本记录见 History.md

对于需要为自定义类或普通对象赋予事件能力、并希望与 Socket.IO 类型化事件体系无缝衔接的场景,@socket.io/component-emitter 提供了"零依赖运行时 + 强类型声明"的组合:运行时只关心 _callbacks 一张表和 on/once/off/emit 四个动词,类型层则由 ListenEventsEmitEventsReservedEvents 三个事件表在编译期完成约束。

登录后查看全文
热门项目推荐
相关项目推荐