鸿蒙 emitter/EventHub 没有 Sticky 粘性事件?手写一个轻量级 EmitterManager 解决
·
在做鸿蒙(HarmonyOS / OpenHarmony)应用开发时,组件间或页面间的通信是绕不开的场景。
系统本身虽然提供了 emitter 和 EventHub,但在实际应用开发中,我们经常会遇到一个非常典型的需求:粘性事件(Sticky Event)。
一、 为什么需要“粘性事件”?
简单来说,普通的事件分发是 “先订阅,后发送”,如果你先发了事件,页面/组件过会儿才初始化并订阅,那这个事件就彻底漏掉了。
而 “粘性事件” 则是:“先发送,后订阅”,即发送事件时系统会把数据缓存起来;当新的组件挂载并订阅时,能立刻收到最近一次发出的数据。
除了常见的“用户登录状态广播”、“全局主题切换”外,开发中还有一个极其频繁且让人头疼的场景:多模块与多级 Tab 联动切换。
📌 典型业务场景:多级 Tab 嵌套与跨模块联动
假设你的 APP 架构如下:
- 首页(Tab A):有一个“查看热榜”按钮。
- 发现页(Tab B):包含二级 Tab(
关注|推荐|热榜),且为了性能优化,二级 Tab 采用了懒加载(只有切到对应 Tab 时,子组件才开始初始化)。
遇到的痛点:
当用户在首页点击“查看热榜”时,业务期望直接跳转到发现页 -> 切换到热榜子 Tab。
- 如果用普通
emitter.emit("SWITCH_TAB", { target: "hot_list" }):
用户从首页切到发现页时,发现页的“热榜”子组件根本还没挂载,监听器还没注册,事件直接打空! - 传统的痛苦解法:需要在 AppStorage/MMKV 存状态,或者在父组件里一层层传参,非常冗余且耦合严重。
- 粘性事件的优雅解法:首页直接发送
emitSticky粘性事件;当“发现页”及“热榜”子组件异步加载挂载后,一注册订阅,立马回放刚才的切换指令,完成精准跳转与数据刷新。
二、 核心设计思路
针对上述复杂场景,我写了一个轻量级的 EmitterManager 工具类,主要实现了以下能力:
- 支持 Sticky 粘性事件:内置
stickyEvents字典,发送粘性事件时自动缓存;新订阅者以sticky: true注册时,立即触发回放。 - 支持 WeakRef 弱引用(防止内存泄漏):在 Tab 子组件中绑定事件时,可以将
this传进来。支持offAll(target)在组件销毁时一键清空该组件绑定的所有事件。 - 支持闭包解绑:
on()方法直接返回unbind销毁函数,方便快速取消。 - 类型安全:基于 TypeScript 泛型设计,兼容鸿蒙 ArkTS 的
ESObject特性。
三、 完整实现代码
代码纯原生无三方依赖,复制到项目的 utils 目录即可使用:
type Callback<T> = (event: T) => void;
interface ListenerItem {
target?: WeakRef<object>;
callback: Callback<ESObject>;
}
export class EmitterManager {
private static instance: EmitterManager;
static getInstance(): EmitterManager {
if (!EmitterManager.instance) {
EmitterManager.instance = new EmitterManager();
}
return EmitterManager.instance;
}
private listeners: Map<string, Set<ListenerItem>> = new Map();
private stickyEvents: Map<string, ESObject> = new Map();
private constructor() {}
// 重载声明
on<T = ESObject>(event: string, target: object, callback: Callback<T>, sticky?: boolean): () => void;
on<T = ESObject>(event: string, callback: Callback<T>, sticky?: boolean): () => void;
on<T = ESObject>(event: string, arg2: object | Callback<T>, arg3?: Callback<T> | boolean, arg4?: boolean): () => void {
let target: object | undefined = undefined;
let callback: Callback<T>;
let sticky: boolean = false;
if (typeof arg2 === 'object' && typeof arg3 === 'function') {
target = arg2;
callback = arg3;
sticky = arg4 ?? false;
} else {
callback = arg2 as Callback<T>;
sticky = arg3 as boolean ?? false;
}
if (!this.listeners.has(event)) {
this.listeners.set(event, new Set());
}
const listenerItem: ListenerItem = { target: target ? new WeakRef(target) : undefined, callback };
this.listeners.get(event)!.add(listenerItem);
// 如果是 Sticky 监听,且当前存在缓存的粘性事件,立即触发回调回放
if (sticky && this.stickyEvents.has(event)) {
callback(this.stickyEvents.get(event) as T);
}
// 返回取消订阅的闭包函数
return (): void => this.off(event, callback);
}
// 精确移除回调
off<T = ESObject>(event: string, callback: Callback<T>) {
const set = this.listeners.get(event);
if (set) {
for (const item of set) {
if (item.callback === callback) {
set.delete(item);
break;
}
}
if (set.size === 0) {
this.listeners.delete(event);
}
}
}
// 根据组件/页面对象批量解绑(配合 WeakRef 完美防泄漏)
offAll(target: object) {
this.listeners.forEach((set, event) => {
for (const item of set) {
if (item.target?.deref() === target) {
set.delete(item);
}
}
if (set.size === 0) {
this.listeners.delete(event);
}
});
}
// 发送粘性事件
emitSticky<T = ESObject>(event: string, data: T) {
this.stickyEvents.set(event, data);
this.emit(event, data);
}
// 普通发送
emit<T = ESObject>(event: string, data: T) {
const set = this.listeners.get(event);
if (set) {
Array.from(set).forEach(item => item.callback(data));
}
}
getSticky<T = ESObject>(eventName: string): T | undefined {
return this.stickyEvents.get(eventName);
}
removeSticky(eventName: string) {
this.stickyEvents.delete(eventName);
}
clearSticky() {
this.stickyEvents.clear();
}
reset() {
this.listeners.clear();
this.stickyEvents.clear();
}
}
四、 实战场景演练
场景一:多级 Tab 跨模块精准跳转(解决懒加载打空问题)
// 1. 首页按钮点击:跨模块通知要跳转到“发现页”的“热榜”Tab,并携带筛选参数
function onHomeHotBtnClick() {
// 先切换底栏一级 Tab 到发现页
mainTabController.changeIndex(1);
// 发送粘性事件(此时发现页里的热榜子组件可能还没挂载!)
EmitterManager.getInstance().emitSticky("NAVIGATE_DISCOVER_TAB", {
subTab: "HOT_RANK",
filterId: 1002
});
}
// 2. 发现页 -> 嵌套的热榜子组件 (HotRankComponent)
@Component
struct HotRankComponent {
@State filterId: number = 0;
aboutToAppear() {
// 监听粘性事件:就算我是后挂载的,也能立刻收到首页发来的跳转指令
EmitterManager.getInstance().on("NAVIGATE_DISCOVER_TAB", this, (data: ESObject) => {
if (data.subTab === "HOT_RANK") {
this.filterId = data.filterId;
this.loadData();
// 消费完后,如果是一次性跳转,可以主动清除粘性缓存
EmitterManager.getInstance().removeSticky("NAVIGATE_DISCOVER_TAB");
}
}, true); // 第四个参数传 true 开启 Sticky
}
aboutToDisappear() {
// 退出时一键解绑,防止内存泄漏
EmitterManager.getInstance().offAll(this);
}
loadData() { /* ... */ }
}
场景二:常规防漏广播(如登录成功数据补发)
// 登录成功后发广播
EmitterManager.getInstance().emitSticky("USER_LOGIN_SUCCESS", { userId: "9527", token: "xxx" });
// 延迟初始化的个人中心页面挂载后:
EmitterManager.getInstance().on("USER_LOGIN_SUCCESS", (userInfo) => {
console.log("自动收到登录成功缓存:", userInfo.userId);
}, true);
五、 总结
在鸿蒙复杂业务开发中需要粘性事件的场景层出不穷,这个 EmitterManager 工具类代码量不大,但很好地弥补了官方 emitter 无法应对后置消费和生命周期安全解绑的短板,在开发过程中提供了很大帮助。
平时你在开发过程中还有哪些让人烦恼而又避不开的小痛点呢?欢迎留言交流。
更多推荐


所有评论(0)