前端战五渣学JavaScript——浅谈发布—订阅模式

1,107 阅读11分钟

因为这两天想看看怎么自己手写一个promise并实现基本功能,最一开始就看到了then方法需要涉及发布-订阅模式。。。所以有专门看了看到底什么是发布-订阅模式

我们是如何绑定点击监听事件的

添加发布-订阅

众所周知啊,我们在一个button或者任意一个标签上绑定点击事件的时候,都是先声明了一个方法,然后再我们点击的时候才会真正的去执行我们绑定的这个方法,这就是一个比较典型的运用了发布-订阅模式的例子。

我们先来看代码吧⬇️

index.html

<button id="btn">点我</button>

index.js

(() => {
  const btn = document.querySelector('#btn');
  btn.addEventListener('click', () => {
    console.log('第一个监听事件');
  }, false);
  btn.addEventListener('click', () => {
    console.log('第二个监听事件');
  }, false);
})();

我们在一个按钮上绑定了两个点击事件,这两个事件都能接收到按钮被点击的信息,一个输出“第一个监听事件”,一个输出“第二个监听事件”
在我们绑定事件的时候,我们并不知道这两个事件什么时候会被执行,反正绑上就完事了
这两个事件并不会冲突,也不会覆盖,而是在我们点击了按钮以后会相继执行

删除订阅

我们既然可以添加一个订阅,那我们肯定在需要的时候也得可以能删除订阅,所以原生api也是有这个功能,看代码⬇️

index.html

  <button id="btn">点我</button>
  <button id="delete">删除第二个订阅事件</button>

index.js

(() => {
  const btn = document.querySelector('#btn');
  const deleteBtn = document.querySelector('#delete');
  function firstFn() {
    console.log('第一个监听事件');
  }
  function secondFn() {
    console.log('第二个监听事件');
  }
  function deleteFirstFn() {
    btn.removeEventListener('click', secondFn); // 删除btn上面的第二个监听事件
  }
  btn.addEventListener('click', firstFn, false);
  btn.addEventListener('click', secondFn, false);
  deleteBtn.addEventListener('click', deleteFirstFn, false); // 点击执行deleteFirstFn方法
})();

现在我们页面上是有两个按钮,当我们点击“点我”按钮的时候,控制台会输出“第一个监听事件”“第二个监听事件”
我们再点击“删除第二个订阅事件”按钮,这个时候我们已经删除了“点我”按钮的第二个监听事件
当我们再次点击“点我”按钮的时候,发现控制台只会输出“第一个监听事件”了,确定我们已经删除了“点我”按钮的第二个监听事件
因为在移除监听事件的时候,必须要指明需要删除的监听事件,所以我们不能使用匿名函数

我们来用发布-订阅实现自定义事件吧

在使用发布-订阅模式写我们自己的事件之前,我们先来设定一个背景故事

简单的发布-订阅

我是来自真新镇的小智,我的目标是成为神奇宝贝大师,我从大木博士的研究所出发,一路收集我喜欢的神奇宝贝,当我捕捉到一个神奇宝贝的时候,我会把这个好消息告诉大木博士和我的妈妈。我出来好几天了,我不想每天都给他们打电话告诉他们我有没有捉到神奇宝贝,我只想在我捕捉到神奇宝贝的时候再通知他们,你能帮助我吗??(越看越像小学应用题的语气。。。。)

let littleZhi = {}; // 声明一个小智
littleZhi.familyList = []; // 来一个缓存队列,存放需要通知的亲戚的回调函数
littleZhi.listen = function (fn) {
  this.familyList.push(fn); // 把需要通知的回调函数放起来
};
littleZhi.trigger = function () { // 通知家人
  for (let i = 0; i < this.familyList.length; i++ ) { // 当执行的时候,从familyList遍历,挨个通知一遍
    let fn = this.familyList[i];
    fn.apply(this, arguments);
  }
};

littleZhi.listen(function(pokemon) { // 事先定好,如果我抓到了pokemon,我就告诉妈妈
  console.log(`妈妈,我抓到${pokemon}了!!!`)
});
littleZhi.listen(function(pokemon) { // 事先定好,如果我抓到了pokemon,我就告诉博士
  console.log(`大木博士,我抓到${pokemon}了!!!`)
});

littleZhi.trigger('绿毛虫'); // 当抓到绿毛虫的时候,通知妈妈和博士,因为他们两个都跟小智说抓到了要告诉他们
littleZhi.trigger('比比鸟'); // 当抓到比比鸟的时候,通知妈妈和博士,因为他们两个都跟小智说抓到了要告诉他们

这样,我们实现了当小智捉到神奇宝贝的时候,就会通知妈妈和大木博士。之前没抓到的时候就不用通知了,不管在什么时候抓到了,只要执行littleZhi.trigger()这个方法,妈妈和大木博士就可以收到通知了

