最近在看JavaScript设计模式与开发实践这本书,感觉受益颇深,当然这本书也是不太推荐还没牢固掌握js基础知识的小白读,适合已经入门或者刚参加工作的人读。

当然发布订阅也是设计模式中最常见的一种模式了,其作用非常强大,现如今的前端框架,比如React/Vue中都能看到该模式,另外我在手写Promise源码的时候也用到了发布订阅模式,遇到复杂的跨组件传值情况也可以使用发布订阅来进行传递。
总之该模式实用性很强,是个必须掌握的一个知识点。

接下来我将一步步实现一个功能较为健全的发布订阅模式。

基本的发布订阅器

首先实现一个基本的发布订阅器,功能具备:

  1. 事件的监听,可以为一个事件类型添加多个函数进行监听。
  2. 事件的触发,触发事件会执行所有该事件的监听函数。
  3. 事件的移除,可以移除同一个事件的某一个监听函数,也可以移除某一个事件的所有监听函数。
const events = {
  /*
     保存事件的一个对象,基本结构为{key1:fn[],key2:fn[],...}
     你也可以使用map代替普通的对象
  */
  eventList: {},
  listen(key, fn) {
    if (!this.eventList[key]) {
      this.eventList[key] = []
    }
    this.eventList[key].push(fn);
  },
  trigger(key, ...args) {
    fns = this.eventList[key];
    if (!fns || fns.length === 0) {
      return false;
    }
    for (let i = 0, fn; fn = fns[i++];) {
      fn.apply(this, args)
    }
  }
  ,
  remove(key, fn) {
    const fns = this.eventList[key];
    // 如果用户没有传一个指定函数就删除该类型下的所有函数
    if (!fns) {
      return false
    }
    if (!fn) {
      fns && (fns.length = 0)
    } else {
      for (let l = fns.length - 1; l >= 0; l++) {
        const _fn = fns[l];
        if (_fn === fn) {
          fns.splice(l, 1);
        }
      }
    }
  }
}

测试用例:

events.listen('home', function (price, square) {
  console.log('价格', price);
  console.log('面积', square);
})

events.listen('home', function (price, square) {
  console.log('面积', square);
})

events.listen('home', function (price) {
  console.log('价格', price);
})

events.listen('car', function (price) {
  console.log('car价格', price);
})
events.remove('car')

events.trigger('home', 2000, 88)
events.trigger('car', 20010, 881)
// 价格 2000
// 面积 88
// 面积 88
// 价格 2000

上述代码实现了一个基本的发布订阅功能,接下来我们实现一个功能较为丰富的发布订阅器。

进阶的发布订阅器

功能具备:

  1. 上述基本的所有功能。
  2. 支持单次监听,即该类型事件只会被监听一次。
  3. 唯一订阅,同类型下,唯一订阅前的所有非唯一订阅都会失效。
  4. 支持先发布后订阅。
  5. 支持类型订阅数量限制。
  6. 具有命名空间。

说明:
先发布后订阅功能,我们上面实现的基本发布订阅器,是需要执行订阅器,然后执行发布器触发,如果先发布的话,那是后续的订阅器是不会执行的了。而我们需要实现先执行发布类型事件(可以发布多个),然后后续遇到订阅了相同事件的订阅器就会执行先发布的类型事件,这里我们的逻辑就是只能触发一次先发布的类型事件。可以理解为离线消息只能被已读一次。

命名空间则是考虑到大量使用发布订阅的情况,遇到了多个事件想使用同一个类型,所以可以加上一个命名空间来进行区分。

