如何打造一个带类型的事件系统

明白你的设计了。TypeScript 版的 pureMVC 里面,notify(eventName, eventData, eventType)
它的第三个参数,和你 Message 有子类其实本质上是一回事儿。不传就是 Message 类,传了 eventType,就去查对应的构造类。
但是你没必要去扩展 message 类,其实你扩展 data 参数就好了呀。这样所有 message 对象都能复用了。(但其实池化优化的必要性不大)

是的,主旨和PureMVC差不多,再结合TS装饰器,某些时候为了优化性能减少对象创建,Message对象可以池化。

其实事件系统是一个比较简单的组件,怎么方便怎么来。我用的就是string为key,拿普通的object结构当参数,其实基本上也够用了。

lz的事件系统实现,用了接口去规范类型,其实是RPC的设计思路。使用上稍微复杂了点。

2赞

需要定义 Game 类,里面定义个方法,方法中才去派发这个事件,引入了额外的很多个装饰器。

所有在Ixxxx里定义好的Function都会自动生成出相应的key->xxxx_Function,以及派发事件的方法。这一块是自动生成的,所以理论上应该不存在什么心智负担,如果是调用链太长了,习惯就好,作为用户流程上是不需要感知你说的事情的。

onCoinChange -> 事件名 -> 查引用,看习惯,我倒是觉得还好,哈哈。

百花齐放,mark一下

1赞

太复杂了,可以用我的,所有接口都是类型安全的

import * as cc from "cc";

/**
 * 事件对象(类型安全)
 * @noInheritDoc
 * @remarks
 * 获取事件键使用 event_target.key.xxx
 */
class mk_event_target<CT> extends cc.EventTarget {
	/** 事件键 */
	key: { [k in keyof CT]: k } = new Proxy(Object.create(null), {
		get: (target, key) => key,
	});

	/* ------------------------------- 功能 ------------------------------- */
	/**
	 * 监听事件
	 * @param type_ 事件类型
	 * @param callback_ 触发回调
	 * @param target_ 事件目标对象
	 * @param once_b_ 是否触发单次
	 * @returns 触发回调
	 */
	// @ts-ignore
	on<T extends keyof CT, T2 extends (...event_: Parameters<CT[T]>) => void>(
		type_: T | T[],
		callback_: T2,
		target_?: any,
		once_b_?: boolean
	): typeof callback_ | null {
		if (Array.isArray(type_)) {
			type_.forEach((v) => {
				super.on(v as any, callback_ as any, target_, once_b_);
			});

			return null;
		} else {
			// 录入事件对象
			target_?.event_target_as?.push(this);

			return super.on(type_ as any, callback_ as any, target_, once_b_);
		}
	}

	/**
	 * 监听单次事件
	 * @param type_ 事件类型
	 * @param callback_ 触发回调
	 * @param target_ 事件目标对象
	 * @returns 触发回调
	 */
	// @ts-ignore
	once<T extends keyof CT, T2 extends (...event_: Parameters<CT[T]>) => void>(
		type_: T | T[],
		callback_: T2,
		target_?: any
	): typeof callback_ | null {
		if (Array.isArray(type_)) {
			type_.forEach((v) => {
				super.once(v as any, callback_ as any, target_);
			});

			return null;
		} else {
			// 录入事件对象
			target_?.event_target_as?.push(this);

			return super.once(type_ as any, callback_ as any, target_);
		}
	}

	/**
	 * 取消监听事件
	 * @param type_ 事件类型
	 * @param callback_ 触发回调
	 * @param target_ 事件目标对象
	 * @returns 触发回调
	 */
	// @ts-ignore
	off<T extends keyof CT, T2 extends (...event_: Parameters<CT[T]>) => void>(type_: T | T[], callback_?: T2, target_?: any): void {
		if (Array.isArray(type_)) {
			type_.forEach((v) => {
				super.off(v as any, callback_ as any, target_);
			});
		} else {
			super.off(type_ as any, callback_ as any, target_);
		}
	}

	/**
	 * 派发事件
	 * @param type_ 事件类型
	 * @param args_ 事件参数
	 */
	// @ts-ignore
	emit<T extends keyof CT, T2 extends Parameters<CT[T]>>(type_: T | T[], ...args_: T2): void {
		if (Array.isArray(type_)) {
			type_.forEach((v) => {
				super.emit(v as any, ...args_);
			});
		} else {
			super.emit(type_ as any, ...args_);
		}
	}