但是我们从输出情况也能看出来,当我们抓到不管是绿毛虫还是比比鸟,妈妈和大木博士收到的消息是一样的

添加标示,区分妈妈和博士

妈妈毕竟是妈妈,妈妈知道小智抓到了神奇宝贝,但是还想在知道抓到神奇宝贝的同时,还能了解一下小智的近况,所以我们需要把监听事件区分开来,我们继续来帮助他吧~

let littleZhi = {}; // 声明一个小智
littleZhi.familyList = []; // 来一个缓存队列,存放需要通知的亲戚的回调函数
littleZhi.listen = function (key, fn) {
  if ( !this.familyList[key] ) {
    this.familyList[key] = []; // 相同key值的情况下,如果还没有任何订阅,就给该类消息创建一个缓存列表
  }
  this.familyList[key].push(fn); // 把需要通知的回调函数放起来
};
littleZhi.trigger = function () { // 通知家人
  let key = Array.prototype.shift.call(arguments); // 从传入的参数里面选取第一个,就是我们传入的key值特殊标示
  let fns = this.familyList[key]; // 取出在familyList中对应key值的事件队列fns,再遍历这个事件队列挨个执行
  if (!fns || fns.length === 0) { // 如果传入的key值没有对应的事件队列,或者有队列,但是队列是空的,就直接返回
    return false
  }
  for (let i = 0; i < fns.length; i++ ) { // 当执行的时候,从familyList遍历,挨个通知一遍
    let fn = fns[i];
    fn.apply(this, arguments);
  }
};

littleZhi.listen('mama', function(pokemon, story) { // 事先定好,如果我抓到了pokemon,我就告诉妈妈,然后告诉她我的近况
  console.log(`妈妈,我抓到${pokemon}了!!!`);
  console.log(story);
});
littleZhi.listen('doctor', function(pokemon) { // 事先定好,如果我抓到了pokemon,我只告诉博士我抓到了什么
  console.log(`大木博士,我抓到${pokemon}了!!!`);
});

littleZhi.trigger('mama', '绿毛虫', '我还被皮卡丘电了'); // 通知妈妈我抓到绿毛虫了,但是我被皮卡丘电了
littleZhi.trigger('doctor', '比比鸟'); // 通知博士我抓到了比比鸟

这样我们就区分开了给妈妈和博士不同的消息,我们给妈妈消息的时候,这条消息就不会传到大木博士那里

诶??但是我们(我们的问题就是这么多)如果出发的不只是小智一个人,还有小茂呢??那小茂是不是也得重新写一遍这些方法和队列呢?

可根据不同人安装事件

我们都知道,跟小智一起从真新镇出发的还有小茂,那小茂出门在外当然也希望能往家里传递他旅行的好消息,但是现在只有小智可以通知家里,所以我们有什么好办法帮助小茂吗??

const event = { // 我们把需要的功能都单独列出来,发布,订阅,队列,以便后面需要的时候赋给需要的人
  familyList: [], // 来一个缓存队列,存放需要通知的亲戚的回调函数
  listen(key, fn) {
    if ( !this.familyList[key] ) {
      this.familyList[key] = []; // 相同key值的情况下,如果还没有任何订阅,就给该类消息创建一个缓存列表
    }
    this.familyList[key].push(fn); // 把需要通知的回调函数放起来
  },
  trigger() { // 通知家人
    let key = Array.prototype.shift.call(arguments); // 从传入的参数里面选取第一个,就是我们传入的key值特殊标示
    let fns = this.familyList[key]; // 取出在familyList中对应key值的事件队列fns,再遍历这个事件队列挨个执行
    if (!fns || fns.length === 0) { // 如果传入的key值没有对应的事件队列,或者有队列,但是队列是空的,就直接返回
      return false
    }
    for (let i = 0; i < fns.length; i++ ) { // 当执行的时候,从familyList遍历,挨个通知一遍
      let fn = fns[i];
      fn.apply(this, arguments);
    }
  }
};

const installEvent = function(pokemonMaster) { // 在出发的神奇宝贝大师身上安装发布订阅的功能
  for (let i in event) {
    pokemonMaster[i] = event[i];
  }
};

let littleZhi = {}; // 声明小智
let littleMao = {}; // 声明小茂

installEvent(littleZhi); // 给小智安装可以给家里通知的技能
installEvent(littleMao); // 给小茂安装可以给家里通知的技能

littleZhi.listen('littleZhiToDoctor', function(pokemon) { // 事先定好,如果小智抓到了pokemon,我只告诉博士我抓到了什么
  console.log(`大木博士,我是小智,我抓到${pokemon}了!!!`);
});
littleMao.listen('littleMaoToDoctor', function(pokemon) { // 事先定好,如果小茂抓到了pokemon,我只告诉博士我抓到了什么
  console.log(`大木博士,我是小茂,我抓到${pokemon}了!!!`);
});
// 小智的triger通知博士
littleZhi.trigger('littleZhiToDoctor', '比比鸟'); // 小智通知博士抓到了比比鸟 
// 小茂的triger通知博士
littleMao.trigger('littleMaoToDoctor', '小火龙'); // 小智通知博士抓到了小火龙


