发布订阅模式在前端的应用

先看一个案例:网站登录
我们在开发一个商场网站,网站里面有header头部、消息列表、购物车等模块,这几个模块的渲染有一个共同的前提,就是必须先获取用户登录信息,但是用户信息是要通过请求获取,什么时候获取成功谁也不知道
这个时候我们可以通过回调函数来解决这个问题,在登录模块里我们可以这么写:

login.success(function(data){
  header.setAvatar(data.avatar);    //设置header模块的头像
  message.refresh();    //刷新消息列表
  cart.refresh();   //刷新购物车
  //...
})

这么做的缺点是什么呢?强耦合
● 我们只负责编写login模块,却必须要了解header模块要调用setAvatar方法去设置头像,message要通过refresh方法去刷新队列.....
● 如果header模块要更改设置头像的方法名,还必须去修改login模块的代码
● 如果又有一个新模块也需要获取用户信息,也要去修改login模块的代码
我们用发布订阅模式重写一下这个代码
login模块

login.success(function(data) {
  this.trigger('loginSuccess', data);   //发布登录成功的消息
})

header模块

login.listen('loginSuccess', function(data) {
  this.setAvatar(data.avatar)
})

这样我们就可以在自己的模块里面添加或更改监听消息即可,登录模块的开发者再也不需要关心这些行为了。
通过上面这个例子我们可以得知:
● 发布订阅模式主要应用于异步编程中,是一种可以替代回调函数的技术方案
● 发布订阅模式可以让两个对象松耦合的联系在一起,不用了解彼此的细节,且不影响他们之间的通信

概念

发布订阅模式定义了对象间一种一对多的依赖关系,当一个对象的状态发生变化时,所有依赖于它的对象都将得到通知。


image.png

实现

通用实现

const event = {
  //缓存列表,存放订阅者的回调函数
  clientList: {}, 
  //添加订阅者
  listen: function(key, fn) {
    if(!this.clientList[key]) {
      this.clientList[key] = []
    }
    this.clientList[key].push(fn)
  },
  //发布消息
  trigger: function(key, ...args) {
    const fns = this.clientList[key] ?? [];
    for(let i = 0; i < fns.length; i++) {
      fns[i].apply(this, args)
    }
  },
  remove: function(key, fn) {
    const fns = this.clientList[key]
    //如果key对应的消息没人订阅,则直接返回
    if(!fns || fns.length === 0) return;
    //如果没有传入具体的回调函数,则取消订阅这个key对应的所有订阅
    if(!fn) {
      fns.length = 0;
    } else {
      //反向遍历并删除所有的fn
      for(let i = fns.length - 1; i >= 0; i--) {
        let _fn = fns[i];
        if(_fn === fn) {
          fns.splice(i, 1)
        }
      }
    }
  }
}

//给对象动态安装发布订阅功能
const installEvent = function(obj){
  for(let key in event) {
    obj[key] = event[key]
  }
}

测试代码

 const login = {};
  installEvent(login);
  const setHeader = function(data) {
    console.log("setHeader", data)
  }
  login.listen('loginSuccess', setHeader)
  login.trigger('loginSuccess', {   //输出setHeader: {name: "xxx"}
    name: "xx"
  });
  login.remove('loginSuccess');
  login.trigger('loginSuccess');  //不输出

优化:全局的发布订阅对象

上面代码还存在一些缺点
● 我们给每个对象都添加了listen、trigger方法,以及一个缓存列表clientList,这是一种资源的浪费
● 其他模块想订阅loginSuccess消息,必须要知道要发布的对象是login才可以,但是我们关心的只是loginSuccess消息,而不是谁发布的消息。
所以我们现在要添加一个类似于中介公司的全局Event对象,来将发布者和订阅者关联起来

const Event = (function(){
  //缓存列表,存放订阅者的回调函数
  const clientList = {};
  //添加订阅者
  const listen = function(key, fn) {
    if(!clientList[key]) {
      clientList[key] = []
    }
    clientList[key].push(fn)
  }
  const remove = function(key, fn) {
    const fns = clientList[key]
    //如果key对应的消息没人订阅,则直接返回
    if(!fns || fns.length === 0) return;
    //如果没有传入具体的回调函数,则取消订阅这个key对应的所有订阅
    if(!fn) {
      fns.length = 0;
    } else {
      //反向遍历并删除所有的fn
      for(let i = fns.length - 1; i >= 0; i--) {
        let _fn = fns[i];
        if(_fn === fn) {
          fns.splice(i, 1)
        }
      }
    }
  }
    //发布消息
    const trigger = function(key, ...args) {
      const fns = clientList[key] ?? [];
      for(let i = 0; i < fns.length; i++) {
        fns[i].apply(this, args)
      }
    }
  return {
    listen,
    remove,
    trigger
  }
})()

