📢 JavaScript中的发布-订阅模式:让代码解耦更优雅 ✨

210 阅读4分钟

前言

发布-订阅模式(Pub-Sub),又叫观察者模式(Observer Pattern),是前端开发中非常经典且实用的设计模式之一。它能帮助我们实现模块之间的松耦合通信,提升代码的可维护性和扩展性。

今天,我们就来深入浅出地聊聊这个模式,让你快速掌握它的原理与实战用法!🚀

🧠 什么是发布-订阅模式?

想象一下你订阅了一个微信公众号 📱:

  • 你(订阅者)关注了这个公众号(主题/事件)。
  • 当公众号有新文章发布时(发布事件),所有订阅它的用户都会收到推送通知。
  • 你不需要主动去查有没有更新,而是“被动接收”通知。

这就是发布-订阅模式的核心思想:

  • 发布者(Publisher):负责发出消息(事件)。
  • 订阅者(Subscriber):提前注册自己感兴趣的事件,等待通知。
  • 事件中心(Event Bus / 中介者):管理所有事件的订阅和发布,是两者之间的桥梁。

✅ 为什么使用发布-订阅模式?

  1. 解耦组件:A组件不需要知道B组件的存在,只需通过事件通信。
  2. 提高灵活性:可以动态添加或移除监听者。
  3. 适用于跨层级通信:比如父子组件、兄弟组件甚至全局状态通知。
  4. 便于测试与维护:逻辑分离,职责清晰。

💻 手写一个简单的发布-订阅中心

下面我们用 JavaScript 实现一个轻量级的 EventEmitter 类,支持:

  • on:订阅事件
  • emit:发布事件
  • off:取消订阅
  • once:只监听一次
// 📦 事件中心类
class EventEmitter {
  constructor() {
    // 存储所有事件及其回调函数
    // 结构:{ eventName: [callback1, callback2] }
    this.events = {};
  }

  /**
   * 📥 订阅事件
   * @param {string} eventName - 事件名称
   * @param {function} callback - 回调函数
   */
  on(eventName, callback) {
    if (!this.events[eventName]) {
      this.events[eventName] = [];
    }
    this.events[eventName].push(callback);
    console.log(`✅ 已订阅事件: "${eventName}"`);
  }

  /**
   * 🔔 发布事件(触发所有回调)
   * @param {string} eventName - 事件名称
   * @param  {...any} args - 传递给回调函数的参数
   */
  emit(eventName, ...args) {
    const callbacks = this.events[eventName];
    if (callbacks && callbacks.length > 0) {
      console.log(`🔔 正在发布事件: "${eventName}",共 ${callbacks.length} 个监听者`);
      callbacks.forEach(callback => {
        try {
          callback(...args);
        } catch (error) {
          console.error(`❌ 回调执行出错:`, error);
        }
      });
    } else {
      console.warn(`⚠️  没有监听者订阅事件: "${eventName}"`);
    }
  }

  /**
   * 🚫 取消订阅事件
   * @param {string} eventName - 事件名称
   * @param {function} callback - 要移除的回调函数(可选:不传则移除所有)
   */
  off(eventName, callback) {
    const callbacks = this.events[eventName];
    if (!callbacks) return;

    if (!callback) {
      // 如果没传具体回调,清空该事件所有监听
      delete this.events[eventName];
      console.log(`🗑️  已清除所有 "${eventName}" 的订阅`);
    } else {
      // 否则只移除指定回调
      this.events[eventName] = callbacks.filter(cb => cb !== callback);
      console.log(`➖ 已移除 "${eventName}" 的一个订阅`);
    }
  }

  /**
   * 🔔 只监听一次事件
   * @param {string} eventName - 事件名称
   * @param {function} callback - 回调函数
   */
  once(eventName, callback) {
    // 包装一个只执行一次的函数
    const onceWrapper = (...args) => {
      callback(...args); // 执行原回调
      this.off(eventName, onceWrapper); // 执行后自动取消订阅
    };
    this.on(eventName, onceWrapper);
    console.log(`🎯 已设置一次性订阅: "${eventName}"`);
  }
}

