如何在 JavaScript 中实现一个事件总线 (Event Bus) / 发布-订阅模式 (Pub-Sub Pattern)?

好的,各位观众老爷们,欢迎来到今天的 "JavaScript 事件总线一日游" 讲座!今天咱们不搞虚头巴脑的理论,直接上代码,用最通俗易懂的方式把事件总线这玩意儿给安排明白。

一、啥是事件总线?为啥要用它?

想象一下,你家客厅里有一个大喇叭,谁有事儿都往里喊一声,其他人根据自己的需求听不听。这就是事件总线干的事儿。

正式点说,事件总线是一种允许不同组件在不直接互相了解的情况下进行通信的模式。它充当一个中央枢纽,组件可以 发布 事件,其他组件可以 订阅 这些事件。

为啥要用它呢?

  • 解耦: 组件之间不再需要直接依赖,改用事件驱动,降低耦合度。修改一个组件,不用担心影响到其他组件。
  • 可扩展性: 增加新的组件,只需要订阅相应的事件即可,无需修改现有代码。
  • 可维护性: 代码结构更清晰,易于理解和维护。

二、手撸一个简易版事件总线

咱们先来一个最简单的版本,让你快速上手。

// 咱们的事件总线对象
const eventBus = {
  // 存放事件和回调函数的对象
  events: {},

  // 订阅事件的方法
  subscribe: function(event, callback) {
    if (!this.events[event]) {
      this.events[event] = []; // 如果事件不存在,创建一个数组来存放回调函数
    }
    this.events[event].push(callback);
  },

  // 发布事件的方法
  publish: function(event, data) {
    if (this.events[event]) {
      this.events[event].forEach(callback => callback(data)); // 遍历所有订阅者,执行回调函数
    }
  },

  // 取消订阅事件的方法 (可选)
  unsubscribe: function(event, callback) {
    if (this.events[event]) {
      this.events[event] = this.events[event].filter(cb => cb !== callback); // 移除指定的回调函数
    }
  }
};

// 示例用法:
// 1. 订阅 "userLoggedIn" 事件
eventBus.subscribe("userLoggedIn", function(userData) {
  console.log("用户登录了!", userData);
  // 在这里处理登录后的逻辑,例如更新 UI、发送欢迎消息等等
});

// 2. 发布 "userLoggedIn" 事件
eventBus.publish("userLoggedIn", { username: "张三", userId: 123 });

// 3. 取消订阅 (可选)
function myCallback(data) {
  console.log("另一个回调函数:", data);
}

eventBus.subscribe("userLoggedIn", myCallback);
eventBus.publish("userLoggedIn", { username: "李四", userId: 456 }); // 会执行两个回调函数

eventBus.unsubscribe("userLoggedIn", myCallback);
eventBus.publish("userLoggedIn", { username: "王五", userId: 789 }); // 只会执行第一个回调函数

代码解释:

  • events:一个对象,用来存储事件和对应的回调函数。key 是事件名,value 是一个回调函数数组。
  • subscribe(event, callback):订阅事件,将回调函数添加到 events 中对应事件的数组里。
  • publish(event, data):发布事件,遍历 events 中对应事件的回调函数数组,并执行它们,传递数据 data
  • unsubscribe(event, callback):取消订阅事件,从 events 中对应事件的数组里移除指定的回调函数。(可选,但强烈建议实现)

三、进阶版:支持命名空间和once订阅

上面的简易版虽然能用,但还不够强大。咱们来加两个功能:

  1. 命名空间: 允许你把事件组织到不同的命名空间下,避免事件名冲突。
  2. once 订阅: 回调函数只执行一次,执行完自动取消订阅。