// 输出:大木博士,我是小智,我抓到比比鸟了!!!
// 输出:大木博士,我是小茂,我抓到小火龙了!!!

通过上面的改造,我们就分别给小智littleZhi和小茂littleMao多赋予了发生事情可以通知家里的技能,所以不单单只有小智可以了哦。

细心的小朋友可能会发现,其实不管是littleZhi还是littleMao添加的listen,都存放在同一个familyList里面,所以导致如果我们在littleZhi.listen(key, fn)littleMao.listen(key, fn)传如果相同的key,例如

littleZhi.listen('littleZhiToDoctor', function(pokemon) { // key为littleZhiToDoctor
  console.log(`大木博士,我是小智,我抓到${pokemon}了!!!`);
});
littleMao.listen('littleZhiToDoctor', function(pokemon) { // key也为littleZhiToDoctor
  console.log(`大木博士,我是小茂,我抓到${pokemon}了!!!`);
});

当我们发布消息的时候

littleZhi.trigger('littleZhiToDoctor', '比比鸟');
// 或者
littleMao.trigger('littleZhiToDoctor', '比比鸟');

不管是上面代码执行哪一行,都会同时输出“大木博士,我是小智,我抓到比比鸟了!!!”和“大木博士,我是小茂,我抓到比比鸟了!!!”和两句话。。。。我们可以理解为——电话串线了。。。。

因为我们在installEvent()的时候,消息队列是浅克隆(不懂深浅克隆的,可以看我的另一篇文章《前端战五渣学JavaScript——深克隆(深拷贝)》),所以两个被安装了方法的对象中famalyList引用的是同一个数组,所以在收到发布消息的时候会都执行。。。所以我们在给对象赋予event对象的时候,需要判断如果是familyList,需要采用深克隆的办法。。。

所以我们需要引入lodash的,用它里面的深克隆方法。。。毕竟自己去实现深克隆很麻烦

const _ = require('lodash');
const event = {...};
const installEvent = function() { // 在出发的神奇宝贝大师身上安装发布订阅的功能
  return _.cloneDeep(event);
};
let littleZhi = installEvent(); // 声明小智
let littleMao = installEvent(); // 声明小茂
...

这样我们即使在有相同key的listen的时候,各自的familyList里面对应的key队列也只有自己的函数。不会说小智trigger了一个key,小茂有,小茂也会执行的尴尬窘迫事情。

跟家里闹别扭,不想通知了,删除

删除的功能一般用的很少吧。。。那我们就来简单的写一下吧。

const event = {
  ...
  remove(key, fn) {
    let fns = this.familyList[key]; // 从事件队列中拿到key值对应的事件数组
    if (!fns) { // 如果key值没有对应的数组,就直接返回
      return false;
    }
    if (!fn) { // 如果没有传入fn,直接发key值对应的数组置空
      fns && ( fns.length = 0 )
    } else { // 反向遍历事件数组,如果有跟传入的函数是同一个的,就删除掉
      for (let i = fns.length - 1; i >= 0; i-- ) {
        let _fn = fns[i];
        if (_fn === fn) {
          fns.splice(i, 1);
        }
      }
    }
  }
}

这样我们就完成了发布订阅模式的删除功能。

Tips

这篇博客感觉长度差不多了,但是还有几点想说的,以后可能会单独开博客讲讲吧

一个上面的发布订阅模式是简陋的,只能完成特定事情的一个模型,但是基本的功能是可以实现了的。再大型项目开发过程中,我们是可以统一封装一个Event对象来实现我们上述的功能,以及定制化的功能。

还有一个是众所周知,react项目中我们可以依赖redux来进行数据的统一管理,那这个redux其实也是运用到了发布订阅的模式,来实现不同模块间的数据通信。

最后其实还有类似发布订阅的最佳实践还没有说到,比如一个组件中的事件执行之后,可能波及到好几个组件进行各种处理,那我们其他的组件怎么知道我这个组件发生了变化呢,那就是运用了发布订阅模式。以后单独开一篇博客来讲讲吧

总结

其实我们不是所用情况都需要用到发布订阅模式的,发布订阅虽好,可不要贪杯哦~

但是这种模式有一些比较明显的有点,就是时间上的解耦,我们在定义好事件以后,我们可以在需要执行的时候去执行。

此篇博客本来不在计划之中的,是想了解一下手写promise的实现,涉及到了这一块的知识,所以就找来看了看,觉得还挺有意思,还可以这么写,所以就单独写篇博客记录一下。


我是前端战五渣,一个前端界的小学生。