适合JavaScript的设计原则

语言: CN / TW / HK

1.单一职责原则

一个类而言,应该仅有一个引起它变化的原因。在JavaScript中,单一职责原则更多地是被运用在对象或者方法级别上。

单一职责原则(SRP)的职责被定义为“引起变化的原因”。如果有两个动机去改写一个方法,那么这个方法就具有两个职责。每个职责都是变化的一个轴线,如果一个方法承担了过多的职责,那么在需求的变迁过程中,需要改写这个方法的可能性就越大

此时,这个方法通常是一个不稳定的方法,修改代码是一件危险的事情,特别是当两个职责耦合在一起的时候,一个职责发生变化可能会影响到其他职责的实现,造成意想不到的破坏,这种耦合性得到的是低内聚和脆弱的设计。

因此,SRP原则体现为:一个对象(方法)只做一件事情

1.1设计模式中的SRP原则

1.1.1 代理模式

通过增加虚拟代理的方式,把预加载图片的职责放到代理对象中,而本体仅仅负责往页面中添加img标签,这也是它最原始的职责

``` js // 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//xxx/loading.gif') img.src = src; } } })()

proxyImage.setSrc('http:// xxx.png') ``` 把添加 img 标签的功能和预加载图片的职责分开放到两个对象中,这两个对象各自都只有一个被修改的动机。在它们各自发生改变的时候,也不会影响另外的对象。

详细 代理模式

1.1.2 迭代器模式

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

``` js 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]) ``` appendDiv函数本来只是负责渲染数据,但是在这里它还承担了遍历聚合对象data的职责。如果data的数据格式从array变成了object, 那遍历data的代码就会出现问题。

有必要把遍历data的职责提取出来,这正是迭代器模式的意义,迭代器模式提供了一种方法来访问聚合对象,而不用暴露这个对象的内部表示

把迭代聚合对象的职责单独封装在each函数中后,即使以后还要增加新的迭代方法,只需要修改each函数即可,appendDiv函数不会受到牵连

``` js var each = function(obj, callback) { var value, i = 0, length = obj.length, isArray = isArraylike(obj);

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) } }; ```

详细 迭代器模式

1.1.3 单例模式

详细 单例模式

1.1.4 装饰者模式

详细 装饰者模式

1.2 何时应该分离职责

SRP原则是所有原则中最简单也是最难正确运用的原则之一

注: 并不是所有的职责都应该一一分离。

  • 如果随着需求的变化,有两个职责总是同时变化,那就不必分离他们。比如在ajax请求的时候,创建xhr对象和发送xhr请求几乎总是在一起的,那么创建xhr对象的职责和发送xhr请求的职责就没有必要分开。

  • 职责的变化轴线仅当它们确定会发生变化时才具有意义,即使两个职责已经被耦合在一起,但它们还没有发生改变的征兆,那么也许没有必要主动分离它们,在代码需要重构的时候再进行分离也不迟

1.3 违反SRP原则

人们总是习惯性地把一组相关的行为放到一起,如何正确地分离职责不是一件容易的事情

也许从来没有考虑过如何分离职责,但这并不妨碍编写代码完成需求。

一方面,受设计原则的指导,另一方面,未必要在任何时候都一成不变地遵守原则。在实际开发中,因为种种原因违反SRP的情况并不少见。比如Jquery的attr等方法,就是明显违反SRP原则的做法。jQuery的attr,即负责赋值,又负责取值,这对于jQuery的维护者来说,会带来一些困难,但对于jQuery的用户来说,却简化了用户的使用。

在方便性与稳定性之间要有一些取舍。具体是选择方便性还是稳定性,并没有标准答案,而是要取决于具体的应用环境。

1.4 SRP原则的优缺点

SRP原则的优点是降低了单个类或者对象的复杂度,按照职责把对象分解成更小的粒度,这有助于代码的复用,也有利于进行单元测试。当一个职责需要变更的时候,不会影响到其他的职责

单SRP原则也有一些缺点,最明显的是会增加编写代码的复杂度。当我们按照职责把对象分解成更小的粒度之后,实际上也增大了这些对象之间相互联系的难度

2.最少知识原则

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

2.1 减少对象之间的联系

单一职责原则指导我们把对象划分成较小的粒度,这可以提高对象的可复用性。但越来越多的对象之间可能会产生错综复杂的联系,如果修改了其中一个对象,很可能会影响到跟它相互引用的其他对象。对象和对象耦合在一起,很可能会降低它们的可复用性。

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

2.2 设计模式中的最少知识原则

最少知识原则在设计模式中体现最多的地方是中介者模式和外观模式

2.2.1 中介者模式