测试代码

const setHeader = function(data) {
    console.log("setHeader", data)
  }
  Event.listen('loginSuccess', setHeader)
  Event.trigger('loginSuccess', {   //输出setHeader: {name: "xxx"}
    name: "xx"
  });
  Event.remove('loginSuccess');
  Event.trigger('loginSuccess');  //不输出

有了Event对象固然方便,但是当模块之间用了太多的全局发布订阅模式来通信,那么模块与模块之间的联系就会被隐藏,因为谁都可以充当发布者,因此我们无法追溯这个消息到底来自哪个模块。

优化:必须订阅再发布吗

我们之前实现的发布订阅都是要订阅者必须先订阅消息,随后才能接收到发布者发布的消息,如果反过来那发布的消息订阅者一定接收不到。
在上面的案例中,header模块如果是一个异步加载的模块,那么就并不能保证订阅loginSuccess的动作一定在发生在发布loginSuccess之前。
为了满足这个需求,我们要建立一个存放离线事件的堆栈,当消息发布的时候,如果没有人订阅这个消息,就暂时把这个发布的动作存入离线事件栈中

const Event = (function(){
  const clientList = {};    //缓存列表,存放订阅者的回调函数
  const offlineStack = {};  //离线事件栈
  //添加订阅的消息
  const _listen = function(key, fn) {
    if(!clientList[key]) {
      clientList[key] = []
    }
    clientList[key].push(fn)
  }
  //移除订阅的消息
  const remove = function(key, fn) {
    const fns = clientList[key]
    //如果key对应的消息没人订阅,则直接返回
    if(!fns || fns.length === 0) return;
    //如果没有传入具体的回调函数,则取消订阅这个key对应的所有订阅
    if(!fn) {
      fns.length = 0;
    } else {
      //反向遍历并删除所有的fn
      for(let i = fns.length - 1; i >= 0; i--) {
        let _fn = fns[i];
        if(_fn === fn) {
          fns.splice(i, 1)
        }
      }
    }
  }
  //发布消息
  const _trigger = function(key, ...args) {
    const fns = clientList[key] ?? [];
    for(let i = 0; i < fns.length; i++) {
      fns[i].apply(this, args)
    }
  }
  return {
    remove,
    /**
     * @param {Boolean} options.last - 是否只触发最后一次离线事件
     */
    listen: function(key, fn, options) {
      _listen(key, fn);
      //处理离线事件
      const stack = offlineStack[key];
      if (!Array.isArray(stack)) {
        //已经处理过了
        return;
      }
      //只触发最后一次
      if (options?.last) {
        const offlineFn = stack.pop();
        offlineFn && offlineFn();
      } else {
        stack.forEach(fn => fn())
      }
      //处理过一次离线事件后就不再处理
      offlineStack[key] = null;
    },
    trigger: function (key, ...args) {
      const _self = this;
      //将发布事件的动作包裹在一个函数里
      const fn = _trigger.bind(_self, key, ...args)   
      if(clientList[key]) return fn();
      //如果此时还没有人订阅这个消息,就暂时存入离线事件栈
      const stack = offlineStack[key];
      if (Array.isArray(stack)) {
        //多次触发同一个消息,并且这个消息还没人监听过
        return stack.push(fn)
      } else {
        //首次触发
        return offlineStack[key] = [fn]
      }
    }
  }
})()

测试代码

  const setHeader = function(data) {
    console.log("setHeader", data)
  }
  Event.trigger('loginSuccess', {   
    name: "xx"
  });
  Event.trigger('loginSuccess', {   
    name: "xx"
  });
  Event.listen('loginSuccess', setHeader, {last: true}) //输出setHeader: {name: "xxx"}

优化:解决命名冲突

全局的发布订阅对象里只有一个clientList来存放消息名,大家都通过它来订阅和发布消息,久而久之,难免会出现事件名冲突的情况,所以我们还可以给Event对象提供创建命名空间的功能。

