三. 设计原则和编程技巧
3.1 单一职责原则(SRP)
SRP 原则体现为:一个对象(方法)只做一件事情;
单一职责原则指的是,就一个类(通常也包括对象和函数等)而言,应该仅有一个引起它变化的原因。如果一个对象承担了多项职责,就意味着这个对象将变得巨大,引起它变化的原因可能会有多个。面向对象设计鼓励将行为分布到细粒度的对象之中,如果一个对象承担的职责过多,等于把这些职责耦合到了一起,这种耦合会导致脆弱和低内聚的设计。当变化发生时,设计可能会遭到意外的破坏;
3.1.1 设计模式中的 SRP 原则
SRP 原则在很多设计模式中都有着广泛的运用,例如代理模式、迭代器模式、单例模式和装饰者模式。
- 代理模式:图片预加载示例,增加虚拟代理的方式,把预加载图片的职责放到代理对象中,而本体仅仅负责往页面中添加 img 标签,这也是它最原始的职责;
// myImage 负责往页面中添加 img 标签:
var myImage = (function(){
var imgNode = document.createElement( 'img' );
document.body.appendChild( imgNode );
return {
setSrc: function( src ){
imgNode.src = src;
}
}
})();
// proxyImage 负责预加载图片,并在预加载完成之后把请求交给本体 myImage:
var proxyImage = (function(){
var img = new Image;
img.onload = function(){
myImage.setSrc( this.src );
}
return {
setSrc: function( src ){
myImage.setSrc( 'file:// /C:/Users/svenzeng/Desktop/loading.gif' );
img.src = src;
}
}
})();
proxyImage.setSrc( 'http:// imgcache.qq.com/music/photo/000GGDys0yA0Nk.jpg' );
- 迭代器模式,实例:先遍历一个集合,然后往页面中添加一些 div,这些 div 的 innerHTML 分别对应集合里的元素;
// a. 基本实现
var appendDiv = function( data ){
for ( var i = 0, l = data.length; i < l; i++ ){
var div = document.createElement( 'div' );
div.innerHTML = data[ i ];
document.body.appendChild( div );
}
};
appendDiv( [ 1, 2, 3, 4, 5, 6 ] );
// b. SRP 原则实现
var each = function( obj, callback ) {
var value, i = 0, length = obj.length, isArray = isArraylike( obj ); // isArraylike 函数未实现,可以翻阅 jQuery 源代码
if ( isArray ) { // 迭代类数组
for ( ; i < length; i++ ) {
callback.call( obj[ i ], i, obj[ i ] );
}
} else {
for ( i in obj ) { // 迭代 object 对象
value = callback.call( obj[ i ], i, obj[ i ] );
}
}
return obj;
};
var appendDiv = function( data ){
each( data, function( i, n ){
var div = document.createElement( 'div' );
div.innerHTML = n;
document.body.appendChild( div );
});
};
appendDiv( [ 1, 2, 3, 4, 5, 6 ] );
appendDiv({a:1,b:2,c:3,d:4} );
- 单例模式:惰性单例创建唯一一个登录窗 div 的示例;
// a. 基本实现
var createLoginLayer = (function(){
var div;
return function(){
if ( !div ){
div = document.createElement( 'div' );
div.innerHTML = '我是登录浮窗';
div.style.display = 'none';
document.body.appendChild( div );
}
return div;
}
})();
// b. SRP 原则实现
var getSingle = function( fn ){ // 获取单例
var result;
return function(){
return result || ( result = fn .apply(this, arguments ) );
}
};
var createLoginLayer = function(){ // 创建登录浮窗
var div = document.createElement( 'div' );
div.innerHTML = '我是登录浮窗';
document.body.appendChild( div );
return div;
};
var createSingleLoginLayer = getSingle( createLoginLayer );
var loginLayer1 = createSingleLoginLayer();
var loginLayer2 = createSingleLoginLayer();
alert ( loginLayer1 === loginLayer2 ); // 输出: true
- 装饰者模式:装饰函数实现;
Function.prototype.after = function( afterfn ){
var __self = this;
return function(){
var ret = __self.apply( this, arguments );
afterfn.apply( this, arguments );
return ret;
}
};
3.1.2 何时应该分离职责
要明确的是,并不是所有的职责都应该一一分离。一方面,如果随着需求的变化,有两个职责总是同时变化,那就不必分离他们;另一方面,职责的变化轴线仅当它们确定会发生变化时才具有意义,即使两个职责已经被耦合在一起,但它们还没有发生改变的征兆,那么也许没有必要主动分离它们,在代码需要重构的时候再进行分离也不迟。
3.1.3 违反 SRP 原则
在常规思维中,总是习惯性地把一组相关的行为放到一起,如何正确地分离职责不是一件容易的事情。一方面,我们受设计原则的指导, 另一方面, 我们未必要在任何时候都一成不变地遵守原则;在方便性与稳定性之间要有一些取舍。具体是选择方便性还是稳定性,并没有标准答案,而是要取决于具体的应用环境。
3.1.4 SRP 原则的优缺点
SRP 原则的优点是降低了单个类或者对象的复杂度,按照职责把对象分解成更小的粒度,这有助于代码的复用,也有利于进行单元测试。当一个职责需要变更的时候,不会影响到其他的职责。但 SRP 原则也有一些缺点,最明显的是会增加编写代码的复杂度。当我们按照职责把对象分解成更小的粒度之后,实际上也增大了这些对象之间相互联系的难度。
3.2 最少知识原则(LKP)
最少知识原则( LKP):一个软件实体应当尽可能少地与其他实体发生相互作用;
3.2.1 减少对象之间的联系
最少知识原则要求在设计程序时,应当尽量减少对象之间的交互。如果两个对象之间不必彼此直接通信,那么这两个对象就不要发生直接的相互联系。常见的做法是引入一个第三者对象,来承担这些对象之间的通信作用。如果一些对象需要向另一些对象发起请求,可以通过第三者对象来转发这些请求。
3.2.2 设计模式中的最少知识原则
最少知识原则在设计模式中体现得最多的地方是中介者模式和外观模式,如下所示:
- 中介者模式:
如博彩公司示例,博彩公司作为中介,每个人都只和博彩公司发生关联,博彩公司会根据所有人的投注情况计算好赔率,彩民们赢了钱就从博彩公司拿,输了钱就赔给博彩公司。中介者模式很好地体现了最少知识原则,通过增加一个中介者对象,让所有的相关对象都通过中介者对象来通信,而不是互相引用。所以,当一个对象发生改变时,只需要通知中介者对象即可。
- 外观模式:主要是为子系统中的一组接口提供一个一致的界面,外观模式定义了一个高层接口,这个接口使子系统更加容易使用;
外观模式的作用是对客户屏蔽一组子系统的复杂性,外观模式对客户提供一个简单易用的高层接口,高层接口会把客户的请求转发给子系统来完成具体的功能实现。大多数客户都可以通过请求外观接口来达到访问子系统的目的,如果外观不能满足客户的个性化需求,那么客户也可以选择越过外观来直接访问子系统;
3.2.3 封装在最少知识原则中的体现
封装在很大程度上表达的是数据的隐藏。一个模块或者对象可以将内部的数据或者实现细节隐藏起来,只暴露必要的接口 API 供外界访问。对象之间难免产生联系,当一个对象必须引用另外一个对象的时候,我们可以让对象只暴露必要的接口,让对象之间的联系限制在最小的范围之内。把变量的可见性限制在一个尽可能小的范围内,这个变量对其他不相关模块的影响就越小,变量被改写和发生冲突的机会也越小。这也是广义的最少知识原的一种体现。
实例:具有缓存效果的计算乘积的函数;
var mult = (function(){
var cache = {};
return function(){
var args = Array.prototype.join.call( arguments, ',' );
if ( cache[ args ] ){
return cache[ args ];
}
var a = 1;
for ( var i = 0, l = arguments.length; i < l; i++ ){
a = a * arguments[i];
}
return cache[ args ] = a;
}
})();
mult( 1, 2, 3 ); // 输出: 6
3.3 开放封闭原则(OCP)
开放封闭原则( OCP):软件实体(类、模块、函数)等应该是可以扩展的,但是不可修改;
开放封闭原则的思想:当需要改变一个程序的功能或者给这个程序增加新功能的时候,可以使用增加代码的方式,但是不允许改动程序的源代码。
3.3.1 扩展 window.onload 函数
通过增加代码,而不是修改原代码的方式,来给 window.onload
函数添加新的功能,代码如下:
// 使用装饰函数实现函数功能扩展
Function.prototype.after = function( afterfn ){
var __self = this;
return function(){
var ret = __self.apply( this, arguments );
afterfn.apply( this, arguments );
return ret;
}
};
window.onload = ( window.onload || function(){} ).after(function(){
console.log( document.getElementsByTagName( '*' ).length );
});
3.3.2 用对象的多态性消除条件分支
过多的条件分支语句是造成程序违反开放封闭原则的一个常见原因,每当需要增加一个新的 if 语句时,都要被迫改动原函数。因此当一大片的 if-else
或者 swtich-case
语句时,第一时间考虑能否利用对象的多态性来重构代码,例如让动物发出叫声的例子,每增加一种动物,就需要改动 makeSound
函数的内部实现:
// a. 使用 if-else 基本实现
var makeSound = function( animal ){
if ( animal instanceof Duck ){
console.log( '嘎嘎嘎' );
}else if ( animal instanceof Chicken ){
console.log( '咯咯咯' );
}
};
var Duck = function(){};
var Chicken = function(){};
makeSound( new Duck() ); // 输出:嘎嘎嘎
makeSound( new Chicken() ); // 输出:咯咯咯
// b. 利用对象的多态性重构代码
var makeSound = function( animal ){
animal.sound();
};
var Duck = function(){};
Duck.prototype.sound = function(){
console.log( '嘎嘎嘎' );
};
var Chicken = function(){};
Chicken.prototype.sound = function(){
console.log( '咯咯咯' );
};
makeSound( new Duck() ); // 嘎嘎嘎
makeSound( new Chicken() ); // 咯咯咯
3.3.3 找出变化的地方
开放封闭原则是一个看起来比较虚幻的原则,并没有实际的模板教导怎样地实现它;但开发中能找到一些让程序尽量遵守开放封闭原则的规律,最明显的就是找出程序中将要发生变化的地方,然后把变化封装起来。通过封装变化的方式,可以把系统中稳定不变的部分和容易变化的部分隔离开来,在系统的演变过程中,只需要替换那些容易变化的部分,而稳定的部分是不需要改变的;
3.3.4 设计模式中的开放封闭原则
开放封闭原则在设计模式中应用很广泛,如之前的装饰者模式示例,还有发布订阅模式、模板方法模式、策略模式、代理模式、职责链模式,如下所示:
- 发布订阅模式
发布订阅模式用来降低多个对象之间的依赖关系,它可以取代对象之间硬编码的通知机制,一个对象不用再显式地调用另外一个对象的某个接口。当有新的订阅者出现时,发布者的代码不需要进行任何修改;同样当发布者需要改变时,也不会影响到之前的订阅者。
- 模板方法模式
模板方法模式是一种典型的通过封装变化来提高系统扩展性的设计模式,在一个运用了模板方法模式的程序中,子类的方法种类和执行顺序都是不变的,所以把这部分逻辑抽出来放到父类的模板方法里面;而子类的方法具体怎么实现则是可变的,于是把这部分变化的逻辑封装到子类中。通过增加新的子类,便能给系统增加新的功能,并不需要改动抽象父类以及其他的子类,这也是符合开放封闭原则的。
- 策略模式
策略模式和模板方法模式在大多数情况下可以相互替换使用,其中模板方法模式基于继承的思想,而策略模式则偏重于组合和委托。策略模式将各种算法都封装成单独的策略类,这些策略类可以被交换使用。策略和使用策略的客户代码可以分别独立进行修改而互不影响。我们增加一个新的策略类也非常方便,完全不用修改之前的代码。
- 代理模式
代理模式的图片预加载示例中,代理函数负责图片预加载,在图片预加载完成之后,再将请求转交给原来的 myImage
函数, myImage
在这个过程中不需要任何改动,预加载图片的功能和给图片设置 src 的功能被隔离在两个函数里,它们可以单独改变而互不影响。 myImage
不知晓代理的存在,它可以继续专注于自己的职责——给图片设置 src
属性;
- 职责链模式
职责链模式的订单示例中,当增加一个新类型的订单函数时,不需要改动原有的订单函数代码,只需要在链条中增加一个新的节点。
3.3.5 开放封闭原则的相对性
实际开发中,让程序保持完全封闭是不容易做到,并且有一些代码是无论如何也不能完全封闭的,总会存在一些无法对其封闭的变化。因此我们可以做到的有下面两点:
- 挑选出最容易发生变化的地方,然后构造抽象来封闭这些变化。
- 在不可避免发生修改的时候,尽量修改那些相对容易修改的地方。如一个开源库,修改它提供的配置文件,总比修改它的源代码来得简单。
系列链接
- JavaScript 设计模式(上)——基础知识
- JavaScript 设计模式(中)——1.单例模式
- JavaScript 设计模式(中)——2.策略模式
- JavaScript 设计模式(中)——3.代理模式
- JavaScript 设计模式(中)——4.迭代器模式
- JavaScript 设计模式(中)——5.发布订阅模式
- JavaScript 设计模式(中)——6.命令模式
- JavaScript 设计模式(中)——7.组合模式
- JavaScript 设计模式(中)——8.模板方法模式
- JavaScript 设计模式(中)——9.享元模式
- JavaScript 设计模式(中)——10.职责链模式
- JavaScript 设计模式(中)——11. 中介者模式
- JavaScript 设计模式(中)——12. 装饰者模式
- JavaScript 设计模式(中)——13.状态模式
- JavaScript 设计模式(中)——14.适配器模式
- JavaScript 设计模式(下)——设计原则
- JavaScript 设计模式练习代码
本文主要参考了《JavaScript设计模式和开发实践》一书