前言: 从八月份入职以来,在可以保证项目进度后,我便开始思考,怎么把事情做得更好,怎么提升自己。
- 一方面,提升自己对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是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设计模式与开发实践》