js进阶-设计模式: 发布订阅模式

2,520 阅读10分钟

前言: 从八月份入职以来,在可以保证项目进度后,我便开始思考,怎么把事情做得更好,怎么提升自己。

  • 一方面,提升自己对javascript这门语言的理解,我在udemy上买了 JavaScript: Understanding the Weird Parts.中文翻译过来,就是javascript: 理解怪异的部分。很经典,我推荐每个越过了基础这道坎的人去看一下这部分内容。我也买了书,之后计划对每一章进行解读。
  • 另一方面,我明白了js是一门编程语言,是工具。那么工具的用法是有很多种的。在不同的场景,使用不同的方法去处理,会让你开发速度事半功倍。也可以提升自己对问题不同的解决方案。所以我阅读了《javascript设计模式与开发实践》,想知道更好的组织代码的形式是怎样,在同一场景下,别人是怎么处理问题的。
  • 对于个人提升方面,可以单独拿一篇来探讨了。鉴于篇幅,只说两点。

正文开始

什么是发布订阅模式

发布订阅模式称观察者模式,它定义对象间的一种一对多的依赖关系,当一个对象状态改变的时候,所有依赖于它对象都将得到通知.

有点绕哈。其实说得简单一点。你关注了我,我更新了文章,你会得到推送,就这个意思。 其实在日常的开发中,你一直在使用着发布订阅模式进行开发。最常见的例子是,原生事件API。(也就是鼠标点击/移动/进入等事件,就是使用了发布订阅模式)

来,举个栗子


// 订阅
document.body.addEventListener('click', function() {
	alert(2);
});
// 触发事件发布
document.body.click();

在发布订阅模式中,有两个对象,一个是事件的发布者,一个是订阅者。

好啦,回答我一个问题,然后继续看下去:

  1. 在例子中,谁是发布者?
  2. 在例子中,谁是订阅者?

假设你答出来了,OK,那么接下来很容易理解。如果没有,那没关系,先看答案: * 发布者是document.body * 订阅者是我们 我们订阅了在document.body上的click事件,当用户点击了body,那么会触发click事件,body节点向用户也就是我发送信息(alert). 使用这个模式还有个优点是:

我们可以随意的增加或者删除事件,这对订阅者不会产生任何影响。

实现发布订阅模式

在我们理解了发布者和订阅者的关系后,来完成一个官方实例: 假设,现在有一个售楼处, 售楼处作为发布者,而买家作为订阅者。当价格变动的时候,售楼处把价格信息推送给订阅者。

// 实现一个发布订阅的步骤

  1. 指定好发布的对象是谁?
  2. 给发布者一个缓存队列,存放回调函数以便通知订阅者。
  3. 发布消息遍历这个缓存队列,以此触发里面存放的订阅者回调函数。(符合条件的就进行触发)

第一版

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是window, 而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设计模式与开发实践》