w字总结《JavaScript设计模式与开发实践》(设计原则和编程技巧)

设计原则和编程技巧

单一职责原则(SRP)

SRP原则体现为:一个对象(方法)值做一件事情。

设计模式中的SRP

代理模式,迭代器模式,单例模式,装饰者模式都运用到了SRP

代理模式

以图片预加载为例,我们添加虚拟代理来执行预加载图片,本体仅仅负责向页面中添加img标签,这也是他最原始的责任。

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
// 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('加载中图片路径')
img.src= src
}
}
})()

proxyImage.setSrc('图片路径')

迭代器模式

有这样一段代码,先遍历一个集合,然后往页面中添加一些 div,这些 div 的 innerHTML分别对应集合里的元素:

1
2
3
4
5
6
7
8
9
var appendDiv = function(data) {
for(var i = 0,l = data.legnth;i < l;i++) {
var div = document.createElement('div')
div.innerHTML = data[i]
document.body.appendChild(div)
}
}

appendDiv([1,2,3,4,5,6])

appendDiv本来只负责渲染数据,但这里他还承担了遍历对象data的职责,如果有一天返回的data格式错误,那么代码会出现问题,需要修改appendDiv内部代码,才能保证功能的正常使用。所以我们需要将遍历过程提取出来,这也是迭代器模式的意义所在,它提供一种方法来访问聚合对象,又不暴露对象的内部表示。

把迭代聚合对象的职责单独封装在each中,即使以后要增加新的迭代方式,我们只需要修改each,无需改动appendDiv。

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
var each = function(obj, callback) {
var value,
i = 0,
length = obj.length,
isArray = isArraylike(obj) // 未实现isArrayLike,可自行实现。

if(isArray) { // 迭代类数组
for(;i < length; i++) {
callback.call(obj[i],i,obj[i])
}
} else {
for(i in obj) {
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} );

单例模式

实现一个惰性单例:

1
2
3
4
5
6
7
8
9
10
11
12
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
}
})

我们把管理单例的职责和创建登录浮窗的职责封装在两个方法中,两者独立变化互不影响,连接起来完成创建唯一登录窗口的功能:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
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

装饰者模式

使用装饰者模式时,通常让类或对象一开始只具有基本职责,更多的职责在代码运行时被动态的装饰到对象上,从另一个角度看,这也是分离职责的一种方式。

何时应该分离职责

SRP 原则是所有原则中最简单也是最难正确运用的原则之一。要明确的是,并不是所有的职责都应该一一分离。一方面,如果随着需求的变化,有两个职责总是同时变化,那就不必分离他们。比如在ajax请求的时候,创建xhr对象和发送xhr请求几乎总是在一起的,那么创建 xhr 对象的职责和发送xhr请求的职责就没有必要分开。
另一方面,职责的变化轴线仅当它们确定会发生变化时才具有意义,即使两个职责已经被耦合在一起,但它们还没有发生改变的征兆,那么也许没有必要主动分离它们,在代码需要重构的时候再进行分离也不迟。

SRP的优缺点

SRP 原则的优点是降低了单个类或者对象的复杂度,按照职责把对象分解成更小的粒度,这有助于代码的复用,也有利于进行单元测试。当一个职责需要变更的时候,不会影响到其他的职责。但SRP原则也有一些缺点,最明显的是会增加编写代码的复杂度。当我们按照职责把对象分解成更小的粒度之后,实际上也增大了这些对象之间相互联系的难度。

最少知识原则(LKP)

一个软件实体应当尽可能少地与其他实体发生相互作用。这里的软件实体是一个广义的概念,不仅包括对象,还包括系统、类、模块、函数、变量等。

减少对象之间的联系

最少知识原则要求我们在设计程序时,应当尽量减少对象之间的交互。如果两个对象之间不必彼此直接通信,那么这两个对象就不要发生直接的相互联系。常见的做法是引入一个第三者对象,来承担这些对象之间的通信作用。如果一些对象需要向另一些对象发起请求,可以通过第三者对象来转发这些请求。

设计模式中的LKP

  1. 中介者模式

通过增加一个中介者对象,让所有的相关对象都通过中介者对象来通信,而不是互相引用。所以,当一个对象发生改变时,只需要通知中介者对象即可。

封装在最少知识原则中的体现

封装在很大程度上表达的是数据的隐藏。一个模块或者对象可以将内部的数据或者实现细节隐藏起来,只暴露必要的接口API供外界访问。对象之间难免产生联系,当一个对象必须引用另外一个对象的时候,我们可以让对象只暴露必要的接口,让对象之间的联系限制在最小的范围之内。

开放-封闭原则(OCP)

当需要改变一个程序的功能或者给这个程序增加新功能的时候,可以使用增加代码的方式,但是不允许改动程序的源代码。

利用对象的多态性消除条件分支