🧪 实战演示:模拟用户登录通知系统

我们来模拟一个简单的“用户登录”场景:

  • 用户登录成功后,发布 user:login 事件。
  • 多个模块(如:更新头像、加载用户数据、发送欢迎消息)订阅该事件并做出响应。
// 创建一个全局事件中心实例 🌐
const eventBus = new EventEmitter();

// 👤 模块1:更新用户头像
eventBus.on('user:login', (userInfo) => {
  console.log(`🖼️  更新头像: ${userInfo.avatar}`);
});

// 📊 模块2:加载用户数据面板
eventBus.on('user:login', (userInfo) => {
  console.log(`📊 加载用户数据: ID=${userInfo.id}, 名称=${userInfo.name}`);
});

// 💬 模块3:发送欢迎消息(只欢迎一次)
eventBus.once('user:login', (userInfo) => {
  console.log(`🎉 欢迎你,${userInfo.name}!这是你的首次登录问候~`);
});

// 🎁 模块4:加载个性化推荐
const loadRecommendations = (userInfo) => {
  console.log(`🎁 推荐内容已加载,基于用户兴趣: ${userInfo.interests.join(', ')}`);
};
eventBus.on('user:login', loadRecommendations);

// 🚪 模拟用户登录
console.log("\n🚀 模拟用户登录...");
eventBus.emit('user:login', {
  id: 1001,
  name: 'Alice',
  avatar: 'https://example.com/avatar.png',
  interests: ['编程', '设计', '旅行']
});

// 再次登录(验证 once 是否只执行一次)
console.log("\n🔁 再次登录...");
eventBus.emit('user:login', {
  id: 1001,
  name: 'Alice',
  avatar: 'https://example.com/avatar.png',
  interests: ['编程', '设计', '旅行']
});

// ❌ 移除某个订阅
console.log("\n🚫 移除推荐模块的监听...");
eventBus.off('user:login', loadRecommendations);

// 再次登录,看是否还触发推荐
console.log("\n🔄 再次登录(移除后)...");
eventBus.emit('user:login', {
  id: 1001,
  name: 'Alice',
  avatar: 'https://example.com/avatar.png'
});

🔍 代码解析:用户登录通知系统的运作流程

我们通过一个完整的“用户登录”案例,来深入剖析 发布-订阅模式 在实际开发中的应用逻辑。下面逐段解析代码执行过程和设计思想。

🌐 1. 创建全局事件中心(Event Bus)

const eventBus = new EventEmitter();

解析
eventBus 是一个全局的“事件调度中心”,所有模块都通过它来订阅或发布消息。
它就像一个“广播站”,谁想听消息就来注册,谁有消息就来这里喊一声。

💡 好处:各个模块之间互不依赖,完全解耦。比如“更新头像”模块不需要知道“欢迎消息”是否存在。

📥 2. 多个模块订阅 user:login 事件

👤 模块1:更新头像
eventBus.on('user:login', (userInfo) => {
  console.log(`🖼️  更新头像: ${userInfo.avatar}`);
});
📊 模块2:加载用户数据
eventBus.on('user:login', (userInfo) => {
  console.log(`📊 加载用户数据: ID=${userInfo.id}, 名称=${userInfo.name}`);
});
💬 模块3:发送欢迎消息(仅一次)
eventBus.once('user:login', (userInfo) => {
  console.log(`🎉 欢迎你,${userInfo.name}!这是你的首次登录问候~`);
});
🎁 模块4:加载推荐内容
const loadRecommendations = (userInfo) => {
  console.log(`🎁 推荐内容已加载,基于用户兴趣: ${userInfo.interests.join(', ')}`);
};
eventBus.on('user:login', loadRecommendations);

解析

  • 所有模块都对 user:login 这个事件感兴趣。
  • 使用 on() 表示“持续监听”,只要事件触发就执行。
  • 使用 once() 表示“只响应一次”,适合做初始化提示、埋点上报等场景。
  • 第4个模块将函数单独定义再传入,便于后续通过 off() 精准移除。