const eventBus = {
  events: {},

  subscribe: function(event, callback, namespace = null) {
    const eventKey = namespace ? `${namespace}.${event}` : event; // 构建事件键,包含命名空间
    if (!this.events[eventKey]) {
      this.events[eventKey] = [];
    }
    this.events[eventKey].push(callback);
  },

  publish: function(event, data, namespace = null) {
    const eventKey = namespace ? `${namespace}.${event}` : event; // 构建事件键,包含命名空间
    if (this.events[eventKey]) {
      this.events[eventKey].forEach(callback => callback(data));
    }
  },

  unsubscribe: function(event, callback, namespace = null) {
    const eventKey = namespace ? `${namespace}.${event}` : event; // 构建事件键,包含命名空间
    if (this.events[eventKey]) {
      this.events[eventKey] = this.events[eventKey].filter(cb => cb !== callback);
    }
  },

  // once 订阅:只执行一次的回调
  once: function(event, callback, namespace = null) {
    const self = this;
    const eventKey = namespace ? `${namespace}.${event}` : event;

    const onceCallback = function(data) {
      callback(data); // 执行原始回调函数
      self.unsubscribe(event, onceCallback, namespace); // 执行完毕后立即取消订阅
    };

    this.subscribe(event, onceCallback, namespace);
  }
};

// 示例用法:
// 1. 使用命名空间
eventBus.subscribe("dataLoaded", function(data) {
  console.log("dataLoaded event (global):", data);
}, null); // 没有命名空间

eventBus.subscribe("dataLoaded", function(data) {
  console.log("dataLoaded event (user namespace):", data);
}, "user"); // "user" 命名空间

eventBus.publish("dataLoaded", { items: ["A", "B", "C"] }, "user"); // 只会触发 "user" 命名空间下的回调
eventBus.publish("dataLoaded", { items: ["X", "Y", "Z"] });  // 触发全局的回调函数

// 2. 使用 once 订阅
eventBus.once("configLoaded", function(config) {
  console.log("Config loaded:", config); // 只会执行一次
});

eventBus.publish("configLoaded", { apiKey: "1234567890" });
eventBus.publish("configLoaded", { apiKey: "0987654321" }); // 不会再执行

代码解释:

  • namespace:可选参数,用于指定事件的命名空间。
  • eventKey:通过 namespaceevent 拼接成唯一的事件键,用于区分不同命名空间下的同名事件。
  • once(event, callback, namespace):创建一个新的回调函数 onceCallback,它会先执行原始的回调函数 callback,然后立即取消订阅自身。

四、再进阶:支持传递多个参数

有些时候,我们需要传递多个参数给回调函数。之前的版本只能传递一个 data 参数。咱们来升级一下,支持传递任意数量的参数。

const eventBus = {
  events: {},

  subscribe: function(event, callback, namespace = null) {
    const eventKey = namespace ? `${namespace}.${event}` : event;
    if (!this.events[eventKey]) {
      this.events[eventKey] = [];
    }
    this.events[eventKey].push(callback);
  },

  publish: function(event, ...args) { // 使用 ...args 收集所有参数
    const eventKey = event;
    if (this.events[eventKey]) {
      this.events[eventKey].forEach(callback => callback(...args)); // 使用 ...args 传递所有参数
    }
  },

  unsubscribe: function(event, callback, namespace = null) {
    const eventKey = namespace ? `${namespace}.${event}` : event;
    if (this.events[eventKey]) {
      this.events[eventKey] = this.events[eventKey].filter(cb => cb !== callback);
    }
  },

  once: function(event, callback, namespace = null) {
    const self = this;
    const eventKey = namespace ? `${namespace}.${event}` : event;

    const onceCallback = function(...args) { // 同样使用 ...args 接收参数
      callback(...args); // 同样使用 ...args 传递参数
      self.unsubscribe(event, onceCallback, namespace);
    };

    this.subscribe(event, onceCallback, namespace);
  }
};

// 示例用法:
eventBus.subscribe("itemAdded", function(itemName, itemPrice) {
  console.log("Item added:", itemName, "Price:", itemPrice);
});

eventBus.publish("itemAdded", "Apple", 2.5); // 传递多个参数
eventBus.publish("itemAdded", "Banana", 1.8);

代码解释:

  • publish(event, ...args):使用剩余参数语法 ...args 收集所有传递给 publish 方法的参数,除了事件名。
  • callback(...args):在执行回调函数时,使用扩展运算符 ...args 将收集到的参数传递给回调函数。
  • onceCallback = function(...args)onceCallback函数也使用剩余参数,确保参数传递的统一性。