过多的条件分支语句是造成程序违反开放-封闭原则的常见原因。每当需要添加新的if语句时都需要修改原函数,switch-case也是同样的道理,对于某些条件分支语句,我们可以通过对象的多态重构他们。这里通过一个例子来说明。

我们实现一个让动物发出叫声的例子,首先提供一段不符合原则的代码,每当我们增加一种动物,就需要修改函数的内部实现

1
2
3
4
5
6
7
8
9
10
11
12
13
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())

如果现在需要添加一个狗,那必须修改makeSound的内部实现才可以,现在我们利用多态的思想,将程序中不变的部分分离出来(动物叫),将可变的部分封装(不同动物叫声不同),这样程序变得可扩展,想要添加狗叫时无需去修改makeSound函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
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())

其他帮助遵守开放-封闭原则的代码

  1. hook

hook也是分离变化的方式,我们在程序可能变化的地方添加钩子函数,钩子的结果决定了程序的下一步执行,这样给代码提供了多种执行结果的可能性。

  1. 回调函数

回调函数是一种特殊的hook,我们可以将一部分易于变化的逻辑封装在回调函数中,将回调当做参数传入一个稳定和封闭的函数中,回调函数真心很时,程序因为回调函数内部逻辑不通而产生不同结果。

设计模式中的开放-封闭原则

  1. 发布-订阅模式
  2. 模板方法模式
  3. 策略模式
  4. 代理模式
  5. 职责链模式

接口和面向接口编程

这里的接口定义是对象能响应的请求的集合。并不是我们常说的一个库或模块对外提供的api接口。

Java中的抽象类和interface的作用无非两点:

  • 通过向上转型隐藏对象的真正类型,表现对象的多态性。
  • 约定类与类之间的契约行为。

在JavaScript中,类是一个相对模糊的概念,不需要利用两者给对象进行“向上转型”。除了基本类型外,其他对象都可以被看做“天生”被“向上转型”成Object类型,所以在编写代码时,如果忽略了检查参数类型或检查实现方法则会有可能在代码中留下隐藏的bug。

1
2
3
4
5
function show( obj ){ 
obj.show(); // Uncaught TypeError: undefined is not a function
}
var myObject = {}; // myObject 对象没有 show 方法
show( myObject );

这时我们需要对show方法添加防御性代码,以保证代码可用。

1
2
3
4
5
function show( obj ){ 
if ( obj && typeof obj.show === 'function' ){
obj.show();
}
}

代码重构

提炼函数

如果代码中有代码可以被独立出来,那么最好将其放在另一个独立的函数中,这很常见,这样的好处有以下几点:

  • 避免出现超大函数
  • 独立出来的函数有助于代码复用
  • 独立出来的函数容易被覆写
  • 独立出来的函数如果拥有良好命名,则其本身即为一个良好的注释

合并重复的条件片段

函数内的一些条件分支语句很容易散布重复代码,有必要进行一些合并去重工作,下面是一个分页函数的例子。

1
2
3
4
5
6
7
8
9
10
11
var paging = function( currPage ){ 
if ( currPage <= 0 ){
currPage = 0;
jump( currPage ); // 跳转
}else if ( currPage >= totalPage ){
currPage = totalPage;
jump( currPage ); // 跳转
}else{
jump( currPage ); // 跳转
}
};

很容易可以发现,jump函数在每个分支内都存在,可以将它独立出来。

1
2
3
4
5
6
7
8
var paging = function( currPage ){ 
if ( currPage <= 0 ){
currPage = 0;
}else if ( currPage >= totalPage ){
currPage = totalPage;
}
jump( currPage ); // 把 jump 函数独立出来
};

条件分支语句提炼为函数

复杂的条件分支是导致程序代码难以阅读和理解的原因之一,且容易导致一个庞大的函数。举一个计算商品价格的例子:

1
2
3
4
5
6
7
var getPrice = function( price ){ 
var date = new Date();
if ( date.getMonth() >= 6 && date.getMonth() <= 9 ){ // 夏天
return price * 0.8;
}
return price;
};

观察函数中的条件分支语句,它的目的只是为了判断当前是否处于夏天,可以将其提炼成单独的函数,既能更准确表达意思,同事函数名本身又能起到注释的作用。

1
2
3
4
5
6
7
8
9
10
var isSummer = function(){ 
var date = new Date();
return date.getMonth() >= 6 && date.getMonth() <= 9;
};
var getPrice = function( price ){
if ( isSummer() ){ // 夏天
return price * 0.8;
}
return price;
};

合理使用循环

在函数体内,如果有些代码实际上负责的是一些重复性的工作,那么合理利用循环不仅可以完成同样的功能,还可以使代码量更少。

提前让函数退出代替嵌套条件分支

