前言: 从八月份入职以来, 在可以保证项目进度后, 我便开始思考, 怎么把事情做得更好, 怎么提升自己.
一方面, 提升自己对 JavaScript 这门语言的理解, 我在 udemy 上买了 JavaScript: Understanding the Weird Parts. 中文翻译过来, 就是 JavaScript: 理解怪异的部分. 很经典, 我推荐每个越过了基础这道坎的人去看一下这部分内容. 我也买了书, 之后计划对每一章进行解读.
另一方面, 我明白了 JS 是一门编程语言, 是工具. 那么工具的用法是有很多种的. 在不同的场景, 使用不同的方法去处理, 会让你开发速度事半功倍. 也可以提升自己对问题不同的解决方案. 所以我阅读了《JavaScript 设计模式与开发实践》, 想知道更好的组织代码的形式是怎样, 在同一场景下, 别人是怎么处理问题的.
对于个人提升方面, 可以单独拿一篇来探讨了. 鉴于篇幅, 只说两点.
正文开始
什么是发布订阅模式
发布订阅模式称观察者模式, 它定义对象间的一种一对多的依赖关系, 当一个对象状态改变的时候, 所有依赖于它对象都将得到通知.
有点绕哈. 其实说得简单一点. 你关注了我, 我更新了文章, 你会得到推送, 就这个意思. 其实在日常的开发中, 你一直在使用着发布订阅模式进行开发. 最常见的例子是, 原生事件 API.(也就是鼠标点击 / 移动 / 进入等事件, 就是使用了发布订阅模式)
来, 举个栗子
- // 订阅
- document.body.addEventListener('click', function() {
- alert(2);
- });
- // 触发事件发布
- document.body.click();
在发布订阅模式中, 有两个对象, 一个是事件的发布者, 一个是订阅者.
好啦, 回答我一个问题, 然后继续看下去:
在例子中, 谁是发布者?
在例子中, 谁是订阅者?
假设你答出来了, OK, 那么接下来很容易理解. 如果没有, 那没关系, 先看答案: * 发布者是 document.body * 订阅者是我们 我们订阅了在 document.body 上的 click 事件, 当用户点击了 body, 那么会触发 click 事件, body 节点向用户也就是我发送信息(alert). 使用这个模式还有个优点是:
我们可以随意的增加或者删除事件, 这对订阅者不会产生任何影响.
实现发布订阅模式
在我们理解了发布者和订阅者的关系后, 来完成一个官方实例: 假设, 现在有一个售楼处, 售楼处作为发布者, 而买家作为订阅者. 当价格变动的时候, 售楼处把价格信息推送给订阅者.
// 实现一个发布订阅的步骤
指定好发布的对象是谁?
给发布者一个缓存队列, 存放回调函数以便通知订阅者.
发布消息遍历这个缓存队列, 以此触发里面存放的订阅者回调函数.(符合条件的就进行触发)
第一版:
- var selfOffices = {} // 定义发布者
- selfOffices.clientList = [] // 缓存队列, 用来存放回调函数
- // 增加订阅
- selfOffices.listen = function (fn) {
- this.clientList.push(fn)
- }
- // 触发事件发布
- selfOffices.trigger = function () {
- for (let i = 0, fn; fn = this.clientList[i++];) {
- fn.apply(this, arguments);
- }
- }
- // 订阅实例
- selfOffices.listen(function (price, squareMeter) {
- console.log('价格 =', price)
- console.log('squareMeter =', squareMeter)
- })
- // 订阅实例
- selfOffices.listen(function (price, squareMeter) {
- console.log('价格 =', price)
- console.log('squareMeter =', squareMeter)
- })
- // 触发
- selfOffices.trigger(2000000, 90)
- selfOffices.trigger(21321312321, 100)
至此实现了最基本的发布订阅模式, 但是你发现问题了吗?
当我触发其中一个订阅的时候, 在上面的模式下, 发布者把其他用户的订阅也发布给了我.
解决方案是增加一个标识.(就像 onclick, onmousemove, 你订阅 click 事件, 在 mousemove 事件触发时, 你不会接收到通知)
第二版:
- var selfOffices = {}
- selfOffices.clientList = []
- // 重要: 在这里, 增加了 key 关键字, 作为标识位
- selfOffices.listen = function (key, fn) {
- if (!this.clientList[key]) {
- this.clientList[key] = []
- }
- this.clientList[key].push(fn)
- }
- selfOffices.trigger = function () {
- // 重要: 在触发之前进行一个判断, 如果在触发的事件该订阅者没有订阅, 则不会执行相应的订阅事件
- var key = Array.prototype.shift.call(arguments)
- fns = this.clientList[key]
- if (!fns || fns.length == 0) {
- return false
- }
- for (let i = 0, fn; fn = fns[i++];) {
- fn.apply(this, arguments);
- }
- }
- selfOffices.listen("square88", function (price) {
- console.log('价格 =', price)
- })
- selfOffices.listen("square100", function (price) {
- console.log('价格 =', price)
- })
- selfOffices.trigger('square88', 90)
- selfOffices.trigger('square100', 100)
至此, 完成了发布特定消息, 订阅者订阅的事件发布的时候通知订阅了特定消息的人.
第三版: 让我们把以上的流程抽象出来, 变成一个通用的发布订阅模式
- // 发布订阅模式的通用模式
- // 发布者
- var event = {
- clientList: [],// 监听队列
- listen: function (key, fn) {// 订阅
- if (!this.clientList[key]) {
- this.clientList[key] = []
- }
- this.clientList[key].push(fn)
- },
- trigger: function () {// 触发
- var key = Array.prototype.shift.call(arguments),
- fns = this.clientList[key];
- if (!fns || fns.length == 0) {
- return false
- }
- for (var i = 0, fn; fn = fns[i++];) {
- fn.apply(this, arguments)
- }
- }
- }
- // 订阅者
- var installEvent = function (obj) {
- for (var i in event) {
- obj[i] = event[i]
- }
- }
- // 测试
- var sales = {}// 订阅者
- installEvent(sales)// 初始化订阅者
- sales.listen('88', function (price) {
- console.log(88)
- })
- sales.listen('99', function (price) {
- console.log(99)
- })
- sales.trigger('88')
- sales.trigger("99")
至此, 完成了一个非破坏性的通用发布订阅模式.
第四版: 你知道的, 可以订阅, 就一定要有取消订阅的功能, 不然... 你看 addEventListener. 很尴尬.(无法取消)
- (这里偷懒, 把第三版的代码假装放在这里)
- // but, 订阅完成之后, 我突然的又不想再继续订阅这个事件了, 因为我找到更加好的了
- // 为我们的发布订阅函数增加取消订阅的功能
- event.remove = function (key, fn) {
- // 根据 key 在缓存找到对应的缓存队列
- var fns = this.clientList[key]
- if (!fns) {
- return false
- }
- // 如果没有传入 fn 那么, 清空该条缓存队列
- if (!fn) {
- fns && (fns.length == 0)
- } else {
- // 相反, 如果存在 fn, 那么遍历缓存队列, 删除该条缓存队列中的事件
- for (var l = fns.length - 1; l>= 0; l--) {
- var _fn = fns[l]
- if (_fn === fn) {
- fns.splice(l, 1)
- }
- }
- }
- }
虽然, 这已经很棒了 XD, 但是, 还是存在一定的问题, 问题体现在以下几个方面:
我们在给每一个对象添加 listen 和 trigger 方法, 以及一个 clientList 列表, 其实没有这个必要
订阅者与发布者之间还是存在一定的耦合关系, 如果订阅者不知道发布者的名称, 那就无法进行订阅,
又或者, 订阅者想订阅另一个发布者的事件, 那么还是要去获取到另一个发布者的名称才能订阅到
解决方案: 使用全局 Event 对象实现, 订阅者不需要知道消息来自哪里, 发布者了也不知道信息要发布给谁 Event 对象作为中介, 链接两者(订阅者, 发布者).
第五版: 用立即执行函数, 形成闭包. 对外暴露出 Event 接口. 供外界使用.
- var Event = (function () {
- var clientList = [],
- listen,
- trigger,
- remove;
- listen = function (key, fn) {
- if (!clientList[key]) {
- clientList[key] = []
- }
- clientList[key].push(fn)
- }
- trigger = function (key) {
- var key = Array.prototype.shift.call(arguments)
- fns = clientList[key]
- if(!fns || fns.length == 0) {
- return false
- }
- for(var i = 0, fn; fn = fns[i++];) {
- fn.apply(this, arguments)
- }
- }
- remove = function (key, fn) {
- var fns = clientList[key]
- if(!fns) {
- return false
- }
- if(!fn) {
- fns && (fns.length = 0)
- }else {
- for(var l = fns.length -1 ; l>= 0; l--) {
- var _fn = fns[l]
- if(_fn === fn) {
- fns.splice(l, 1)
- }
- }
- }
- }
- return {
- listen: listen,
- trigger: trigger,
- remove: remove
- }
- })();
- Event.listen( '99', function (price) {
- console.log(price);
- })
- Event.trigger('99', 2999)
- // Event.remove('99')
- // Event.trigger('99', 299)
额, 第五版, 哎呀, 写得太棒了, 感觉没啥问题了. 但是想想, 在大型应用中, 使用发布订阅模式很可能, 很可能很多个. 那么在以上的模式下, 到最后, clientList 会有些膨胀. 可能造成很多很多的事件集中在这里. 不好管理, 以及 debugger. 所以, 我们迎来了第六版! 为发布订阅模式提供命名空间的能力! 更好的管理每个事件, 可以对每类事件分门别类的放好. 安排!
第六版: 对第五版的代码进行增强, 提供命名空间的能力 第六版, 其实看起来有点多, 其实就是增加了一个 create 还好还好, 如果觉得比较困难你可以收藏, 未来再回来看会好很多.
- // todo 为了使发布订阅模式更加适用. 我们要对上个版本的发布订阅模式进行增强. 提供命名空间的能力. 更好的管理每个发布订阅.
- var Event = (function () {
- // 兼容各个平台, 因为 broswer 的 global 是 Windows, 而 node.JS 的是 global
- var global = this,
- Event,// 初始化挂载点
- _default = 'default';// 初始化命名空间
- Event = function () {
- // 初始化 Event 各个方法: 监听, 触发, 移除
- var _listen,
- _trigger,
- _remove,
- // 初始化工具方法
- _slice = Array.prototype.slice,
- _shift = Array.prototype.shift,
- _unshift = Array.prototype.unshift,
- // 初始化命名空间缓存
- namespaceCache = {},
- // 初始化以命名空间作为 event 的方法
- _create,
- // ! 这个 find 就很迷了, 不知道什么作用, 求各位大佬解答
- find,
- // 自建迭代器
- each = function ( ary, fn ) {
- var ret;
- for(var i = 0, l = ary.length; i <l ; i++) {
- var n = ary[i];
- ret = fn.call(n, i, n);
- }
- return ret;
- };
- // 监听: 如果这个监听的名称在监听缓存中不存在, 那么, 初始化, 并且把该监听事件存入 cache[key]数组中.
- _listen = function(key, fn, cache) {
- if( !cache [key]){
- cache[key] = []
- }
- cache[key].push(fn);
- };
- // 移除: 首先判断监听缓存队列中是否存在对应的记录, 如果存在, 在对应的 cache[key]数组中删除对应的监听事件.
- _remove = function (key, cache, fn) {
- if(cache[key]){
- if(fn){
- for(var i = cache[key].length; i>= 0; i--) {
- if(cache[key] == fn) {
- cache[key].splice(i, 1);
- }
- }
- }else{
- cache [key] = [];
- }
- }
- };
- // 触发: 取出 cache 队列, 迭代队列, 触发事件
- _trigger = function () {
- var cache = _shift.call(arguments),// 取出 cache 队列
- key = _shift.call(arguments),// 取出对应的 key, 像 "click"
- args = arguments,// 经过以上两步, 剩下的只有入参了
- _self = this,// 在这一步, 获取 this, 也就是 Event 对象本身
- ret,
- // 获得触发栈, 也就是之前使用 listen 设置的监听事件
- stack = cache[key];
- if(!stack || !stack.length ) {
- return;
- }
- return each(stack, function (){
- // 此时 this 指向 stack 中每个匿名函数
- return this.apply(_self, args);
- });
- };
- // 创建命名空间的方法
- _create = function (namespace) {
- // 给命名空间设定默认值
- var namespace = namespace || _default;
- // 初始化 cache 和离线栈
- var cache = {},
- offlineStack = [],
- // 这个 ret 最后会挂载到命名空间 (namespaceCache) 的缓存中
- ret = {
- listen: function (key, fn, last) {
- _listen(key, fn, cache);
- if(offlineStack == null) {
- return;
- }
- if(last == 'last') {
- offlineStack.length && offlineStack.pop()();
- }else{
- each(offlineStack, function () {
- this()
- })
- }
- offlineStack = null;
- },
- one: function (key, fn, last) {
- _remove (key, cache);
- this.listen(key, fn, last);
- },
- remove: function (key, fn) {
- _remove(key, cache, fn);
- },
- trigger: function () {
- var fn,
- args,
- _self = this;
- _unshift.call(arguments, cache);
- args = arguments;
- fn = function() {
- return _trigger.apply(_self, args);
- };
- if(offlineStack) {
- return offlineStack.push(fn);
- }
- return fn();
- }
- };
- // 使用命名空间时的返回
- return namespaceCache ? (namespaceCache[namespace] ? namespaceCache [namespace] : namespaceCache[namespace] = ret) :ret;
- };
- return {
- // 使用全局 Event 时的返回
- create: _create,
- one: function (key, fn, last) {
- var event = this.create();
- event.one = (key, fn, last);
- },
- remove: function (key, fn) {
- var event = this.create();
- event.remove(key, fn);
- },
- listen: function (key, fn, last) {
- var event = this.create();
- event.listen(key, fn, last);
- },
- trigger: function () {
- var event = this.create();
- event.trigger.apply(this, arguments);
- }
- };
- }();
- return Event;
- }())
- // 使用范例
- // 先发布后订阅
- Event.trigger('click', 1);
- Event.listen('click', function (a) {
- console.log(a)
- });
- // 使用命名空间, 让各个订阅事件整洁有序
- Event.create('namespace1').listen('click', function (a) {
- console.log(a)
- });
- Event.create('namespace1').trigger('click', 1);
- Event.create('namespace2').listen('click', function (a) {
- console.log(a)
- });
- Event.create('namespace2').trigger('click', 1);
(完)
OK, 不知道大家感觉怎么样. 如果你看到了这里. 谢谢你. 我认为自己做的事情有价值, 能给大家带来帮助就会让我很有成就感.
稍微横向扩展一下, 发布订阅模式在 JS 这门语言中用在很多地方: node.JS 的事件驱动模型以及 vue 中的自定义事件, 在我看来, 都使用了发布订阅这种思想
篇幅有限, 周末还有半天, 以上两点就不继续写下去了.
另外, 掘金社区的各位大佬, 感谢批评指正. 希望得到一些正向, 中肯的评价. 感谢各位大佬.
参考: 《JavaScript 设计模式与开发实践》
来源: https://juejin.im/post/5c15df7ef265da613f2f5f9c