class MyEvent {
  constructor() {
    // 命名空间
    this.namespaceCache = {};
  }
  // 默认的命名空间
  static _default = "default"
  //遍历执行函数的迭代器
  static each(arr, fn) {
    let ret;
    for (let i = 0, l = arr.length; i < l; i++) {
      const itemFn = arr[i];
      ret = fn(itemFn, i)
    }
    return ret;
  }
  // 默认最大监听函数数量
  static defaultMaxListeners = 5
  // 设置最大监听函数数量
  setMaxListeners(count) {
    this._count = count
  }
  // 获取最大监听函数数量
  getMaxListeners() {
    return this._count ? this._count : MyEvent.defaultMaxListeners
  }
  // 基础的监听函数
  _on(key, fn, cache) {
    let fns = cache.get(key);
    if (!fns) {
      fns = [];
    }
    if (fns.length >= this.getMaxListeners()) {
      return
    }
    fns.push(fn);
    cache.set(key, fns);

  }
  _remove(key, fn, cache) {
    let fns = cache.get(key);
    //删除具体的函数
    if (fn) {
      for (let i = fns.length; i >= 0; i--) {
        if (fns[i] === fn) {
          fns.splice(i, 1);
        }
      }
      // 没有传递就删除所有函数
    } else {
      fns && (fns.length = 0)
    }
  }
  _emit(key, cache, ...args) {
    const fns = cache.get(key);
    if (!fns || !fns.length) {
      return;
    }
    // 这里用箭头函数的话内部绑定的this就是无效的,另外这里封装一个迭代器是为了接受返回值
    return MyEvent.each(fns, (itemFn) => {
      return itemFn.apply(this, args);
    });
  }
  // 创建命名空间
  create(namespace = MyEvent._default) {
    const cache = new Map();
    // 保存先发布的的类型和参数数组,这是支持先发布后订阅功能关键
    let offlineStack = [];
    const childEvent = {
      on: (key, fn, isLast = false) => {
        this._on(key, fn, cache); 
        // 说明没有已经发布的了
        if (offlineStack === null) {
          return
        }
        // isLast 这个参数进行先发布后订阅生效,主要目的是后续的订阅只会对最后一个发布生效
        if (isLast) {
          offlineStack.length && offlineStack.pop()();
        } else {
          MyEvent.each(offlineStack, (itemFn) => {
            itemFn()
          })
        }
        // 订阅的时候只能执行一次预先的发布函数,后续的订阅就不执行了
        offlineStack = null;
      },
      // 订阅的时候在这之前的所有订阅都会失效
      one: (key, fn, isLast = false) => {
        // 清除所有保存的订阅函数,只执行当前的
        this._remove(key, null, cache);
        childEvent.on(key, fn, isLast)
      },
      once: (key, fn, isLast) => {
        const wrap = (...args) => {
          fn.apply(this, args);
          childEvent.remove(key, wrap)
        }
        childEvent.on(key, wrap, isLast)
      },
      remove: (key, fn) => {
        this._remove(key, fn, cache);
      },
      emit: (key, ...args) => {
        // 将参数保存
        const fn = () => {
          return this._emit.call(this, key, cache, ...args)
        }
        // 如果有说明存在还没被订阅就先发布了,所以保存在栈中
        if (offlineStack) {
          return offlineStack.push(fn);
        }
        // 否则就是正常订阅发布顺序,直接执行
        return fn();
      }
    }
    // 保存命名空间
    return this.namespaceCache[namespace] ? this.namespaceCache[namespace] : this.namespaceCache[namespace] = childEvent
  }
  // 考虑没有设置命名空间的情况,返回代理方法
  on(key, fn, isLast) {
    const event = this.create();
    event.on(key, fn, isLast)
  }
  one(key, fn, isLast) {
    const event = this.create();
    event.one(key, fn, isLast)
  }
  once(key, fn, isLast) {
    const event = this.create();
    event.once(key, fn, isLast)
  }
  emit(key, ...args) {
    const event = this.create();
    event.emit(key, ...args)
  }
  remove(key, fn) {
    const event = this.create();
    event.remove(key, fn)
  }
}

module.exports = new MyEvent();

测试用例:

// 这里默认最大监听数是 2
Event.on('car', (price) => {
  console.log('汽车降价了', price)
})
Event.on('car', (price) => {
  console.log('汽车又降价了', price - 1000)
})

