使用TypeScript实现一个类型安全的EventBus示例详解
本文通过TypeScript实现一个具备类型安全特性的EventBus,涵盖注册、发布与取消订阅核心功能。利用泛型约束确保事件名与参数类型匹配,结合函数重载优化IDE提示,提供完整代码示例与测试用例,帮助开发者构建类型安全的消息通信机制。
前言
伴随vue3的推出,TypeScript在国内热度日增,掌握TypeScript已成必然趋势。本文旨在通过构建一个类型安全的EventBus来实践TypeScript,期望能为读者提供借鉴。
准备工作
构建TypeScript项目的初始结构:
// 创建目录
mkdir ts-event-bus && cd ts-event-bus
// 初始化工程
yarn init -y
// 安装typescript
yarn add typescript -D
// 生成typescript配置文件
npx tsc --init
至此,TypeScript的基础框架已搭建完毕。为便于后续测试,需安装ts-node,该工具允许我们在不执行TypeScript编译步骤的情况下直接运行TypeScript脚本。
yarn add ts-node -D
目标
- 实现核心功能,涵盖注册、发布及取消订阅三项基础操作。
- 确保类型安全,对输入参数进行严格约束,并提供代码智能提示。
思路
每个Event可关联多个处理函数。我们利用Set存储这些函数,并通过Map维护Event与对应Set之间的映射关系,具体结构如图所示:

具体实现
// 定义泛型函数类型
type Handler<T = any> = (val: T) => void;
class EventBus<Events extends Record<string, any>> {
/** 保存 key => set 映射 */
private map: Map<string, Set<Handler>> = new Map();
on<EventName extends keyof Events>(
name: EventName,
handler: Handler<Events[EventName]>
) {
let set: Set<Handler<Events[EventName]>> | undefined = this.map.get(
name as string
);
if (!set) {
set = new Set();
this.map.set(name as string, set);
}
set.add(handler);
}
}此处将从逻辑实现与类型定义两个维度展开阐述。
在逻辑层面,我们初始化了一个空的Map。当调用on进行事件注册时,系统首先依据EventName查询是否存在对应的Set;若不存在则新建,并将事件处理函数加入Set。此部分逻辑简洁,易于实现。
在类型层面,我们将EventBus定义为泛型类,并限制泛型参数为Events extends Record<string, any>。此举确保了传入的泛型参数必须为对象类型,例如:
type Events = {
foo : number;
bar : string;
}借助此类型定义,我们可以推导key所对应的value类型。
// number;
type ValueTypeOfFoo = Events['foo']进而,能够获取foo事件所绑定的handler函数类型,即:
// (val:number) => void;
type HandlerOfFoo = Handler<Events['foo']>我们将on方法定义为泛型函数,并对EventName extends keyof Events施加约束。由此,Events[EventName]对应具体的值类型,而Handler<Events[EventName]>对应处理函数类型。通过上述机制,我们成功构建了一个类型安全的on方法。
随后,我们编写测试代码进行验证。
结果显示,在VSCode中编写代码时,编辑器能够自动提供代码提示。

当我们输入handler函数时,编辑器会提示val参数属于string类型。

若传入的参数不符合规范,TypeScript编译器将发出警告。