const Event = (function(){
  const namespaceCache = {}
  //添加订阅的消息
  const _listen = function(clientList, key, fn) {
    if(!clientList[key]) {
      clientList[key] = []
    }
    clientList[key].push(fn)
  }
  //移除订阅的消息
  const _remove = function(clientList, key, fn) {
    const fns = clientList[key]
    //如果key对应的消息没人订阅,则直接返回
    if(!fns || fns.length === 0) return;
    //如果没有传入具体的回调函数,则取消订阅这个key对应的所有订阅
    if(!fn) {
      fns.length = 0;
    } else {
      //反向遍历并删除所有的fn
      for(let i = fns.length - 1; i >= 0; i--) {
        let _fn = fns[i];
        if(_fn === fn) {
          fns.splice(i, 1)
        }
      }
    }
  }
  //发布消息
  const _trigger = function(clientList, key, ...args) {
    const fns = clientList[key] ?? [];
    for(let i = 0; i < fns.length; i++) {
      fns[i].apply(this, args)
    }
  }
  //创建命名空间
  const _create = function(namespace = 'default') {
    const clientList = {};    //缓存列表,存放订阅者的回调函数
    const offlineStack = {};  //离线事件栈
    const ret = {
      /**
       * @param {Boolean} options.last - 是否只触发最后一次离线事件
       */
      listen: function(key, fn, options) {
        _listen(clientList, key, fn);
        //处理离线事件
        const stack = offlineStack[key];
        if (!Array.isArray(stack)) {
          //已经处理过了
          return;
        }
        //只触发最后一次
        if (options?.last) {
          const offlineFn = stack.pop();
          offlineFn && offlineFn();
        } else {
          stack.forEach(fn => fn())
        }
        //处理过一次离线事件后就不再处理
        offlineStack[key] = null;
      },
      remove: function() {
        _remove(clientList, key, fn)
      },
      trigger: function (key, ...args) {
        const _self = this;
        //将发布事件的动作包裹在一个函数里
        const fn = _trigger.bind(_self, clientList, key, ...args)   
        if(clientList[key]) return fn();
        //如果此时还没有人订阅这个消息,就暂时存入离线事件栈
        const stack = offlineStack[key];
        if (Array.isArray(stack)) {
          //多次触发同一个消息,并且这个消息还没人监听过
          return stack.push(fn)
        } else {
          //首次触发
          return offlineStack[key] = [fn]
        }
      }
    }
    //返回对应的Event对象
    if (!namespaceCache[namespace]) {
      namespaceCache[namespace] = ret;
    }
    return namespaceCache[namespace]
  }

  return {
    create: _create,
    listen: function(...args) {
      const event = this.create();
      event.listen(...args)
    },
    remove: function(...args) {
      const event = this.create();
      event.remove(...args)
    },
    trigger: function(...args) {
      const event = this.create();
      event.trigger(...args)
    }
  }
 
})()

测试代码

Event.listen('click', function(){
  console.log(1)
})
Event.create("test").listen('click', function(){
  console.log(2)
})

Event.trigger('click');  //输出1
Event.create("test").trigger('click');  //输出2

应用后的改良版

const Emitter = (function () {
  const namespaceCache = {};
  //添加订阅的消息
  const _listen = function (clientList, key, fn) {
    if (!clientList[key]) {
      clientList[key] = [];
    }
    clientList[key].push(fn);
  };
  //移除订阅的消息
  const _remove = function (clientList, key, fn) {
    const fns = clientList[key];
    //如果key对应的消息没人订阅,则直接返回
    if (!fns || fns.length === 0) return;
    //如果没有传入具体的回调函数,则取消订阅这个key对应的所有订阅
    if (!fn) {
      clientList[key] = null;
    } else {
      //反向遍历并删除所有的fn
      for (let i = fns.length - 1; i >= 0; i--) {
        let _fn = fns[i];
        if (_fn === fn) {
          fns.splice(i, 1);
        }
      }
    }
  };
  //发布消息
  const _trigger = function (clientList, key, ...args) {
    const fns = clientList[key] ?? [];
    for (let i = 0; i < fns.length; i++) {
      fns[i].apply(this, args);
    }
  };
  //创建命名空间
  const _create = function (namespace = "default") {
    const clientList = {}; //缓存列表,存放订阅者的回调函数
    const offlineStack = {}; //离线事件栈
    const ret = {
      /**
       * @param {Boolean} options.last - 是否只触发最后一次离线事件
       */
      listen: function (key, fn, options) {
        _listen(clientList, key, fn);
        //处理离线事件
        const stack = offlineStack[key];
        if (!Array.isArray(stack)) {
          //已经处理过了
          return;
        }
        //只触发最后一次
        if (options?.last) {
          const offlineFn = stack.pop();
          offlineFn && offlineFn();
        } else {
          stack.forEach((fn) => fn());
        }
        //处理过一次离线事件后就不再处理
        offlineStack[key] = null;
      },
      remove: function (key, fn) {
        _remove(clientList, key, fn);
        /** 移除 **/
        offlineStack[key] = [];
      },
      trigger: function (key, ...args) {
        const _self = this;
        //将发布事件的动作包裹在一个函数里
        const fn = _trigger.bind(_self, clientList, key, ...args);
        if (clientList[key]) return fn();
        //如果此时还没有人订阅这个消息,就暂时存入离线事件栈
        const stack = offlineStack[key];
        if (Array.isArray(stack)) {
          //多次触发同一个消息,并且这个消息还没人监听过
          return stack.push(fn);
        } else {
          //首次触发
          return (offlineStack[key] = [fn]);
        }
      },
    };
    //返回对应的Event对象
    if (!namespaceCache[namespace]) {
      namespaceCache[namespace] = ret;
    }
    return namespaceCache[namespace];
  };

  return {
    create: _create,
    listen: function (...args) {
      const event = this.create();
      event.listen(...args);
    },
    remove: function (...args) {
      const event = this.create();
      event.remove(...args);
    },
    trigger: function (...args) {
      const event = this.create();
      event.trigger(...args);
    },
  };
})();

