导读:本期聚焦于赵六创作的《JavaScript中如何实现一个发布订阅模式?它与观察者模式究竟有何不同?》,敬请观看详情。实现一个可用的发布订阅模式,难点并不在于把回调存进数组,而在于如何设计取消订阅、一次性监听、异常隔离和内存回收。本文从零构建一个事件中心,逐步加入命名空间、通配符和优先级控制,再通过对比观察者模式说明两者的边界。观察者模式中目标对象直接持有观察者引用,依赖关系更明显;发布订阅模式则引入中间调度层,发布者和订阅者完全不知道对方存在。文章还会解析Node.js EventEmitter的设计取舍,以及为什么在组件销毁时必须手动移除事件监听,否则容易造成闭包引用和内存泄漏。代码示例覆盖基础实现、观察者对比实现和增强版事件总线。

发布订阅模式的核心价值在于把事件的产生方和事件的消费方彻底拆开。比如用户登录模块只需要向外发出 login 事件,而购物车、通知中心、统计埋点各自订阅这个事件,它们完全不需要知道是谁触发了登录,也不用共享同一个对象引用。要实现这一点,关键是维护一个中央调度列表,并在事件触发时按照注册顺序调用回调。这样一个调度列表通常被称为事件中心。

JavaScript中如何实现一个发布订阅模式?它与观察者模式究竟有何不同?

从零实现一个基础事件总线

先定义最基础的接口。一个事件总线至少要提供三个方法:on 负责注册监听函数,emit 负责触发事件,off 负责移除监听。为了避免同名事件互相覆盖,通常会用一个对象保存所有事件类型,每个类型对应一个回调数组。下面是一个完整的基础实现,其中也包含了 once 方法。

class EventBus {
  constructor() {
    this.events = Object.create(null);
  }
  on(type, handler) {
    if (typeof handler !== 'function') {
      return this;
    }
    const list = this.events[type] || (this.events[type] = []);
    list.push(handler);
    return this;
  }
  emit(type, payload) {
    const list = this.events[type];
    if (!list || list.length === 0) {
      return false;
    }
    // 使用副本避免在回调中修改原数组导致遍历错乱
    const queue = list.slice();
    for (let i = 0; i < queue.length; i++) {
      try {
        queue[i](payload);
      } catch (error) {
        console.error('事件处理函数执行出错', error);
      }
    }
    return true;
  }
  off(type, handler) {
    const list = this.events[type];
    if (!list) {
      return this;
    }
    if (!handler) {
      delete this.events[type];
      return this;
    }
    this.events[type] = list.filter(function(item) {
      return item !== handler;
    });
    return this;
  }
  once(type, handler) {
    const self = this;
    function onceWrapper(payload) {
      handler(payload);
      self.off(type, onceWrapper);
    }
    onceWrapper.origin = handler;
    this.on(type, onceWrapper);
    return this;
  }
}

这个基础实现有几处细节值得注意。第一,emit 使用 list.slice() 复制了一份数组,这样即使某个回调在内部调用了 off,也不会影响当前正在进行的遍历。第二,每个回调都用 try...catch 包裹,单个回调抛出异常不会阻塞后续回调,也不会让整个分发流程中断。第三,once 包装函数在执行后自动解除绑定,同时通过 origin 属性保留原始函数引用,使外部仍然可以按原函数取消一次性监听。

基础事件总线已经具备生产环境的最小可用能力。接下来分析另一个容易混淆的问题:它和观察者模式到底是不是一回事。

发布订阅与观察者模式的核心差异

观察者模式通常由两个角色组成:目标对象 Subject 和观察者 Observer。目标对象内部维护一个观察者列表,观察者需要事先注册到目标对象上。当目标状态改变时,它会主动调用所有观察者的 update 方法。发布订阅模式则增加了一个中间调度层,发布者只和事件中心通信,订阅者也只和事件中心通信,发布者和订阅者之间没有直接引用。

用一段观察者模式的代码来对比会更直观。

class Subject {
  constructor() {
    this.observers = [];
  }
  addObserver(observer) {
    if (!this.observers.includes(observer)) {
      this.observers.push(observer);
    }
  }
  removeObserver(observer) {
    this.observers = this.observers.filter(function(item) {
      return item !== observer;
    });
  }
  notify(data) {
    for (let i = 0; i < this.observers.length; i++) {
      this.observers[i].update(data);
    }
  }
}

class Observer {
  constructor(name) {
    this.name = name;
  }
  update(data) {
    console.log(this.name + ' received ' + data);
  }
}

const subject = new Subject();
const observerA = new Observer('A');
const observerB = new Observer('B');
subject.addObserver(observerA);
subject.addObserver(observerB);
subject.notify('hello');

从代码可以看出,Observer 必须实现约定的 update 方法,Subject 必须知道观察者对象本身。如果一个观察者还想同时监听多个目标,就需要实现多个不同的回调接口,扩展起来比较麻烦。发布订阅模式把这种强约定转变为字符串或符号事件名,任何函数只要满足参数签名就可以订阅,订阅者不需要暴露完整的对象。

用一个简单的表格梳理两者的差别。

对比维度观察者模式发布订阅模式
耦合关系目标直接持有观察者引用发布者和订阅者互不感知
通信方式调用固定接口通过事件名或消息队列
扩展性观察者需实现统一接口任意函数均可订阅
适用场景状态变化驱动、一对多行为联动跨模块解耦、多人协作的大型事件流

