JavaScript设计原则和编程技巧——开放-封闭原则

学习曾探的 《JavaScript设计模式与开发实践》并做记录。

书籍的购买链接

设计模式的定义是:在面向对象软件设计过程中针对特定问题的简洁而优雅的解决方案。

通俗一点,设计模式是在某种场合下对某个问题的一种解决方案。是给面向对象软件开发中的一些好的设计取个名字。

说每种设计模式都是为了让代码迎合其中一个或多个原则而出现的,它们本身已经融入了设计模式之中,给面向对象编程指明了方向。

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

JavaScript设计原则和编程技巧——开放-封闭原则

在面向对象的程序设计中,开放封闭原则(OCP)是最重要的一条原则。很多时候,一个程序具有良好的设计,往往说明它是符合开放-封闭原则的

软件实体(类、模块、函数)等应该是可以扩展的,但是不可修改

扩展 window.onload 函数

假设我们是一个大型 Web 项目的维护人员,在接手这个项目时,发现它已经拥有 10 万行以上的 JavaScript 代码和数百个 JS 文件。

不久后接到了一个新的需求,即在 window.onload 函数中打印出页面中的所有节点数量。这当然难不倒我们了。于是我们打开文本编辑器,搜索出 window.onload 函数在文件中的位置,在函数内部添加以下代码:

1
2
3
4
window.onload = function(){ 
// 原有代码略
console.log( document.getElementsByTagName( '*' ).length );
};

在项目需求变迁的过程中,我们经常会找到相关代码,然后改写它们。这似乎是理所当然的事情,不改动代码怎么满足新的需求呢?想要扩展一个模块,最常用的方式当然是修改它的源代码。如果一个模块不允许修改,那么它的行为常常是固定的。然而,改动代码是一种危险的行为,也许我们都遇到过 bug 越改越多的场景。刚刚改好了一个 bug,但是又在不知不觉中引发了其他的 bug。

那么,有没有办法在不修改代码的情况下,就能满足新需求呢?

1
2
3
4
5
6
7
8
9
10
11
Function.prototype.after = function(afterfn){
const self = this;
return function(){
const ret = self.apply(this,arguments);
afterfn.apply(this,arguments);
return ret;
}
}
window.onload = (window.onload || function(){}).after(function(){
console.log(xxx);
});

通过动态装饰函数的方式,我们完全不用理会从前 window.onload 函数的内部实现,无论它的实现优雅或是丑陋。就算我们作为维护者,拿到的是一份混淆压缩过的代码也没有关系。只要它从前是个稳定运行的函数,那么以后也不会因为我们的新增需求而产生错误。新增的代码和原有的代码可以井水不犯河水。

开发和封闭

上一节为 window.onload 函数扩展功能时,用到了两种方式。一种是修改原有的代码,另一种是增加一段新的代码。使用哪种方式效果更好,已经不言而喻。

封闭-开发原则的思想:当需要改变一个程序的功能或者给这个程序增加新的功能的时候,可以使用增加代码的方式,但是不允许改动程序的源代码。

在现实生活中,我们也能找到一些跟开放封闭原则相关的故事。下面这个故事人尽皆知,且跟肥皂相关

有一家生产肥皂的大企业,从欧洲花巨资引入了一条生产线。这条生产线可以自动完成从原材料加工到包装成箱的整个流程,但美中不足的是,生产出来的肥皂有一定的空盒几率。于是老板又从欧洲找来一支专家团队,花费数百万元改造这一生产线,终于解决了生产出空盒肥皂的问题。

另一家企业也引入了这条生产线,他们同样遇到了空盒肥皂的问题。但他们的解决办法很简单:用一个大风扇在生产线旁边吹,空盒肥皂就会被吹走。

这个故事告诉我们,相比修改源程序,如果通过增加几行代码就能解决问题,那这显然更加简单和优雅,而且增加代码并不会影响原系统的稳定。讲述这个故事,我们的目的不在于说明风扇的成本有多低,而是想说明,如果使用风扇这样简单的方式可以解决问题,根本没有必要去大动干戈地改造原有的生产线。

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

过多的条件分支语句是造成程序违反开放-封闭原则的一个常见原因。每当需要增加一个新的 if 语句时,都要被迫改动原函数。把 if 换成 switch-case 是没有用的,这是一种换汤不换药的做法。实际上,每当我们看到一大片的 if 或者 swtich-case 语句时,第一时间就应该考虑,能否利用对象的多态性来重构它们。

利用对象的多态性来让程序遵守开放-封闭原则,是一个常用的技巧。用让动物发出叫声的例子。下面先提供一段不符合开放-封闭原则的代码。每当我们增加一种新的动物时,都需要改动makeSound 函数的内部实现

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 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 函数必须改成:
var makeSound = function( animal ){
if ( animal instanceof Duck ){
console.log( '嘎嘎嘎' );
}else if ( animal instanceof Chicken ){
console.log( '咯咯咯' );
}else if ( animal instanceof Dog ){ // 增加跟狗叫声相关的代码
console.log('汪汪汪' );
}
};
var Dog = function(){};
makeSound( new Dog() ); // 增加一只狗