export default Emitter;

应用

常见的底层原理

发布订阅模式在前端的使用非常广泛,但是有一些与发布订阅模式类似的设计模式很容易混淆,大家可以猜一下下面几种是否属于发布订阅模式

  1. addEventListen

addEventListener 方法并不是基于发布订阅模式的实现,而是基于直接事件监听的机制。
addEventListener 是 JavaScript 提供的一种机制,用于在特定的 DOM 元素上注册事件监听器。当指定的事件在元素上触发时,注册的监听器函数会被调用。
这种直接事件监听的机制是基于浏览器的事件模型,其中包括捕获、目标和冒泡阶段。在冒泡阶段,事件从触发元素开始冒泡至其父元素,依次触发相应的事件监听器。这种机制使得我们可以在父元素上进行事件监听,而不仅限于目标元素本身。
尽管直接事件监听和发布订阅模式都涉及到事件和事件处理,但它们在实现上有一些区别。直接事件监听是一种基于浏览器提供的事件机制,将事件处理函数直接绑定到特定元素上。而发布订阅模式更注重解耦,通过一个中心调度器或事件总线来订阅和发布事件,以实现多对多的事件通信。
需要注意的是,在底层实现中,某些事件系统可能结合了多种设计模式和技术,因此可能不仅仅依赖于单一的发布订阅模式或直接事件监听。这取决于具体的实现和框架。

  1. vue的自定义事件:$on, $emit
//定义MyComponent
const MyComponent = {
  name: "MyComponent",
  setup(props, {emit}) {
    emit("change", 1)
  }
}

//调用MyComponent
<template>
    <MyComponent @change="doChange" />
</template>

<script setup>
    const doChange = (x) => {
    console.log(x)
  }
</script>

<MyComponent @change="doChange" />翻译成虚拟DOM:

{
  type: MyComponent,
  props: {
    onChange: doChange
  }
}

emit函数源码

//event: 事件名称
//pyaload:传递给事件处理函数的参数
function emit(event, ...payload) {
  //处理事件名称,如change => onChange
  const eventName = `on${event[0].toUpperCase() + event.slice(1)}`
  //根据处理后的事件名称去props中寻找对应的事件处理函数
  const handler = instance.props[eventName]
  if(handler) {
    handler(...payload)
  } else {
    console.('事件不存在')
  }
}

真实项目场景

在项目中某些场景也可以用到,比如一个页面有动态筛选条和一些需要依赖筛选条去发起请求的指标,同时筛选条可能要请求接口去渲染,这时我们预期的情况是: 筛选条请求接口获取下拉列表数据 > 筛选条完成渲染 > 请求指标布局信息 > 指标占位渲染完成(loading 状态) > 用筛选条件请求指标 > 指标完成渲染(有数据状态)

这样只能先渲染筛选条再渲染指标,这样会同步依次处理势必会将整体渲染完成的时间大大增加,因此我们希望的是让筛选条和指标同时进行渲染
筛选条请求接口获取下拉列表数据 > 筛选条完成渲染
请求指标布局信息 > 指标占位渲染完成(loading 状态)

这时候根据响应速度的不同,筛选条和指标占位渲染就有先后之分,如果筛选条先渲染好,那么指标占位渲染好后就可以直接拿到筛选条件渲染指标,那如果反之呢?这时候指标怎么拿到筛选条件?
这时候就可以利用发布订阅模式,以及前面所说的离线事件栈的 last 参数,不管筛选条是先还是后,他只负责发消息,而指标就只负责订阅,利用last 参数不管他们渲染顺序的先后,都可以让指标在筛选条渲染完成后正常发起请求。

©著作权归作者所有,转载或内容合作请联系作者
平台声明:文章内容(如有图片或视频亦包括在内)由作者上传并发布,文章内容仅代表作者本人观点,简书系信息发布平台,仅提供信息存储服务。

推荐阅读更多精彩内容