许多人把观察者模式和发布订阅模式混为一谈,主要原因是在早期的 JavaScript 实现中,事件绑定和观察者模式确实边界模糊。例如 DOM 事件模型中,按钮对象本身既是目标又是事件来源,监听器通过 addEventListener 注册,而触发时又由浏览器统一调度,这其实更接近发布订阅。Vue 2 的实例事件总线 vm.$on 和 vm.$emit 就是典型的发布订阅接口,而 Vue 的响应式系统则更接近观察者模式,因为依赖收集时会将当前的渲染函数作为订阅者精确地添加到对应属性的依赖列表中。

增强实现:命名空间、异常隔离与内存管理

在实际项目中,事件总线经常需要支持命名空间。例如一个页面同时有商品模块和订单模块,它们都可能用到 update 事件,如果直接用字符串事件名很容易冲突。可以在事件名中加入命名空间分隔符,或者把事件类型设计成路径式结构,如 cart:update 和 order:update。更高级的总线还会支持通配符订阅,例如订阅 cart:* 来监听购物车模块下的所有事件。

另一个必须面对的问题是异常隔离。基础实现虽然在 emit 中加入了 try...catch,但项目里如果把日志上报、性能埋点等异步任务也直接放入回调,就需要更细粒度的控制。增强版实现可以为每个回调增加优先级和一次性标记,并在触发前处理 once 标记,避免在回调执行后再删除时因为回调内部再次触发同一事件而产生偏差。

class EnhancedEventBus {
  constructor() {
    this.events = Object.create(null);
  }
  on(type, handler, options) {
    options = options || {};
    const wrapper = {
      handler: handler,
      priority: options.priority || 0,
      once: Boolean(options.once)
    };
    const list = this.events[type] || (this.events[type] = []);
    list.push(wrapper);
    list.sort(function(a, b) {
      return b.priority - a.priority;
    });
    return this;
  }
  emit(type, payload) {
    const list = this.events[type];
    if (!list || list.length === 0) {
      return false;
    }
    const queue = list.slice();
    for (let i = 0; i < queue.length; i++) {
      const item = queue[i];
      if (item.once) {
        this.off(type, item.handler);
      }
      try {
        item.handler(payload);
      } catch (error) {
        console.error('handler error', error);
      }
    }
    return true;
  }
  off(type, handler) {
    const list = this.events[type];
    if (!list) {
      return this;
    }
    if (!handler) {
      delete this.events[type];
      return this;
    }
    this.events[type] = list.filter(function(item) {
      return item.handler !== handler;
    });
    return this;
  }
}

这里使用 handler 来查找并移除一次性监听,可以保证多次注册同一个函数时仍能准确删除对应的包装对象。如果希望更严谨,可以给每个包装对象生成唯一 ID,并通过 ID 来取消,避免外部修改函数引用后导致删除失败。

内存泄漏是发布订阅模式最常见的隐性成本。在一个长生命周期的总线上,如果某个组件订阅了事件,却没有在销毁阶段调用 off,那么总线仍然持有该组件的回调函数,回调函数又可能通过闭包持有组件的 DOM 节点或其他数据,最终导致整个组件无法被垃圾回收。解决思路有两种:一是约定统一的销毁钩子,在组件卸载时集中调用 off;二是使用 WeakMap 存储事件与回调的关系,但需要注意的是,WeakMap 的键必须是对象,因此更适合按组件实例管理订阅,而不能直接用来替代字符串事件表。

Node.js 内置的 EventEmitter 也提供了类似能力,但它不是所有环境都可用,浏览器端通常需要自己封装。它的实现思路同样值得参考:将每个事件类型的监听器包装成 Object,支持 prependListener、removeAllListeners 等方法,并会在监听器数量超过默认阈值时给出警告。这个警告本身是一种保护,提醒开发者检查是否因为不断注册而没有释放导致潜在内存问题。理解了这些细节,再回头看项目中那些不起眼的 on 和 emit,就会发现它并不是简单地把函数推进数组,而是涉及调度顺序、错误隔离和资源释放的完整设计。

发布订阅模式观察者模式JavaScript事件机制修改时间:2026-09-21 09:26:53

免责声明:已尽一切努力确保本网站所含信息的准确性。网站作品多为原创整理与精心创作,观点力求客观中立。本站旨在免费分享,内容仅供个人学习、研究或参考使用。若引用了第三方作品,版权归原作者所有。如内容涉及您的权益,请联系我们进行处理Email:chomcom@qq.com。
引用或转载本作品时,请注明当前出处:https://www.ipipp.com/html/0921/60003.html,基于非商业用途的前提下,欢迎转载或二创本作品。
内容垂直聚焦
专注技术核心技术栏目,确保每篇文章深度聚焦于实用技能。从代码技巧到架构设计,为用户提供无干扰的纯技术知识沉淀,精准满足专业提升需求。
知识结构清晰
覆盖从开发到部署的全链路。AI、前端、编程、数据库、服务器、建站、系统层层递进,构建清晰学习路径,帮助用户系统化掌握开发与运维所需的核心技术。
深度技术解析
拒绝泛泛而谈,深入技术细节与实践难点。无论是数据库优化还是服务器配置,均结合真实场景与代码示例进行剖析,致力于提供可直接应用于工作的解决方案。
专业领域覆盖
精准对应开发生命周期。从前端界面到后端编程,从数据库操作到服务器运维,形成完整闭环,一站式满足全栈工程师和运维人员的技术需求。
即学即用高效
内容强调实操性,步骤清晰、代码完整。用户可根据教程直接复现和应用于自身项目,显著缩短从学习到实践的距离,快速解决开发中的具体问题。
持续更新保障
专注既定技术方向进行长期、稳定的内容输出。确保各栏目技术文章持续更新迭代,紧跟主流技术发展趋势,为用户提供经久不衰的学习价值。