// 改动后
const makeSound = function(animal){
animal.sound();
}
const Duck = function(){};
Duck.prototype.sound = function(){
console.log( '嘎嘎嘎' );
};
const Chicken = function(){};
Chicken.prototype.sound = function(){
console.log( '咯咯咯' );
};
makeSound( new Duck() ); // 嘎嘎嘎
makeSound( new Chicken() ); // 咯咯咯
/********* 增加动物狗,不用改动原有的 makeSound 函数 ****************/
var Dog = function(){};
Dog.prototype.sound = function(){
console.log( '汪汪汪' );
};
makeSound( new Dog() ); // 汪汪汪

找出变化的地方

开放封闭原则是一个看起来比较虚幻的原则,并没有实际的模板教导我们怎样亦步亦趋地实现它。但我们还是能找到一些让程序尽量遵守开放-封闭原则的规律,最明显的就是找出程序中将要发生变化的地方,然后把变化封装起来

通过封装变化的方式,可以把系统中稳定不变的部分和容易变化的部分隔离开来。在系统的演变过程中,我们只需要替换那些容易变化的部分,如果这些部分是已经被封装好的,那么替换起来也相对容易。而变化部分之外的就是稳定的部分。在系统的演变过程中,稳定的部分是不需要改变的。

除了利用对象的多态性之外,还有其他方式可以帮助我们编写遵守开放-封闭原则的代码,下面将详细介绍。

1.放置倒钩

放置挂钩(hook)也是分离变化的一种方式。我们在程序有可能发生变化的地方放置一个挂钩,挂钩的返回结果决定了程序的下一步走向。这样一来,原本的代码执行路径上就出现了一个分叉路口,程序未来的执行方向被预埋下多种可能性。

由于子类的数量是无限制的,总会有一些“个性化”的子类迫使我们不得不去改变已经封装好的算法骨架。于是我们可以在父类中的某个容易变化的地方放置挂钩,挂钩的返回结果由具体子类决定。这样一来,程序就拥有了变化的可能

2.使用回调函数

回调函数是一种特殊的挂钩。我们可以把一部分易于变化的逻辑封装在回调函数里,然后把回调函数当作参数传入一个稳定和封闭的函数中。当回调函数被执行的时候,程序就可以因为回调函数的内部逻辑不同,而产生不同的结果。

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

有一种说法是,设计模式就是给做的好的设计取个名字。几乎所有的设计模式都是遵守开放-封闭原则的,我们见到的好设计,通常都经得起开放-封闭原则的考验。不管是具体的各种设计模式,还是更抽象的面向对象设计原则,比如单一职责原则、最少知识原则、依赖倒置原则等都是为了让程序遵守开放-封闭原则而出现的。可以这样说,开放-封闭原则是编写一个好程序的目标,其他设计原则都是达到这个目标的过程。

1.发布-订阅模式

模式用来降低多个对象之间的依赖关系,它可以取代对象之间硬编码的通知机制,一个对象不用再显式地调用另外一个对象的某个接口。当有新的订阅者出现时,发布者的代码不需要进行任何修改;同样当发布者需要改变时,也不会影响到之前的订阅者。

2.模板方法模式

是一种典型的通过封装变化来提高系统扩展性的设计模式。在一个运用了模板方法模式的程序中,子类的方法种类和执行顺序都是不变的,所以我们把这部分逻辑抽出来放到父类的模板方法里面;而子类的方法具体怎么实现则是可变的,于是把这部分变化的逻辑封装到子类中。通过增加新的子类,便能给系统增加新的功能,并不需要改动抽象父类以及其他的子类,这也是符合开放-封闭原则的。

3.策略模式

策略模式和模板方法模式是一对竞争者。在大多数情况下,它们可以相互替换使用。模板方法模式基于继承的思想,而策略模式则偏重于组合和委托。

策略模式将各种算法都封装成单独的策略类,这些策略类可以被交换使用。策略和使用策略的客户代码可以分别独立进行修改而互不影响。我们增加一个新的策略类也非常方便,完全不用修改之前的代码

4.代理模式

5.职责链模式

接受第一次愚弄

让程序一开始就尽量遵守开放-封闭原则,并不是一件很容易的事情。一方面,我们需要尽快知道程序在哪些地方会发生变化,这要求我们有一些“未卜先知”的能力。另一方面,留给程序员的需求排期并不是无限的,所以我们可以说服自己去接受不合理的代码带来的第一次愚弄。在最初编写代码的时候,先假设变化永远不会发生,这有利于我们迅速完成需求。当变化发生并且对我们接下来的工作造成影响的时候,可以再回过头来封装这些变化的地方。

0%