🧠 关键点:这些模块可以分布在项目的不同文件中,甚至由不同团队维护,只要它们都连接到同一个 eventBus,就能实现通信。

🔔 3. 模拟用户登录 —— 发布事件

console.log("\n🚀 模拟用户登录...");
eventBus.emit('user:login', {
  id: 1001,
  name: 'Alice',
  avatar: 'https://example.com/avatar.png',
  interests: ['编程', '设计', '旅行']
});

解析

  • 当用户成功登录后,系统调用 emit('user:login', userInfo) 发布事件。
  • emit 方法会查找所有订阅了 user:login 的回调函数,并依次执行。
  • 传递的 userInfo 对象作为参数传给每个监听者,实现数据共享。

🔧 内部机制回顾

this.events['user:login'].forEach(callback => callback(userInfo));

这就是“广播”的本质:遍历数组,逐一通知。

🔁 4. 再次登录 —— 验证 once 的行为

console.log("\n🔁 再次登录...");
eventBus.emit('user:login', { /* 同上 */ });

解析

  • 此时 once 绑定的欢迎消息不会再执行
  • 因为在 onceWrapper 执行完后,已自动调用 off() 将自己从事件列表中移除。
  • 其他 on 订阅的模块仍然正常响应。

✅ 这体现了 once 的“一次性”特性,非常适合用于首次加载提示新手引导等场景。

🚫 5. 移除订阅 —— 动态控制监听器

eventBus.off('user:login', loadRecommendations);

解析

  • 我们显式地移除了“加载推荐”这个监听函数。
  • 关键在于:必须传入同一个函数引用才能正确匹配并删除。
  • 如果是匿名函数(如 (userInfo) => { ... }),则无法单独移除,只能清空整个事件。

⚠️ 重要提醒:在 Vue/React 组件中,如果在 mounteduseEffect 中订阅事件,一定要在 beforeDestroy 或返回的清理函数中 off,否则可能导致内存泄漏或重复触发!

🔄 6. 最终登录测试 —— 验证移除效果

eventBus.emit('user:login', { /* ... */ });

解析

  • 此时事件中心只有 3 个监听者(欢迎消息已因 once 失效,推荐模块已被手动移除)。
  • 输出中不再出现 🎁 推荐内容...,说明 off 成功生效。

📌 一句话总结

发布-订阅模式就像“微信群发”,有人发消息(发布),群里所有人(订阅者)都能收到,而发消息的人不需要知道群里都有谁。

掌握它,你的前端架构会更加灵活、可维护、可扩展!🚀

✅ 输出结果:

✅ 已订阅事件: "user:login"
✅ 已订阅事件: "user:login"
🎯 已设置一次性订阅: "user:login"
✅ 已订阅事件: "user:login"

🚀 模拟用户登录...
🔔 正在发布事件: "user:login",共 4 个监听者
🖼️  更新头像: https://example.com/avatar.png
📊 加载用户数据: ID=1001, 名称=Alice
🎉 欢迎你,Alice!这是你的首次登录问候~
🎁 推荐内容已加载,基于用户兴趣: 编程, 设计, 旅行

🔁 再次登录...
🔔 正在发布事件: "user:login",共 4 个监听者
🖼️  更新头像: https://example.com/avatar.png
📊 加载用户数据: ID=1001, 名称=Alice
🎁 推荐内容已加载,基于用户兴趣: 编程, 设计, 旅行

🚫 移除推荐模块的监听...
➖ 已移除 "user:login" 的一个订阅

🔄 再次登录(移除后)...
🔔 正在发布事件: "user:login",共 3 个监听者
🖼️  更新头像: https://example.com/avatar.png
📊 加载用户数据: ID=1001, 名称=Alice

🔍 注意:once 的欢迎消息只出现了一次,off 后推荐模块不再触发。

🌟 实际应用场景

场景说明
🔔 组件通信Vue/React 中的 EventBus 或自定义事件总线
🔄 状态管理Redux、Vuex 内部也用到了类似机制
📡 WebSocket 消息处理收到消息后通知多个模块
⏱️ 定时任务通知定时器触发后通知监听者
🧩 插件系统插件监听核心系统的事件进行扩展

