javascript设计模式(三)

发布-订阅模式

其实这个模式使用频率很多,之前楼主写react的时候,就遇到过这么个场景,一个页面中涉及的组件有:

  • form part(一堆筛选控件,如一级二级select,datepicker,地理位置选择器等等)
  • slider嵌套的多个card,card内部又有统计数据部分和echarts图表部分,随着formpart的变动而更改。

card中的数据变动不定,而且里面有很多子组件,最开始的时候通信是用props一层层的传递下去,这样遇到两个问题:

  • 组件嵌套过深时,通过props传递时,中间组件由于中介作用需要传递很多中间数据
  • 从grandparent传递下来,使得很多实际并没有变化的card也进行了渲染。(嗯,可以用should component update检测,这是后话)。

中途进行了一个重构就是引入了event-bus.每个card 注册监听事件,监听ajax的success中数据的返回(包括应该变更的card ID),如果是自己该更新,那么就render。而那些无需变动的card选择忽略就好。

后来写的vue项目中,(1.0版本的时候还有$broadcast$dispatch等等),2.0版本中只有$emit,在父组件中注册监听事件,(有点点像只有一层的props传递函数)。
除此之外,还有watch方法,computed计算属性等等,都是发布-订阅模式的一个实现。

实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
var event = {
observers: [],
listen:function(eventName,fn){
if(!this.observers[eventName]) this.observers[eventName] = [];
this.observers[eventName].push(fn);
},
trigger:function(){
var eventName = Array.protptype.shift.call(arguments),
fnArr = this.observers[eventName];
if(!fnArr|| fnArr.length===0) return false;
for(var i=0;i<fnArr.length;i++) fn.apply(this,arguments);
}
}
var installEvent = function(obj){
for(var i in evnt){
obj[i] = event[i];
}
}
var salsesOffice = {};
installEvent(salsesOffice);
salsesOffice.listen('eventName',function(v){
console.log(" person1 is listening ",v);
})
salsesOffice.trigger('eventName','test');

取消订阅的话,只需要在fnsArr里面splice即可。
这边的observer是function。
其他的case有很多,上面我们已经说过了,书中提到的例子有网站登录

上面的方法还有一个小问题是,在observer代码里,需要知晓它监听的对象,即login。那么有一种方法是建立一个全局的事件对象,类似于中介者,去进行事件的统筹分配。订阅者不需要了解消息来自哪个发布者,发布者也不知道消息会推送给哪些订阅者,Event作为一个类似“中介者”的角色,把订阅者和发布者联系起来。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
//全局Event对象
var Event = (function(){
var observers = {},//{'eventName1':[fn1,fn2],'eventName2':[watcher1,watcher2]}
listen,trigger,remove;
//三个函数的定义
//...
return{
listen: listen,
trigger: trigger,
remove: remove
}
})();
Event.listen('eventName1',fn1);
Event.trigger('eventName1',param1);
Event.remove('eventName1',fn1);

全局event对象的一个不足之处是会造成命名冲突,因此可以添加namespace,具体代码见书。

后话是,楼主用event重构的react的项目后来又换回去了一部分,因为一是性能问题可以用shouldcomponentupdate来解决,另一方面,大量的event不好追踪一个事件的双方,那么模块与模块之间的联系就被隐藏到了背后。我们最终会搞不清楚消息来自哪个模块,或者消息会流向哪些模块,感觉一个代码里充斥着越过组件的攻击曲线..=.=

作用

问: 跟加了回调的代理模式有啥区别?

  • 可以不再显式调用某一个函数,达到松散耦合,比如说,之前在登录成功的回调里面处理header,nav的信息,当模块多了的话会要不停修改login成功回调,现在不需要动登陆成功的回调函数,只要各自监听就好,可以不断添加observer。
  • 而且,局部订阅者模式是在某一方加了接口,而代理是在两方基础上添加一个中间处理方:一种是保护,另一种是多做一些性能的额外处理,比如合并请求,预加载,懒加载等等。那中介者模式呢…

其他

  • 符合“好莱坞原则”
  • 离线消息的存在必要性: 在很多情况下,并不是严格按照订阅-发布的时间顺序,比如ajax回调中会trigger一个事件,但是trigger事件的时候,可能没有订阅者,或者订阅者还没有准备好,那么他们就无法捕获到这个事件的发生,就会遗漏事件,因此有必要将暂时还没有接受者的事件存储下来,当出现订阅者时发送给他们,执行一次即可,阅后即焚…

中介者模式

当对象变多时,他们之间的关系错综复杂,中介者模式就是为了解除对象之间的紧密耦合关系而存在的,应用以后对象之间都通过中介者通信,互相不透镜,使得多对多关系变为比较简单的一对多关系。

其实之前在观察者模式中我们也涉及到了中介者模式,就是最后的全局Event对象,它对对象之间的注册触发进行了统筹。

既然放一起了,就做个区分吧:

观察者模式最朴素的是A,B之间进行处理,A需要知道B的存在,就是多对多的关系。
改进一点的方法是用中间的全局Event对象去进行管理。仔细想一下的话,中介者模式是为了观察者模式做服务的,它只是用来处理双方之间的关系,而观察者模式是用来解决消息通知问题的,中介者是为了观察者模式更好的进行而存在的。