1
2
3
4
5
6
7
8
9
10
11
var del = function( obj ){ 
if ( obj.isReadOnly ){ // 反转 if 表达式
return;
}
if ( obj.isFolder ){
return deleteFolder( obj );
}
if ( obj.isFile ){
return deleteFile( obj );
}
};

传递对象参数代替过长的参数列表

当函数接收多个参数且数量过多时,我们需要明白所有参数的意义,并要小心是否有参数未传递或颠倒了位置,或我们想添加参数时需要修改大量代码。

我们可以将参数放在对象内,这样我们无需关心参数数量和顺序,只要保证参数对应的key不变即可。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
var setUserInfo = function( obj ){ 
console.log( 'id= ' + obj.id );
console.log( 'name= ' + obj.name );
console.log( 'address= ' + obj.address );
console.log( 'sex= ' + obj.sex );
console.log( 'mobile= ' + obj.mobile );
console.log( 'qq= ' + obj.qq );
};
setUserInfo({
id: 1314,
name: 'sven',
address: 'shenzhen',
sex: 'male',
mobile: '137********',
qq: 377876679
});

尽量减少参数数量

一个函数的参数过多是让人望而生畏的,我们需要搞清楚参数的含义,小心翼翼的传递,所以我们更喜欢不需要任何参数就能使用的函数,所以我们应该尽量减少参数的数量。

1
2
3
4
5
6
7
var draw = function( width, height, square ){};

// 实际上正方形的面积是可以通过 width 和 height 计算出来的,于是我们可以把参数 square从 draw 函数中去掉

var draw = function( width, height ){
var square = width * height;
};

少用三目运算符

三目运算符仅仅在代码量上可以胜过if-else,但却是以牺牲代码可读性和可维护性为前提的。如果条件分支逻辑简单清晰,我们可以使用三目运算符,但如果逻辑复杂,还是if-else更易于维护和阅读,比如下边的代码:

1
2
3
4
5
6
7
8
9
if ( !aup || !bup ) { 
return a === doc ? -1 :
b === doc ? 1 :
aup ? -1 :
bup ? 1 :
sortInput ?
( indexOf.call( sortInput, a ) - indexOf.call( sortInput, b ) ) :
0;
}

链式调用的好处与坏处

jQuery中可以通过链式调用非常方便的实现需求,且在阅读上不会有很多困难,又能剩下很多的字符以及中间变量,但如果有一条链中有错误出现,我们需要将链拆开并加上断点才能定位错误。

用return退出多重循环

假设在函数体内有一个两重循环语句,我们需要在内层循环中判断,当达到某个临界条件时退出外层的循环。我们大多数时候会引入一个控制标记变量:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
var func = function(){ 
var flag = false;
for ( var i = 0; i < 10; i++ ){
for ( var j = 0; j < 10; j++ ){
if ( i * j >30 ){
flag = true;
break;
}
}
if ( flag === true ){
break;
}
}
};

第二种做法是设置循环标记:

1
2
3
4
5
6
7
8
9
10
11
var func = function(){ 
outerloop:
for ( var i = 0; i < 10; i++ ){
innerloop:
for ( var j = 0; j < 10; j++ ){
if ( i * j >30 ){
break outerloop;
}
}
}
};

这两种做法无疑都让人头晕目眩,更简单的做法是在需要中止循环的时候直接退出整个方法:

1
2
3
4
5
6
7
8
9
var func = function(){ 
for ( var i = 0; i < 10; i++ ){
for ( var j = 0; j < 10; j++ ){
if ( i * j >30 ){
return;
}
}
}
};

当然用 return 直接退出方法会带来一个问题,如果在循环之后还有一些将被执行的代码呢?
如果我们提前退出了整个方法,这些代码就得不到被执行的机会:

1
2
3
4
5
6
7
8
9
10
var func = function(){ 
for ( var i = 0; i < 10; i++ ){
for ( var j = 0; j < 10; j++ ){
if ( i * j >30 ){
return;
}
}
}
console.log( i ); // 这句代码没有机会被执行
};

为了解决这个问题,我们可以把循环后面的代码放到 return 后面,如果代码比较多,就应该把它们提炼成一个单独的函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
var print = function( i ){ 
console.log( i );
};
var func = function(){
for ( var i = 0; i < 10; i++ ){
for ( var j = 0; j < 10; j++ ){
if ( i * j >30 ){
return print( i );
}
}
}
};
func();

最后

EDG夺冠flag实现了1/4,第一本书结束了,这本书中的例子和讲解都做的非常好,文章中并没有将其全部提现出来,值得多读几遍细细品味,浅显易懂的语言风格中夹杂着深奥的编程思想,值得入手一本。


w字总结《JavaScript设计模式与开发实践》(设计原则和编程技巧)
https://moewang0321.github.io/2021/11/22/w字总结《JavaScript设计模式与开发实践》(设计原则和编程技巧)/
作者
Moe Wang
发布于
2021年11月22日
许可协议