Event.on('car', (price) => {
  console.log('汽车内购价', price - 10000)
})

Event.emit('car', 50000);
// 汽车降价了 50000
// 汽车又降价了 49000
Event.setMaxListeners(5)
Event.on('car', (price) => {
  console.log('汽车降价了', price)
})
Event.on('car', (price) => {
  console.log('汽车又降价了', price - 1000)
})

Event.on('car', (price) => {
  console.log('汽车内购价', price - 10000)
})


Event.emit('car', 50000)
// 汽车降价了 50000
// 汽车又降价了 49000
// 汽车内购价 40000
// 测试先发布订阅
Event.emit('car', 50000)
Event.on('car', (price) => {
  console.log('汽车降价了', price)
})

// 后续的订阅不会触发
Event.on('car', (price) => {
  console.log('汽车又降价了', price - 1000)
})
// 汽车降价了 50000
// 多个发布
Event.emit('car', 50000)
Event.emit('car', 50001)
Event.emit('car', 50002)
Event.on('car', (price) => {
  console.log('汽车降价了', price)
})

// 后续的订阅不会触发
Event.on('car', (price) => {
  console.log('汽车又降价了', price - 1000)
})
// 汽车降价了 50000
// 汽车降价了 50001
// 汽车降价了 50002
// 测试isLast
Event.emit('car', 50000)
Event.emit('car', 50001)
Event.emit('car', 50002)
Event.on('car', (price) => {
  console.log('汽车降价了', price)
}, true)

// 后续的订阅不会触发
Event.on('car', (price) => {
  console.log('汽车又降价了', price - 1000)
})
// 汽车降价了 50002
// 测试once
Event.on('car', (price) => {
  console.log('常年促销', price)
})
Event.once('car', (price) => {
  console.log('汽车降价了', price)
})

Event.emit('car', 50000)
Event.emit('car', 50000)
// 常年促销 50000
// 汽车降价了 50000
// 常年促销 50000
// 测试唯一订阅,同类型下,唯一订阅前的所有非唯一订阅都会失效(同一命名空间下)
Event.setMaxListeners(5)
Event.on('car', (price) => {
  console.log('常年促销1', price)
})
Event.on('car', (price) => {
  console.log('常年促销2', price)
})
Event.on('car', (price) => {
  console.log('常年促销3', price)
})
Event.one('car', (price) => {
  console.log('只有我能订阅', price)
})

Event.emit('car', 50000)
Event.emit('car', 50000)
// 只有我能订阅 50000
// 只有我能订阅 50000
// 测试命名空间
Event.setMaxListeners(5)
Event.create('namespace1').on('car', (price) => {
  console.log('常年促销1', price);
})
Event.create('namespace2').on('car', (price) => {
  console.log('常年促销2', price);
})
Event.one('car', (price) => {
  console.log('只有我能订阅', price);
})

Event.emit('car', 50000);
Event.create('namespace1').emit('car', 50000);
Event.create('namespace2').emit('car', 50000);
// 只有我能订阅 50000
// 常年促销1 50000
// 常年促销2 50000

以上就是一个较为完整的发布订阅器。
上述Javascript实现的的发布订阅模式其实还是和一些语言有一些不同。一些其他的语言相较JavaScript可能实现较为复杂,JavaScript可以将函数作为参数的特性,使得实现起来更加优雅和简单。
发布订阅模式优点很明显,对于对象二者通信的结耦,在异步编程中也可以实现更加简洁松耦合的代码编写。
但是该模式也不可避免的有一些缺点,毕竟发布订阅模式内部还是有闭包的存在,导致订阅者会一直存在内存中。另外我们过度的使用发布订阅会导致对象与对象之间的关系变得不可捉摸,同时多个发布订阅嵌套在一起导致的bug可能更加不可排查。

一句话,合理利用,不可滥用。

参考文献:

发布订阅模式,在工作中它的能量超乎你的想象

JavaScript设计模式与开发实践

Last modification:April 26, 2022
如果觉得我的文章对你有用,请随意赞赏