如何在发布订阅中心触发未来事件

panda2024-12-08 20:13:34前端发布订阅 骚操作

起因

今天在刷抖音的时候, 刷到远方os的视频,视频中讲述了如何触发一个发布订阅中心未来事件,看了一会儿后可以这么理解:当订阅方还没开始订阅的时候,发布方已经发布内容,然后需要订阅方订阅以后收到以前的历史推送.意思大概是这么个意思,远方os博主将这种行为成为未来事件.我可能没有描述清楚,我会在下边贴出原文链接:

远方os的短视频内容open in new window

相信大家已经明白了这个需求是个什么意思.然后我想起我在以前的工作中也遇到过这个问题, 但是实现方式和远方大佬的有些差异.所以我来回忆一下当时怎么做的吧.

我们先来实现一个标准的发布订阅

class EventBus {
  constructor() {
    this._map = new Map();

    // 有些人喜欢写$,有些人不喜欢写,包容一下
    this.$on = this.on;
    this.$once = this.once;
    this.$off = this.off;
    this.$emit = this.emit;
  }

  on(event, handler) {
    const eventHandlers = this._map.get(event);
    if (!eventHandlers || eventHandlers.length === 0) {
      this._map.set(event, [handler]);
    } else {
      eventHandlers.push(handler);
    }
  }

  once(event, handler) {
    const fn = (...args) => {
      handler(...args);
      this.off(event, fn);
    };
    this.on(event, fn);
  }

  emit(event, ...args) {
    const eventHandlers = this._map.get(event);
    if (!eventHandlers || eventHandlers.length === 0) return;
    eventHandlers.forEach((f) => {
      f(...args);
    });
  }

  off(event, handler) {
    const eventHandlers = this._map.get(event);
    if (!eventHandlers || eventHandlers.length === 0) return;
    this._map.set(
      event,
      eventHandlers.filter((f) => {
        return f !== handler;
      })
    );
  }
}

大致的实现思路

大致实现思路是在未来事件进行触发的时候,我们订阅一个未来代理事件,将来未来事件被订阅的时候,我们触发这个代理事件,代理事件再触发未来事件,最后将代理事件从订阅中心移除,整个过程就完成了.远方大佬维护了一个未来事件列表,在适时的时候进行事件函数的收集和调用;而我这种方式则依然是借用了发布订阅的特性订阅了一个未来事件的代理事件,这样的好处是不用再单独维护一个列表,但有一个不好的地方是,代理事件名称有一定的特殊性,需要留意用户订阅事件名的冲突行为.

class EventBus {
  FUTURE_SUFFIX = "_$wait";
  constructor() {
    this._map = new Map();

    this.$on = this.on;
    this.$once = this.once;
    this.$off = this.off;
    this.$emit = this.emit;
  }

  on(event, handler) {
    const eventHandlers = this._map.get(event);
    if (!eventHandlers || eventHandlers.length === 0) {
      this._map.set(event, [handler]);
    } else {
      eventHandlers.push(handler);
    }

    // 检查未来事件,并触发未来事件的代理事件
    if (this._map.get(event + this.FUTURE_SUFFIX)?.length) {
      this.$emit(event + this.FUTURE_SUFFIX, handler);
      this._map.delete(event + this.FUTURE_SUFFIX);
    }
  }

  once(event, handler) {
    const fn = (...args) => {
      handler(...args);
      this.off(event, fn);
    };
    this.on(event, fn);
  }

  emit(event, ...args) {
    if (this._map.get(event)?.length) {
      const eventHandlers = this._map.get(event);
      if (!eventHandlers || eventHandlers.length === 0) return;
      eventHandlers.forEach((fn) => {
        fn(...args);
      });
    }
    // 如果是未来事件,我们将注册一个新的一次性事件(这里姑且称为代理事件吧),用于当未来事件被订阅的时候触发
    else {
      this.$once(event + this.FUTURE_SUFFIX, (fn) => {
        fn(...args);
      });
    }
  }

  off(event, handler) {
    const eventHandlers = this._map.get(event);
    if (!eventHandlers || eventHandlers.length === 0) return;
    this._map.set(
      event,
      eventHandlers.filter((fn) => {
        return fn !== handler;
      })
    );
  }
}

