开放-封闭原则最早由Eiffel语言的设计者Bertrand Meyer在其著作Object-Oriented Software Construction中提出。它的定义:软件实体(类、模式、函数)等应该是可以扩展的,但是不可修改。

扩展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);
});

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

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

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

过多的条件分支语句是造成程序违反开放-封闭原则的一个常见原因。每当需要增加一个新的if语句时,都要被迫改动原函数。把if换成swich-case是没有用的,这是一种换汤不换药的做法,可以考虑利用对象的多态性来重构它们。

借用动物发出叫声的例子,最初的代码:

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());

增加一只狗,需要改成:

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()); //增加一只小狗

利用多态的思想,我们把程序中不变的部分隔离出来(动物都会叫),然后把可变的部分封装起来(不同类型的动物发出不同的叫声),这样程序就有了可扩展性。当我们要增加一只狗发出叫声时,只需增加一段代码即可,而不用改动原有的makeSound函数:

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()); //咯咯咯

//增加动物狗,不需要改动原有代码
var Dog = function(){};
Dog.prototype.sound = function() {
    console.log('汪汪汪'); 
};
makeSound(new Dog()); //汪汪汪

找出变化的地方

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

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

除了利用对象的多态外,还有其他方式帮助我们遵守开放-封闭原则:

放置挂钩

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

使用回调函数

在JavaScript中,函数可以作为参数传递给另外一个函数,这是高阶函数的意义之一。在这种情况下,我们通常会把这个函数称为回调函数。策略模式和命令模式都可以用回调函数轻松实现。

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

  1. ajax异步请求用户信息后要做一些事情:
var getUserInfo = function(callback) {
    $.ajax('http:// xxx.com/getUserInfo', callback);
};
getUserInfo(function(data) { 
    console.log(data.userName);
});
getUserInfo(function(data) { 
    console.log(data.userId);
});
  1. 在不支持Array.prototype.map的浏览器中,我们可以简单地模拟实现一个map函数。
var arrayMap = function(ary, callback) { 
    var i=0,
    length = ary.length, 
    value,
    ret = [];
    for(i; i<length; i++) {
        value = callback(i, ary[i]); 
        ret.push( value );
    }
    return ret; 
}

var a = arrayMap([1,2,3], function(i,n) { 
    return n * 2;
});

var b = arrayMap([1,2,3], function(i,n) { 
    return n * 3;
});

console.log(a); //  输出:[ 2, 4, 6 ]
console.log(b); //  输出:[ 3, 6, 9 ]

arrayMap函数的作用是把一个数组“映射”为另外一个数组。映射的步骤是不变的,而映射的规则可变,于是我们把这部分规则放在回调函数中,传入arrayMap函数。

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

几乎所有的设计模式都是遵守开放-封闭原则的,我们见到的好设计,通常都经得起开放-封闭原则的考验。不管是具体的各种设计模式,还是更抽象的面向对象设计原则,比如单一职责原则,最少知识原则,依赖倒置原则等,都是为了让程序遵守开放-封闭原则而出现的。

发布-订阅模式

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

模版方法模式

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

策略模式

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

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

代理模式

代理模式见之前的文章

职责链模式
var order500yuan = new Chain(function(orderType, pay, stock) { 
    //具体代码略    
});
var order200yuan = new Chain(function(orderType, pay, stock) { 
    //具体代码略    
});
var orderNormal = new Chain(function(orderType, pay, stock) {    
    //具体代码略    
});

order500yuan.setNextSuccessor(order200yuan).setNextSuccessor(orderNormal);
order500yuan.passRequest(1, true, 10); //500元定金预购,得到100优惠卷

这样,当我们增加一个新类型的订单函数时,不需要改动原有的订单函数代码,只需要在链条中增加一个新的节点。

开放-封闭原则的相对性

让程序符合开放-封闭原则的代价是引入更多的抽象层次,更多的抽象有可能增加代码的复杂度。更何况,有些代码是无论如何也不能完全封闭的,总会存在一些无法对其封闭的变化。作为程序员我们可以做到以下两点:

  1. 挑选出容易发生变化的地方,然后构造抽象来封闭这些变化。

  2. 在不可避免发生修改的时候,尽量修改那些相对容易修改的地方。拿一个开源库来说,修改它提供的配置文件,总比修改它的源代码来的简单。

在最初编码时,先假设变化永远不会发生,这有利于迅速完成需求。当变化发生并且对我们接下来的工作造成影响的时候,可以再来封装这些变化的地方,确保不会掉进同一个坑里。有点像星矢说的:“圣斗士不会被同样的招数击倒第二次”。