⚠️ 使用注意事项

  1. 避免内存泄漏:记得在组件销毁时 off 取消订阅,尤其是 DOM 相关的。
  2. 事件命名规范:建议使用命名空间,如 user:loginorder:created,避免冲突。
  3. 不要滥用:过度使用会导致事件流难以追踪,调试困难。建议配合状态管理工具使用。
  4. 错误处理:每个回调最好加 try-catch,防止一个错误影响其他监听者。

🧰 扩展:支持命名空间和通配符(进阶)

你可以进一步扩展 EventEmitter,支持:

  • user.*:通配符匹配所有用户相关事件
  • priority:优先级控制
  • async/await:异步事件处理

这些在大型项目中非常有用,比如使用 wildcard-eventemittermitt 等库。

🏁 总结

发布-订阅模式是 JavaScript 中实现松耦合通信的利器 🔧:

  • ✅ 解耦模块,提升可维护性
  • ✅ 易于扩展和测试
  • ✅ 适合跨层级通信

通过手写一个 EventEmitter,我们不仅理解了其内部机制,还能在实际项目中灵活运用。

💡 记住:好的架构不是一开始就设计出来的,而是在不断重构中演进的。掌握发布-订阅模式,是迈向高级前端的重要一步!

📚 推荐阅读

  • 📖 《JavaScript设计模式》—— Addy Osmani
  • 📦 npm 库:mitt(超轻量事件总线)
  • 📘 MDN 文档:CustomEvent

💬 思考题
如果多个订阅者依赖同一个事件,但执行顺序很重要,你怎么保证顺序?欢迎留言讨论!👇

解答:

为了保证多个订阅者在处理同一个事件时能够按照特定顺序执行,可以通过给每个订阅的回调函数分配一个优先级来实现。以下是简化的实现思路:

实现步骤

  1. 修改 EventEmitter:为每个事件的监听器添加一个优先级属性,默认情况下可以设置为中等优先级(例如 0)。当添加监听器时,允许指定优先级。

  2. 调整监听器存储结构:将监听器按照优先级排序存储,确保触发事件时按正确的顺序执行。

  3. 触发事件时排序执行:当发布事件时,根据监听器的优先级对它们进行排序,然后依次调用。

示例代码

这里给出一个简化版的实现,展示了如何基于优先级来管理监听器的执行顺序:

class EventEmitter {
  constructor() {
    this.events = {};
  }

  on(eventName, callback, priority = 0) { // 添加优先级参数,默认为0
    if (!this.events[eventName]) {
      this.events[eventName] = [];
    }
    // 插入时根据优先级插入到正确位置
    const newListener = {callback, priority};
    const added = false;
    for (let i = 0; i < this.events[eventName].length; i++) {
      if (this.events[eventName][i].priority > priority) {
        this.events[eventName].splice(i, 0, newListener);
        added = true;
        break;
      }
    }
    if (!added) {
      this.events[eventName].push(newListener);
    }
  }

  emit(eventName, ...args) {
    if (this.events[eventName]) {
      this.events[eventName].forEach(listener => listener.callback(...args));
    }
  }

  off(eventName, callback) {
    if (this.events[eventName]) {
      this.events[eventName] = this.events[eventName].filter(l => l.callback !== callback);
    }
  }
}

// 使用例子
const eventBus = new EventEmitter();

eventBus.on('user:login', userInfo => console.log(`低优先级任务: ${userInfo.name}`), -1);
eventBus.on('user:login', userInfo => console.log(`高优先级任务: ${userInfo.name}`), 1);

eventBus.emit('user:login', {name: 'Alice'});

关键点

  • 在注册监听器时通过 priority 参数指定其执行优先级。
  • 监听器列表根据优先级有序插入,这样在事件触发时就能保证按照预定顺序执行。
  • 默认情况下,所有监听器具有相同的优先级(如上面的例子中的 0),因此除非特别指定了优先级,否则它们会按照注册的顺序执行。

这种方法简单直接,适用于需要控制监听器执行顺序的场景。

📌 点赞 + 收藏 = 学会一半!
Happy Coding!💻✨