盗图一张…

书中的一个例子是玩游戏,设用户为A,B,C…添加一个中间人,每次A,B等有操作时通知中间人。

1
2
3
4
5
6
7
8
9
10
11
12
13
/**
* 中介者模式的话,需要各对象在操作时通知中介者
* @return {[type]} [description]
*/
Player.prototype.die = function() {
this.state = 'dead';
playerDirector.ReceiveMessage('playerDead', this); // 给中介者发送消息,玩家死亡,this告知中介者当前对象
}
/*******************移除玩家*****************/
Player.prototype.remove = function(){
playerDirector.ReceiveMessage( 'removePlayer', this ); // 给中介者发送消息,移除一个玩家};
}

上面的代码是对象通知中介者,那中介者收到这些消息后进行处理,会将信息发送给各对象,这边可以遍历,调用对象的方法。

1
2
3
4
5
6
//中介者通知其他人
if (all_dead === true) { // 全部死亡
for ( var i = 0, player; player = teamPlayers[ i++]; ){
player.lose(); // 本队所有玩家lose }
}
}

而关于这个中介者的实现上,书中提到了两种方法:
>

利用发布—订阅模式。将playerDirector实现为订阅者,各player作为发布者,一旦player的状态发生改变,便推送消息给playerDirector,playerDirector处理消息后将反馈发送给其他player。
在playerDirector中开放一些接收消息的接口,各player可以直接调用该接口来给playerDirector发送消息,player只需传递一个参数给playerDirector,这个参数(书中为 this ,即当前对象)的目的是使playerDirector可以识别发送者。同样,playerDirector接收到消息之后会将处理结果反馈给其他player。

中介者模式是迎合迪米特法则的一种实现。迪米特法则也叫最少知识原则,是指一个对象应该尽可能少地了解另外的对象(类似不和陌生人说话)。

【summary】中介者模式总结来说,是将耦合转嫁到一个中间人去管理这些,类似于“上帝之手”,解决的是对象之间的关系。


装饰器模式

在程序开发中,许多时候都并不希望某个类天生就非常庞大,一次性包含许多职责。那么我们就可以使用装饰者模式,不停的包装对象,生成一个又一个嵌套的对象,每次都增加一丢丢功能,类似于俄罗斯套娃。

装饰者模式可以动态地给某个对象添加一些额外的职责,而不会影响从这个类中派生的其他对象。

这种功能通常可以用继承来做,但是存在一点问题,就是:

  • 会造成父类和子类的强耦合,父类改变时,子类会随之变动
  • 当功能增多是,会出现大量的子类

而装饰器模式能够在不改变对象自身的基础上,在程序运行期间给对象动态地添加职责。跟继承相比,装饰者是一种更轻便灵活的做法。

JavaScript天然可以增加属性/职责,如下:
obj.address = obj.address +'福田区'

示例

1
2
3
4
5
6
7
8
9
10
11
//原始
var plane = {
fire: function(){
console.log( '发射普通子弹' );
}
}
var _fire = plane.fire;
plane.fire = function(){
_fire();
someOtherFun();
}

这种方法最简单粗暴,直接改写了原有方法(覆盖),增加了中间变量_fire(一定要增加…否则就是递归调用了=。=)。
而且有时候会遇到this被劫持的问题,比如document.getElementById中的this.
正确的写法如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
Function.prototype.before = function(beforeFn){
var fn = this;
return function () {
beforeFn.apply(this,arguments);
return fn.apply(this,arguments)
}
}
var test = function(param){
console.log('test',param);
}
var after = test.before(function(param2){
console.log('before ',param2);
});
after('final result');

还有一个validate的部分,在策略模式那边添加了部分代码,可以走过路过看一下:)

装饰器模式与代理模式

  • 代理模式通常只有一层代理-本体的引用,而装饰者模式经常会形成一条长长的装饰链。
  • 装饰器模式与代理模式都是为本体添加功能,但是前者是真正添加额外功能,而后者是将原有功能优化,如性能等

适配器模式

这种模式相对简单,主要是针对接口的兼容修补工作,使得A,B两个对外呈现的接口一致。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
var googleMap = {
show: function() {
console.log('开始渲染谷歌地图');
}
};
var baiduMap = {
display: function() {
console.log('开始渲染百度地图');
}
};
//here display-> show兼容
var baiduMapAdapter = {
show: function(){
return baiduMap.display();
}
};

####区分

装饰器模式会形成长长的包装链、代理和适配器只包装一次。
代理模式是不变功能,只是功能更加聪明,性能更好,装饰器是增加功能,适配器模式是改外部接口,不涉及内部功能。

享元模式

常用于性能优化,如果项目中有大量的相似对象,可以把他们的状态抽取成可以共用的内部状态外部的场景状态

如书中介绍到的内衣工厂,model对象的属性为性别,整个项目中设置两个对象,男女模特,然后衣服设置为外部状态。