// test
const processMsg = (msg) => {
  console.log(msg);
};
const eb = new EventBus();
// 正常事件
eb.$on("msg", processMsg);
eb.$emit("msg", "哈哈哈哈");
eb.$emit("msg", "哈哈哈哈1");
eb.$emit("msg", "哈哈哈哈2");
console.log(eb._map);
eb.$off("msg", processMsg);
console.log(eb._map);
// 未来事件
eb.$emit("msg", "哈哈哈哈历史消息1");
eb.$emit("msg", "哈哈哈哈历史消息2");
eb.$emit("msg", "哈哈哈哈历史消息3");
eb.$on("msg", processMsg);
eb.$emit("msg", "哈哈哈哈1");
eb.$emit("msg", "哈哈哈哈2");
console.log(eb._map);
eb.$off("msg", processMsg);
console.log(eb._map);

这样的方式有一个致命缺陷,就是用户订阅事件名的冲突行为

比如用户先订阅了一个名为msg_$wait的事件,然后接着又订阅了一个名为msg,那么msg_$wait的事件将被触发,并且参数错误的接收到msg的事件处理器.为了避免这种行为:

  • 我们可以在用户尝试以_$wait为后缀的事件名进行订阅的时候进行[警告提示/抛错]
  • 让用户可以自己决定一个绝对使用不到的特殊后缀

基于这两种方式,我们需要将代码调整为下边的样子:

class EventBus {
  FUTURE_SUFFIX = "_$wait";
  constructor(future_suffix) {
    this.FUTURE_SUFFIX = future_suffix || this.FUTURE_SUFFIX;

    this._map = new Map();
    this.$on = this.on;
    this.$once = this.once;
    this.$off = this.off;
    this.$emit = this.emit;
  }

  on(event, handler) {
    if (event.endsWith(this.FUTURE_SUFFIX)) {
      throw new Error(`your event name cannot end with ${this.FUTURE_SUFFIX}.`);
    }

    const eventHandlers = this._map.get(event);
    if (!eventHandlers || eventHandlers.length === 0) {
      this._map.set(event, [handler]);
    } else {
      eventHandlers.push(handler);
    }

    // 检查未来事件,并触发未来事件的代理事件
    if (this._map.get(event + this.Future_Suffix)?.length) {
      this.$emit(event + this.Future_Suffix, handler);
      this._map.delete(event + this.Future_Suffix);
    }
  }

  once(event, handler) {
    const fn = (...args) => {
      handler(...args);
      this.off(event, fn);
    };

    this.on(event, fn);
  }

  emit(event, ...args) {
    if (this._map.get(event)?.length) {
      const eventHandlers = this._map.get(event);
      if (!eventHandlers || eventHandlers.length === 0) return;
      eventHandlers.forEach((fn) => {
        fn(...args);
      });
    }
    // 如果是未来事件,我们将注册一个新的一次性事件(这里姑且称为代理事件吧),用于当未来事件被订阅的时候触发
    else {
      this.$once(event + this.Future_Suffix, (fn) => {
        fn(...args);
      });
    }
  }

  off(event, handler) {
    const eventHandlers = this._map.get(event);
    if (!eventHandlers || eventHandlers.length === 0) return;
    this._map.set(
      event,
      eventHandlers.filter((fn) => {
        return fn !== handler;
      })
    );
  }
}

总结

我们借用了发布订阅的特性实现了这个增强功能,这样做的一个好处是不用单独维护一个列表,但是代码的复杂度稍微增加了一些😄;另外,这种增强其实是违反发布订阅的规则的,你无法确定用户的功能业务代码是否依赖于原生的规则(即:用户想要的就是不触发未来事件),所以如果放到实际的运用中,最好是通过一些方式让用户显式的知道自己正处在这种规则下,以便达成共识,方式有很多,列举两种:

  • 使用构造器的参数进行区分
  • 在类上实现一个专用于触发未来事件的emitWait函数,用户如果使用这个函数来触发事件,即表示用户的确是需要触发未来事件(推荐此项)

拜拜~