在做鸿蒙(HarmonyOS / OpenHarmony)应用开发时,组件间或页面间的通信是绕不开的场景。

系统本身虽然提供了 emitterEventHub,但在实际应用开发中,我们经常会遇到一个非常典型的需求:粘性事件(Sticky Event)

一、 为什么需要“粘性事件”?

简单来说,普通的事件分发是 “先订阅,后发送”,如果你先发了事件,页面/组件过会儿才初始化并订阅,那这个事件就彻底漏掉了。

“粘性事件” 则是:“先发送,后订阅”,即发送事件时系统会把数据缓存起来;当新的组件挂载并订阅时,能立刻收到最近一次发出的数据。

除了常见的“用户登录状态广播”、“全局主题切换”外,开发中还有一个极其频繁且让人头疼的场景:多模块与多级 Tab 联动切换

📌 典型业务场景:多级 Tab 嵌套与跨模块联动

假设你的 APP 架构如下:

  • 首页(Tab A):有一个“查看热榜”按钮。
  • 发现页(Tab B):包含二级 Tab(关注 | 推荐 | 热榜),且为了性能优化,二级 Tab 采用了懒加载(只有切到对应 Tab 时,子组件才开始初始化)。

遇到的痛点:
当用户在首页点击“查看热榜”时,业务期望直接跳转到发现页 -> 切换到热榜子 Tab

  • 如果用普通 emitter.emit("SWITCH_TAB", { target: "hot_list" })
    用户从首页切到发现页时,发现页的“热榜”子组件根本还没挂载,监听器还没注册,事件直接打空!
  • 传统的痛苦解法:需要在 AppStorage/MMKV 存状态,或者在父组件里一层层传参,非常冗余且耦合严重。
  • 粘性事件的优雅解法:首页直接发送 emitSticky 粘性事件;当“发现页”及“热榜”子组件异步加载挂载后,一注册订阅,立马回放刚才的切换指令,完成精准跳转与数据刷新。

二、 核心设计思路

针对上述复杂场景,我写了一个轻量级的 EmitterManager 工具类,主要实现了以下能力:

  1. 支持 Sticky 粘性事件:内置 stickyEvents 字典,发送粘性事件时自动缓存;新订阅者以 sticky: true 注册时,立即触发回放。
  2. 支持 WeakRef 弱引用(防止内存泄漏):在 Tab 子组件中绑定事件时,可以将 this 传进来。支持 offAll(target) 在组件销毁时一键清空该组件绑定的所有事件。
  3. 支持闭包解绑on() 方法直接返回 unbind 销毁函数,方便快速取消。
  4. 类型安全:基于 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 无法应对后置消费生命周期安全解绑的短板,在开发过程中提供了很大帮助。

平时你在开发过程中还有哪些让人烦恼而又避不开的小痛点呢?欢迎留言交流。

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