	/**
	 * 是否存在事件
	 * @param type_ 事件类型
	 * @param callback_ 触发回调
	 * @param target_ 事件目标对象
	 * @returns
	 */
	// @ts-ignore
	has<T extends keyof CT, T2 extends (...event_: Parameters<CT[T]>) => void>(type_: T, callback_?: T2, target_?: any): boolean {
		return super.hasEventListener(type_ as any, callback_ as any, target_);
	}

	/** 清空所有事件 */
	declare clear: () => void;

	/**
	 * 请求事件
	 * @param type_ 事件类型
	 * @param args_ 事件参数
	 * @remarks
	 * 等待请求事件返回
	 */
	// @ts-ignore
	request<T extends keyof CT, T2 extends Parameters<CT[T]>, T3 extends ReturnType<CT[T]>>(type_: T | T[], ...args_: T2): Promise<T3>[] {
		if (Array.isArray(type_)) {
			const result_as: Promise<any>[] = [];

			type_.forEach((v) => {
				result_as.push(...this._request_single(v, ...args_));
			});

			return result_as;
		} else {
			return this._request_single(type_, ...args_);
		}
	}

	/**
	 * 请求单个事件
	 * @param type_ 事件类型
	 * @param args_ 事件参数
	 * @returns
	 */
	// @ts-ignore
	private _request_single<T extends keyof CT, T2 extends Parameters<CT[T]>, T3 extends ReturnType<CT[T]>>(type_: T, ...args_: T2): Promise<T3>[] {
		/** 返回值 */
		const result_as: Promise<any>[] = [];
		/** 回调列表 */
		const callback_as: { callback: Function; target?: any }[] = this["_callbackTable"][type_]?.callbackInfos;

		if (!callback_as) {
			return result_as;
		}

		callback_as.forEach((v) => {
			const old_callback_f = v.callback;
			const target = v.target;

			v.callback = (...args: any[]) => {
				result_as.push(old_callback_f.call(target, ...args));
				v.callback = old_callback_f;
			};
		});

		this.emit(type_, ...args_);

		return result_as;
	}
}

export default mk_event_target;

这个是驼峰版本的

3赞

事件是为了解耦,但是在你准备好定义事件的那一刻,你不就想好了这个事件属于什么模块以及应该要进行什么数据传递么?
以前的思路是写一个eventname,你希望得到别人的数据,或者你希望传递这个数据,你在对应的模块emit或者addListener,现在只是把eventname变成了方法名,数据变成了方法参数,原理是一样的

我现在用的就是学习你的写的 :laughing: 请求事件方法非常好用

1赞

我现在看到这种引入一堆文件的模块就头疼。
踩过很多这种坑。

在此分享一个我用过的WebSocket RPC调用实现,处理网络异步响应非常好用

还是太复杂了,我的MK,甚至不需要消息号[狗头]

我个人觉得你发的使用心智负担太大了.

我这个消息号是为了兼容其它非rpc网络协议

分享下目前在用的事件系统,用了几个项目也没遇到什么问题,胜在简单粗暴。

事件系统:

export class EventCenter {  
    private static eventMap: Map<string, Array<{callback: Function, target?: any}>> = new Map();  

    // 注册事件,增加target参数  
    public static registerEvent(eventName: string, callback: Function, target?: any): void {  
        if (!this.eventMap.has(eventName)) {  
            this.eventMap.set(eventName, []);  
        }  
        this.eventMap.get(eventName)?.push({ callback, target });  
    }  

    // 触发事件  
    public static triggerEvent(eventName: string, ...args: any[]): void {  
        const handlers = this.eventMap.get(eventName);  
        if (handlers) {  
            handlers.forEach(({ callback, target }) => {  
                if (target) {  
                    callback.apply(target, args);  
                } else {  
                    callback(...args);  
                }  
            });  
        }  
    }  

    // 移除事件  
    public static removeEvent(eventName: string, callback: Function, target?: any): void {  
        const handlers = this.eventMap.get(eventName);  
        if (handlers) {  
            const index = handlers.findIndex(item =>   
                item.callback === callback && item.target === target  
            );  
            if (index !== -1) {  
                handlers.splice(index, 1);  
            }  
        }  
    }  
}