接下来,我们参照类似逻辑实现emit函数。
class EventBus<Events extends Record<string, any>> {
... others code
/** 触发事件 */
emit<EventName extends keyof Events>(
name: EventName,
value: Events[EventName]
) {
const set: Set<Handler<Events[EventName]>> | undefined = this.map.get(
name as string
);
if (!set) return;
const copied = [...set];
copied.forEach((fn) => fn(value));
}
}首先定位EventName对应的Set,若存在则取出并依次执行。该逻辑同样简单,我们通过代码测试验证其正确性:
const bus = new EventBus<{
foo: string;
bar: number;
}>();
bus.on("foo", (val) => {
console.log(val);
});
// 输出 hello
bus.emit("foo", "hello");在终端执行npx ts-node ./index.ts后,输出“hello”,表明程序运行正常。
随后,我们实现取消订阅功能。
{
...
off<EventName extends keyof Events>(
name?: EventName,
handler?: Handler<Events[EventName]>
): void {
// 什么都不传,则清除所有事件
if (!name) {
this.map.clear();
return;
}
// 只传名字,则清除同名事件
if (!handler) {
this.map.delete(name as string);
return;
}
// name 和 handler 都传了,则清除指定handler
const handlers: Set<Handler<Events[EventName]>> | undefined = this.map.get(
name as string
);
if (!handlers) {
return;
}
handlers.delete(handler);
}
}取消订阅功能设计为接受0至2个参数:不传参表示清除所有事件;仅传一个参数表示清除同名下的所有事件;传两个参数表示仅移除该事件指定的特定处理函数。因此,这两个参数均为可选参数,实现逻辑较为直观,此处不再赘述。
我们编写测试代码以观察实际效果。
const bus = new EventBus<{
foo: string;
bar: number;
}>();
// 测试传2个参数的情况
const handlerFoo1 = (val: string) => {
console.log("2个参数 handlerFoo1 => ", val);
};
bus.on("foo", handlerFoo1);
bus.emit("foo", "hello");
// 打印 2个参数 handlerFoo1 => hello
bus.off("foo", handlerFoo1);
bus.emit("foo", "hello");
// 什么都没打印
// 测试传1个参数的情况
const handlerFoo2 = (val: string) => {
console.log("1个参数 handlerFoo2 => ", val);
};
const handlerFoo3 = (val: string) => {
console.log("1个参数 handlerFoo3 => ", val);
};
bus.on("foo", handlerFoo2);
bus.on("foo", handlerFoo3);
bus.emit("foo", "hello");
// 打印 1个参数 handlerFoo2 => hello
// 打印 1个参数 handlerFoo3 => hello
bus.off("foo");
bus.emit("foo", "hello");
// 什么都没输出
// 测试传0个参数的情况
const handlerFoo4 = (val: string) => {
console.log("0个参数 handlerFoo4 => ", val);
};
const handlerBar1 = (val: number) => {
console.log("0个参数 handlerBar1 => ", val);
};
bus.on("foo", handlerFoo4);
bus.on("bar", handlerBar1);
bus.emit("foo", "hello");
bus.emit("bar", 123);
// 打印 1个参数 handlerFoo4 => hello
// 打印 1个参数 handlerBar1 => 123
bus.off();
bus.emit("foo", "hello");
bus.emit("bar", 123);
// 什么都没输出
测试结果证实,我们的off方法功能正常,至此完成了EventBus的全部开发。
此外,我们可以为方法添加注释,这样在鼠标悬停于API上方或输入参数时,编辑器会显示相应的提示信息。
/**
* 订阅事件
* @param name 事件名
* @param handler 事件处理函数
*/
on<EventName extends keyof Events>(
name: EventName,
handler: Handler<Events[EventName]>
) {
let set: Set<Handler<Events[EventName]>> | undefined = this.map.get(
name as string
);
if (!set) {
set = new Set();
this.map.set(name as string, set);
}
set.add(handler);
}

可见,编辑器提供了极佳的提示体验,极大地提升了编码效率。
我们还可以利用函数重载技术优化off方法,以提供更友好的交互提示。
{
/**
* 清除所有事件
*/
off(): void;
/**
* 清除同名事件
* @param name 事件名
*/
off<EventName extends keyof Events>(name: EventName): void;
/**
* 清除指定事件
* @param name 事件名
* @param handler 事件处理函数
*/
off<EventName extends keyof Events>(
name: EventName,
handler: Handler<Events[EventName]>
): void;
off<EventName extends keyof Events>(
name?: EventName,
handler?: Handler<Events[EventName]>
): void {
// 什么都不传,则清除所有事件
if (!name) {
this.map.clear();
return;
}
// 只传名字,则清除同名事件
if (!handler) {
this.map.delete(name as string);
return;
}
// name 和 handler 都传了,则清除指定handler
const handlers: Set<Handler<Events[EventName]>> | undefined = this.map.get(
name as string
);
if (!handlers) {
return;
}
handlers.delete(handler);
}
}
改造前的提示效果:

改造后的提示效果:

至此,一个功能完备且类型安全的EventBus便开发完成。
全部代码
type Handler<T = any> = (val: T) => void;
class EventBus<Events extends Record<string, any>> {
private map: Map<string, Set<Handler>> = new Map();
/**
* 订阅事件
* @param name 事件名
* @param handler 事件处理函数
*/
on<EventName extends keyof Events>(
name: EventName,
handler: Handler<Events[EventName]>
) {
let set: Set<Handler<Events[EventName]>> | undefined = this.map.get(
name as string
);
if (!set) {
set = new Set();
this.map.set(name as string, set);
}
set.add(handler);
}
/**
* 触发事件
* @param name 事件名
* @param handler 事件处理函数
*/
emit<EventName extends keyof Events>(
name: EventName,
value: Events[EventName]
) {
const set: Set<Handler<Events[EventName]>> | undefined = this.map.get(
name as string
);
if (!set) return;
const copied = [...set];
copied.forEach((fn) => fn(value));
}
/**
* 清除所有事件
*/
off(): void;
/**
* 清除同名事件
* @param name 事件名
*/
off<EventName extends keyof Events>(name: EventName): void;
/**
* 清除指定事件
* @param name 事件名
* @param handler 处理函数
*/
off<EventName extends keyof Events>(
name: EventName,
handler: Handler<Events[EventName]>
): void;
off<EventName extends keyof Events>(
name?: EventName,
handler?: Handler<Events[EventName]>
): void {
// 什么都不传,则清除所有事件
if (!name) {
this.map.clear();
return;
}
// 只传名字,则清除同名事件
if (!handler) {
this.map.delete(name as string);
return;
}
// name 和 handler 都传了,则清除指定handler
const handlers: Set<Handler<Events[EventName]>> | undefined = this.map.get(
name as string
);
if (!handlers) {
return;
}
handlers.delete(handler);
}
}
const bus = new EventBus<{
foo: string;
bar: number;
}>();
// 测试传2个参数的情况
const handlerFoo1 = (val: string) => {
console.log("2个参数 handlerFoo1 => ", val);
};
bus.on("foo", handlerFoo1);
bus.emit("foo", "hello");
// 打印 2个参数 handlerFoo1 => hello
bus.off("foo", handlerFoo1);
bus.emit("foo", "hello");
// 什么都没打印
// 测试传1个参数的情况
const handlerFoo2 = (val: string) => {
console.log("1个参数 handlerFoo2 => ", val);
};
const handlerFoo3 = (val: string) => {
console.log("1个参数 handlerFoo3 => ", val);
};
bus.on("foo", handlerFoo2);
bus.on("foo", handlerFoo3);
bus.emit("foo", "hello");
// 打印 1个参数 handlerFoo2 => hello
// 打印 1个参数 handlerFoo3 => hello
bus.off("foo");
bus.emit("foo", "hello");
// 什么都没输出
// 测试传0个参数的情况
const handlerFoo4 = (val: string) => {
console.log("0个参数 handlerFoo4 => ", val);
};
const handlerBar1 = (val: number) => {
console.log("0个参数 handlerBar1 => ", val);
};
bus.on("foo", handlerFoo4);
bus.on("bar", handlerBar1);
bus.emit("foo", "hello");
bus.emit("bar", 123);
// 打印 1个参数 handlerFoo4 => hello
// 打印 1个参数 handlerBar1 => 123
bus.off();
bus.emit("foo", "hello");
bus.emit("bar", 123);
// 什么都没输出
后记
EventBus是日常开发中的常用工具。本文利用Typescript实现了一个具备基础功能且类型安全的EventBus,这是笔者近期学习Typescript知识的总结,希望能对各位有所助益。
本文相关代码已同步至GitHub,感兴趣的同学可以clone源码进行学习。若觉得有用,欢迎点赞支持!
关于使用TypeScript实现类型安全EventBus的文章介绍至此,更多TypeScript实现类型安全EventBus相关内容,请查阅本站过往文章或继续浏览后续文章。希望大家今后能多多支持本站!
UmiJS 项目构建速度优化与打包体积精简技巧
2026-11-04 22:37:09
原生JS操作DOM与Vue/React虚拟DOM的本质区别
2026-11-04 17:52:27
怎么使用console.group和table优化JS日志可读性
2026-11-03 23:14:34
基于AST抽象语法树的JS代码反混淆实战教程
2026-11-03 21:22:24
JS手机号输入校验的性能优化与用户体验提升
2026-11-03 19:19:19