中介者模式很好地体现了最少知识原则。通过增加一个中介者对象,让所有的相关对象都通 过中介者对象来通信,而不是互相引用。所以,当一个对象发生改变时,只需要通知中介者对象即可。

2.2.2 外观模式

外观模式主要是为子系统中的一组接口提供一个一致的界面,外观模式定义了一个高层接口,这个 接口使子系统更加容易使用

外观模式的作用是对客户屏蔽一组子系统的复杂性。外观模式对客户提供一个简单易用的高 层接口,高层接口会把客户的请求转发给子系统来完成具体的功能实现。大多数客户都可以通过 请求外观接口来达到访问子系统的目的。但在一段使用了外观模式的程序中,请求外观并不是强 制的。如果外观不能满足客户的个性化需求,那么客户也可以选择越过外观来直接访问子系统。

外观模式容易跟普通的封装实现混淆。这两者都封装了一些事物,但外观模式的关键是定义 一个高层接口去封装一组“子系统”。

外观模式映射到 JavaScript 中,这个子系统至少应该指的是一组函数的集合

js var A = function(){ a1(); a2(); } var B = function(){ b1(); b2(); } var facade = function(){ A(); B(); } facade();

外观模式的作用主要有两点

  • 为一组子系统提供一个简单便利的访问入口。
  • 隔离客户与复杂子系统之间的联系,客户不用去了解子系统的细节。

从第二点来,外观模式是符合最少知识原则的。

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

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

同时,封装也用来限制变量的作用域。在 JavaScript 中对变量作用域的规定是:

  • 变量在全局声明,或者在代码的任何位置隐式申明(不用 var),则该变量在全局可见;
  • 变量在函数内显式申明(使用 var),则在函数内可见。

把变量的可见性限制在一个尽可能小的范围内,这个变量对其他不相关模块的影响就越小, 变量被改写和发生冲突的机会也越小。这也是广义的最少知识原则的一种体现。

3. 开放-封闭原则

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

3.1 扩展 window.onload 函数

``` js 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 函数的内部实现,无论它 的实现优雅或是丑陋。就算我们作为维护者,拿到的是一份混淆压缩过的代码也没有关系。只要 它从前是个稳定运行的函数,那么以后也不会因为我们的新增需求而产生错误。新增的代码和原 有的代码可以井水不犯河水。

3.2 开放和封闭

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

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

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

利用对象的多态性来让程序遵守开放-封闭原则,是一个常用的技巧。

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

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

3.4 找出变化的地方

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

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

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

3.4.1 放置挂钩

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

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

3.4.2 使用回调函数

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

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

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

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

3.5.1 发布-订阅模式

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

3.5.2 模板方法模式

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

3.5.3. 策略模式

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

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

3.5.4 代理模式

拿预加载图片举 例,我们现在已有一个给图片设置 src 的函数 myImage,当我们想为它增加图片预加载功能时, 一种做法是改动 myImage 函数内部的代码,更好的做法是提供一个代理函数 proxyMyImage,代理 函数负责图片预加载,在图片预加载完成之后,再将请求转交给原来的 myImage 函数,myImage 在 这个过程中不需要任何改动。

预加载图片的功能和给图片设置 src 的功能被隔离在两个函数里,它们可以单独改变而互不 影响。myImage 不知晓代理的存在,它可以继续专注于自己的职责——给图片设置 src。

3.5.5 职责链模式

把一个巨大的订单函数分别拆成了 500 元订单、 200 元订单以及普通订单的 3 个函数。这 3 个函数通过职责链连接在一起,客户的请求会在这条 链条里面依次传递:

``` js 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 优惠券 ```

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

3.6 开放-封闭原则的相对性

在职责链模式代码中,大家也许会产生这个疑问:开放封闭原则要求我们只能通过增加源 代码的方式扩展程序的功能,而不允许修改源代码。那当我们往职责链中增加一个新的 100 元订 单函数节点时,不也必须改动设置链条的代码吗?代码如下:

``` js order500yuan.setNextSuccessor( order200yuan ).setNextSuccessor( orderNormal );

变为:

order500yuan.setNextSuccessor( order200yuan ).setNextSuccessor( order100yuan ).setNextSuccessor( orderNormal ); ```

实际上,让程序保持完全封闭是不容易做到的。就算技术上做得到,也需要花费太多的时间 和精力。而且让程序符合开放-封闭原则的代价是引入更多的抽象层次,更多的抽象有可能会增 大代码的复杂度。

更何况,有一些代码是无论如何也不能完全封闭的,总会存在一些无法对其封闭的变化。

  • 挑选出最容易发生变化的地方,然后构造抽象来封闭这些变化。
  • 在不可避免发生修改的时候,尽量修改那些相对容易修改的地方。拿一个开源库来说, 修改它提供的配置文件,总比修改它的源代码来得简单。