五、高级版:异步事件处理

如果你的回调函数需要执行异步操作,例如发送网络请求,那么直接在 publish 方法中执行可能会阻塞主线程。咱们可以稍微修改一下,让事件处理变成异步的。

const eventBus = {
  events: {},

  subscribe: function(event, callback, namespace = null) {
    const eventKey = namespace ? `${namespace}.${event}` : event;
    if (!this.events[eventKey]) {
      this.events[eventKey] = [];
    }
    this.events[eventKey].push(callback);
  },

  publish: function(event, ...args) {
    const eventKey = event;
    if (this.events[eventKey]) {
      this.events[eventKey].forEach(callback => {
        // 使用 setTimeout 模拟异步执行
        setTimeout(() => {
          callback(...args);
        }, 0);
      });
    }
  },

  unsubscribe: function(event, callback, namespace = null) {
    const eventKey = namespace ? `${namespace}.${event}` : event;
    if (this.events[eventKey]) {
      this.events[eventKey] = this.events[eventKey].filter(cb => cb !== callback);
    }
  },

  once: function(event, callback, namespace = null) {
    const self = this;
    const eventKey = namespace ? `${namespace}.${event}` : event;

    const onceCallback = function(...args) {
      callback(...args);
      self.unsubscribe(event, onceCallback, namespace);
    };

    this.subscribe(event, onceCallback, namespace);
  }
};

// 示例用法:
eventBus.subscribe("dataFetched", function(data) {
  console.log("Data fetched:", data);
  // 模拟异步操作
  setTimeout(() => {
    console.log("Processing data...");
  }, 500);
});

eventBus.publish("dataFetched", { items: [1, 2, 3] });
console.log("Publishing dataFetched event..."); // 这行代码会在 "Data fetched" 之前执行

代码解释:

  • setTimeout(() => { callback(...args); }, 0):使用 setTimeout 函数,将回调函数的执行推迟到下一个事件循环。这样可以避免阻塞主线程,提高应用的响应速度。

六、一些使用技巧和注意事项

  • 事件命名: 使用有意义的事件名,例如 userLoggedIndataLoadeditemAdded 等。
  • 数据传递: 尽量传递简单的数据对象,避免传递大型复杂的数据结构。
  • 错误处理: 在回调函数中添加错误处理机制,避免因为一个回调函数出错而影响到其他回调函数的执行。
  • 内存泄漏: 记得在组件销毁时取消订阅事件,避免内存泄漏。
  • 滥用事件总线: 不要过度依赖事件总线,只有在真正需要解耦的场景下才使用。过度使用会导致代码难以追踪和维护。

七、与其他实现方式的比较

虽然咱们手撸了一个事件总线,但市面上有很多现成的库可以使用,例如:

库/方法 优点 缺点 适用场景
手撸事件总线 简单易懂,可定制性强 功能相对简单,需要自己处理内存泄漏等问题 小型项目,或者需要高度定制的场景
EventEmitter (Node.js) Node.js 内置,使用方便 只能在 Node.js 环境中使用 Node.js 环境下的事件处理
mitt 体积小巧,性能优秀,适用于各种 JavaScript 环境 功能相对简单 对体积和性能要求较高的项目
RxJS Subject 功能强大,支持各种操作符,可以处理复杂的异步事件流 学习曲线陡峭,体积较大 需要处理复杂的异步事件流的项目
Vue.js EventBus Vue.js 内置,与 Vue.js 生态系统集成良好 只能在 Vue.js 项目中使用 Vue.js 项目中的组件通信
Redux 虽然主要是状态管理工具,但也可以用来实现事件总线,具有可预测性,易于调试 相对复杂,需要引入 Redux 的概念 需要进行复杂状态管理的项目,并且希望事件流可预测

八、总结

今天咱们从最简单的版本开始,一步步地完善了一个功能强大的事件总线。希望通过今天的讲座,你能对事件总线有一个更深入的理解,并能在实际项目中灵活运用它,写出更优雅、更健壮的代码!

记住,编程的乐趣在于实践,多写代码,多思考,才能真正掌握这些技术。下次再见!

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注