注册与移除:

    protected onEnable(): void {
        //参数1:自定义事件名  参数2:要绑定的方法  参数3:绑定当前类
        EventCenter.registerEvent('UpdateHP', this.updateBarValue, this);
    }

    protected onDisable(): void {
        EventCenter.removeEvent('UpdateHP', this.updateBarValue, this);
    }

触发:

    //参数1:自定义事件名    后续参数:任意数量和类型的参数,对应注册方法
    EventCenter.triggerEvent("UpdateHP",  this.hp , isAnim);
2赞

emit<T extends keyof CT, T2 extends Parameters<CT[T]>>

这个我不太了解,其他语言一般都没有这种写法。问了一下AI。


TypeScript 语法解析

emit<T extends keyof CT, T2 extends Parameters<CT[T]>>(type_: T | T[], ...args_: T2): void

这行代码定义了一个泛型方法 emit 。我们来分解它的泛型参数:

  1. CT :
  • 这是 SafeEventTarget<CT> 类定义中的泛型参数。它代表一个“契约类型 (Contract Type)”或者说是一个事件定义的集合。
  • 在你的例子中 interface Event { test(a: number): void; } ,当创建 new SafeEventTarget<Event>() 时, CT 就被实例化为 Event 这个接口类型。
  1. T extends keyof CT :
  • 这是一个泛型参数 T
  • keyof CT : 这是一个 TypeScript 的操作符,它会获取类型 CT 的所有公共属性名(键)组成的联合类型。
    • 对于 interface Event { test(a: number): void; }keyof Event 的结果是 "test" (如果 Event 有更多属性,比如 close(): void; ,那么 keyof Event 就是 "test" | "close" )。
  • T extends keyof CT : 这叫做泛型约束。它意味着 T 必须是 CT 的一个键。换句话说, T 只能是 CT 中定义的事件名之一。
    • 在你的例子中, T 只能是 "test"
  • 这个约束确保了 emit 函数的第一个参数 type_ (当它不是数组时) 必须是一个在 CT 中真实存在的事件名。如果你尝试 emit("nonExistentEvent", ...) ,TypeScript 会在编译时报错。
  1. T2 extends Parameters<CT[T]> :
  • 这是第二个泛型参数 T2
  • CT[T] : 这叫做索引访问类型 (Indexed Access Type) 或查找类型 (Lookup Type)。它会获取类型 CT 中属性 T 的类型。
    • 如果 CTEventT"test" ,那么 CT[T] (即 Event["test"] ) 的类型就是 (a: number) => void (一个函数类型)。
  • Parameters<FuncType> : 这是一个 TypeScript 内置的工具类型 (Utility Type)。它会提取函数类型 FuncType 的参数类型,并将它们构造成一个元组 (tuple) 类型。
    • 对于 (a: number) => voidParameters<(a: number) => void> 的结果是 [number] (一个只包含一个 number 类型元素的元组)。
  • T2 extends Parameters<CT[T]> : 这又是一个泛型约束。它意味着 T2 必须是(或兼容于)由 CT[T] (即事件对应的函数类型) 的参数所组成的元组类型。
  • 这个约束确保了 emit 函数的剩余参数 ...args_ (这是一个 rest parameter,会收集所有后续参数到一个数组/元组中) 必须与 CT 中为事件 T 定义的函数的参数类型和数量相匹配。
    • 在你的例子中,因为 Parameters<Event["test"]>[number] ,所以 ...args_ 必须是一个 number 类型的参数。 emit("test", 0) 是合法的,但 emit("test", "hello")emit("test", 0, 1) 就会在编译时报错。