1
2
3
4
5
6
7
8
9
10
var Model = function( sex ){
this.sex = sex;
};
Model.prototype.takePhoto = function(){
console.log( 'sex= ' +this.sex +' underwear=' +this.underwear);
};
for ( var j = 1; j <= 50; j++){
femaleModel.underwear = 'underwear' +j; //外部状态
femaleModel.takePhoto();
};

通常来说,内部状态有多少种组合,系统中就有多少个对象。

另一个case就是文件上传(敲黑板,之前文件上传的时候我们用过迭代器模式)
可以看到之前是每个文件对象里面包括了这么多属性

那么用享元模式的话要把这些属性分类,抽取出“上传类型”这个内部属性,其他的都归结为外部属性
这边借助闭包实现一个共享的对象(也可以认为是一个单例模式的粗陋实现)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
var Upload = function(uploadType) {
this.uploadType = uploadType;
};
var UploadFactory = (function() {
var createdFlyWeightObjs = {};
return {
create: function(uploadType) {
if (createdFlyWeightObjs[uploadType]) {
return createdFlyWeightObjs[uploadType];
}
return createdFlyWeightObjs[uploadType] = new Upload(uploadType);
}
}
})();
var uploadManager = (function() {
var uploadDatabase = {};
return {
add: function(id, uploadType, fileName, fileSize) {
//生成对象,+ 外部状态放到一个database里面去,
var flyWeightObj = UploadFactory.create(uploadType);
//...
uploadDatabase[id] = {
fileName: fileName,
fileSize: fileSize,
dom: dom
};
return flyWeightObj;
},
setExternalState: function(id, flyWeightObj) {
//给单利对象挂靠外部属性,其实是替换外部属性
// internalObj =
// {
// uploadType: 'html5', //不变的部分
//
// filename
// filesize
// dom //若干个对象各自有的,在循环中是不断替换的
//
// }
var uploadData = uploadDatabase[id];
for (var i in uploadData) { flyWeightObj[i] = uploadData[i]; }
}
}
})();

除此之外,需要注意的是,享元状态可以只有外部状态(内部状态只有一个),这个时候实际上就是一个单例模式。

另,还有一种不区分内外状态的对象池,可以用于DOM节点的回收和创建。


状态模式

之前我们说过利用鸭子类型来避免if-else的反复code,是将接口/函数的不同转变为对象的多态性,这边要介绍的状态模式则是把状态封装成类,状态中的同一接口函数定义了状态的切换。

每种状态都封装成单独的类,跟此种状态有关的行为都被封装在这个类的内部,每次状态变化时把请求委托给当前的状态对象即可,该状态对象会负责渲染它自身的行为。

调用的时候直接是

1
2
this.obj.setState(someState)
this.curState.someAction()

也就是说每个状态类需要实现同样的接口,由于跟模板方式中我们介绍过的一样,JavaScript直到执行时才会报错,(throw error)。

其实想一下,Vue或者react中就有状态模式的概念,react中直接就是state对象,每次都去setstate切换状态,去重新渲染,切换状态后又会有新的行为,就是这个状态下的操作。
Vue中对应的大概就是data对象了,只不过这个状态模式我们感受的不太明显,地表以上对开发人员透明的感觉。

状态模式与策略模式

  • 策略模式中的各个策略类之间是平等又平行的,它们之间没有任何联系,之间的切换是由用户手动操作的。
  • 状态模式中,状态和状态对应的行为是早已被封装好的,状态之间的切换也早被规定完成,被封装在了状态类内部,对客户来说,并不需要了解这些细节。这正是状态模式的作用所在

其他

设计原则:

  • 单一职责原则:代理模式、装饰器模式、迭代器模式(回调函数)、单例模式(通用的创建移到外部)
  • 最少知识原则(迪米特法则):应当尽量减少对象之间的交互。常见的做法是增加一个中间人,如中介者模式 ,去管理他们之间的关系。
    • 外观模式
    • 中介者模式
    • 作用域也是广义的最少知识原则的体现,限定在一个小区域内known
  • 开放封闭原则: 可扩展,但不允许修改
    • 对象的多态性代替分支
    • 在变化的地方放置hook,回调
    • 设计模式中的实践:
      • 发布-订阅模式(增加订阅者不需要改动发布者内部函数)
      • 模板方法模式:(增加子类)
      • 策略模式
      • 代理模式(本体实现基本的功能不动就好)
      • 职责链模式

前辈总结的这些设计原则通常指的是单一职责原则、里氏替换原则、依赖倒置原则、接口隔离原则、合成复用原则和最少知识原则。

代码编写时的建议:

  • 提炼函数
  • 合并重复的条件片段
  • 分支条件语句提炼成函数便于调试
  • 合理使用循环(迭代器模式)代替if-else
  • 提前让函数return (如果有多个if-else的haul)
  • 使用object作为参数来代替多个参数(因为会有顺序的问题,当然es6的话还可以解构)
  • 尽量减少参数数量
  • 多条件嵌套时少用三目运算符
  • 分解大型类

to distinguish

  • [ ] 发布-订阅者模式、中介者模式
  • [ ] 装饰器模式、适配器模式、代理模式
  • [ ] 享元模式、状态模式
  • [ ] 模板方法模式与装饰器模式