总结 TypeScript 的优势:

  • 类型安全 :编译器会检查事件名是否存在,以及传递的参数是否与事件定义的参数类型匹配。
  • 智能提示 (IntelliSense) :IDE 可以根据 CT 的定义,在你输入 event.emit( 时提示合法的事件名,并在选中事件名后提示所需的参数类型。
  • 重构友好 :如果修改了 Event 接口中某个事件的签名(例如参数类型或数量),TypeScript 会在所有不匹配的 emit 调用处报错。

TypeScript 的类型系统在这方面确实展现了其独特之处和强大能力。让我们再深入一点理解为什么:

  1. 结构化类型系统 (Structural Typing) : TypeScript 的类型系统主要是结构化的(“duck typing”)。如果一个对象具有某个接口要求的所有属性和方法(且类型兼容),它就被认为是实现了该接口,无需显式声明 implements 。这使得 keyof 和索引访问类型 CT[T] 的概念更加自然和强大。它关心的是“形状”而不是“名称”或“声明”。
  2. 字面量类型 (Literal Types) : TypeScript 支持字符串字面量类型 (e.g., type EventName = "click" | "hover" ). keyof CT 正是产生了一个由字符串字面量组成的联合类型。这是能够将“键的名称”提升到类型层面进行操作的关键。
  3. 映射类型 (Mapped Types) 和条件类型 (Conditional Types) : 虽然这个例子中没有直接大量使用映射类型( key 属性的类型 {[k in keyof CT]: k} 是一个简单的映射类型),但它们是 TypeScript 类型系统能进行复杂类型转换和推断的基石。 Parameters<T>ReturnType<T> 这样的工具类型内部就是用条件类型和 infer 关键字等高级特性实现的。

为什么其他静态类型语言的泛型通常做不到这一点?

  • 名义类型系统 (Nominal Typing) : 大多数主流静态类型语言(如 C++, C#, Java)主要采用名义类型系统。类型是通过其声明的名称来区分的。这意味着即使两个类有完全相同的结构,如果它们名称不同,它们就是不同的类型。这使得“获取任意类型的键并将其用作类型”这种操作不那么直接。
  • 泛型主要关注“类型参数化”,而非“结构内省” :
    • 在 C# 或 Java 中,泛型 List<T> 意味着这个列表可以存储任何类型 T 的对象。 T 是一个占位符,代表一个完整的类型。
    • 它们通常没有内置的、在泛型约束中直接使用的机制来:
      1. 获取一个泛型参数 CT 的所有成员名称,并将其变成一个可在编译时使用的“键的类型集合”(类似 TypeScript 的 keyof CT 产生的字符串字面量联合类型)。
      2. 使用这个“键的类型”(如果是字符串)来静态地、类型安全地索引 CT 以获取成员的类型(类似 CT[T] )。
  • 反射 vs. 静态类型操作 :
    • C# 和 Java 等语言可以通过 反射 (Reflection) 在运行时获取类的成员信息(名称、类型等)。但这是运行时的行为,通常会丢失编译时的类型安全,或者需要编写大量防御性代码。
    • TypeScript 的 keyofCT[T]编译时 的静态类型操作,编译器在编译阶段就能进行检查和推断。
  • C++ 模板元编程 : C++ 的模板元编程非常强大,理论上可以在编译时做很多事情。但要实现与 TypeScript 完全等价的、基于字符串字面量(或类似物)的 keyof 和索引访问会非常复杂,通常需要复杂的模板技巧、宏,或者依赖于未来的反射特性(如 C++23 或之后提案的静态反射)。它通常不以这种“基于名称字符串的类型查找”为主要设计范式。C++ 更倾向于使用类型本身、枚举或特化来区分和操作。

总结来说:

TypeScript 的设计目标之一就是为 JavaScript 提供强大的静态类型检查,同时尽可能地贴合 JavaScript 的动态特性。因此,它的类型系统发展出了很多独特的功能,允许在类型层面进行非常灵活和精细的操作,比如根据一个类型的“形状”来推断和约束其他类型。

其他静态语言的泛型系统虽然也非常强大,但它们的设计哲学和演进路径不同,通常不直接提供这种“以成员名称(作为类型)来驱动泛型类型推断”的机制。它们可以通过其他方式(如接口、继承、特定的设计模式、或 C# 的 Source Generators,C++ 的模板特化等)来实现类型安全的事件系统,但实现 emit<T extends keyof CT, T2 extends Parameters<CT[T]>> 这种签名所依赖的 编译时静态推断链条 会困难得多,或者说不是其泛型系统的原生设计目标。


看起来甚至连C++的模版元编程都做不到这一点。

基于这个机制,就可以大量简化类型安全所需要的设施了。 :+1: :+1:

MK框架也有消息号,但是在用户层可以隐藏,重要的是实现这两种方式我都没改框架层后续还可以扩展更多

并且支持两种消息号的方式
第一种

message Package {
    /** 消息号 */
    int32 id = 1;
    /** 消息体 */
    bytes data = 2;
}

第二种

message test {
    int32 id [default = 100];
}

其实主要是下面红框部分,而且我的rpc调用是自定义序列化和反序列化,不是protobuf

底层看你自己选择了,萝卜青菜各有所爱,我是基于 proto 开发的

实现只是顺序字节写入,使用还是很方便的,我写好注释就非常容易理解了。

好好好,cv一下