javascript设计模式与开发实践

javascript设计模式与开发实践

设计模式

每个设计模式我们需要从三点问题入手:

定义

作用

用法与实现

单例模式

定义

每个类只有一个实例,并提供一个访问它的全局访问点

单例模式是一种常用的模式,有一些对象我们往往只需要一个,比如线程池、全局缓存、浏览器中的 window 对象等。在 JavaScript开发中,单例模式的用途同样非常广泛。试想一下,当我们单击登录按钮的时候,页面中会出现一个登录浮窗,而这个登录浮窗是唯一的,无论单击多少次登录按钮,这个浮窗都只会被创建一次,那么这个登录浮窗就适合用单例模式来创建。

实现一个单例模式

function Singleton(name){
    this.name = name;
}

Singleton.prototype.sayName = function(){
    console.log(this.name)
};

Singleton.getInstance = (function(){
    var instance = null;
    return function(name){
        if(!instance){
            instance = new Singleton(name) ;
        }
        return instance;
    }
})();

我们通过 Singleton.getInstance 来获取 Singleton 类的唯一对象,这种方式相对简单,但有一个问题,就是增加了这个类的“不透明性”, Singleton 类的使用者必须知道这是一个单例类,跟以往通过 new XXX 的方式来获取对象不同,这里偏要使用 Singleton.getInstance 来获取对象。

下面我们来验证一下:

var a = Singleton.getInstance('zz');
var b = Singleton.getInstance('cc');
console.log(a === b);  // true

通用的惰性单例

惰性单例就是指在使用的时候才会创建对象实例。

// 创建单例模型和创建对象解耦
getSingle = function(fn){
    var result = null;
    return function(){
        return result || (result = fn.apply(this, arguments))
    }
}

var createLoginLayer = function(){
    var div = document.createElement( 'div' );
    div.innerHTML = '我是登录浮窗';
    div.style.display = 'none';
    document.body.appendChild( div );
    return div;
};

var createSingleLoginLayer = getSingle( createLoginLayer );
document.getElementById( 'loginBtn' ).onclick = function(){
    var loginLayer = createSingleLoginLayer();
    loginLayer.style.display = 'block';
};

小结

在 getSinge 函数中,实际上也提到了闭包和高阶函数的概念。单例模式是一种简单但非常实用的模式,特别是惰性单例技术,在合适的时候才创建对象,并且只创建唯一的一个。更奇妙的是,创建对象和管理单例的职责被分布在两个不同的方法中,这两个方法组合起来才具有单例模式的威力。

策略模式

定义

定义一系列算法,把他们都封装起来,并且可以互相替换。

在程序设计中,我们也常常遇到类似的情况,要实现某一个功能有多种方案可以选择。比如一个压缩文件的程序,既可以选择 zip算法,也可以选择 gzip算法。这些算法灵活多样,而且可以随意互相替换。这就可以用策略模式。

mark

使用策略模式计算奖金

本节我们就以年终奖的计算为例进行介绍。
很多公司的年终奖是根据员工的工资基数和年底绩效情况来发放的。例如,绩效为 S的人年终奖有 4倍工资,绩效为 A的人年终奖有 3倍工资,而绩效为 B的人年终奖是 2倍工资。假设财务部要求我们提供一段代码,来方便他们计算员工的年终奖。

1.最初的代码实现
var calculateBonus = function( performanceLevel, salary ){
    if(performanceLevel == 'S'){
        return salary * 4;
    }
    if(performanceLevel == 'A'){
        return salary * 3;
    }

    if(performanceLevel == 'B'){
        return salary * 2;
    }
}

console.log(calculateBonus( 'B', 20000 ));  // 40000
console.log(calculateBonus( 'S', 20000 ));  // 80000

这样虽然 简单,但产生了很多问题:

  1. calculateBonus逻辑很复杂,包含了很多分支。
  2. calculateBonus 函数缺乏弹性,如果增加了一种新的绩效等级 C,或者想把绩效 S 的奖金系数改为 5,那我们必须深入 calculateBonus 函数的内部实现,这是违反开放封闭原则的。
  3. 算法的复用性差,如果其他地方也使用相同的算法,那就只能复制粘贴了。
2. javascript版本的策略模式
var strategies = {
    'S': function(salary){
        return salary * 4;
    },
    'A': function(salary){
        return salary * 3;
    },
    'B': function(salary){
        return salary * 2;
    }
}

var calculateBonus = function(performanceLevel, salary){
    return strategies[performanceLevel](salary)
}

console.log(calculateBonus( 'B', 20000 ));  // 40000
console.log(calculateBonus( 'S', 20000 ));  // 80000

我们再来回顾一下策略模式的思想:

定义一系列的算法,把它们一个个封装起来,并且使它们可以相互替换

这句话如果说得更详细一点,就是:定义一系列的算法,把它们各自封装成策略类,算法被封装在策略类内部的方法里。在客户对 Context发起请求的时候,Context总是把请求委托给这些策略对象中间的某一个进行计算

多态在策略模式中的体现

通过使用策略模式重构代码,我们消除了原程序中大片的条件分支语句。所有跟计算奖金有关的逻辑不再放在 Context中,而是分布在各个策略对象中。Context并没有计算奖金的能力,而是把这个职责委托给了某个策略对象。每个策略对象负责的算法已被各自封装在对象内部。当我们对这些策略对象发出“计算奖金”的请求时,它们会返回各自不同的计算结果,这正是对象多态性的体现,也是“它们可以相互替换”的目的。替换 Context中当前保存的策略对象,便能执行不同的算法来得到我们想要的结果。

使用策略模式实现缓动动画


/*   策略类    
     * @param t: current time(当前时间);
     * @param b: beginning value(初始值);
     * @param c: change in value(变化量);
     * @param d: duration(持续时间)。
     * @return: 动画元素应该处在的当前位置
     */
var tween = {
    linear: function (t, b, c, d) {
        return c * t / d + b;
    },
    easeIn: function (t, b, c, d) {
        return c * ( t /= d ) * t + b;
    },
    strongEaseIn: function (t, b, c, d) {
        return c * ( t /= d ) * t * t * t * t + b;
    },
    strongEaseOut: function (t, b, c, d) {
        return c * ( ( t = t / d - 1) * t * t * t * t + 1 ) + b;
    },
    sineaseIn: function (t, b, c, d) {
        return c * ( t /= d) * t * t + b;
    },
    sineaseOut: function (t, b, c, d) {
        return c * ( ( t = t / d - 1) * t * t + 1 ) + b;
    }
};

写下剩下的完整代码

<body>
    <div style="position:absolute;background:blue" id="div">我是 div</div>
</body>
var Animate = function(dom){
        this.dom = dom; // 进行运动的 dom 节点
        this.startTime = 0; // 动画开始时间
        this.startPos = 0; // 动画开始时,dom 节点的位置,即 dom 的初始位置
        this.endPos = 0; // 动画结束时,dom 节点的位置,即 dom 的目标位置
        this.propertyName = null; // dom 节点需要被改变的 css 属性名
        this.easing = null; // 缓动算法
        this.duration = null; // 动画持续时间
}

Animate.prototype.start = function( propertyName, endPos, duration, easing ){
    this.startTime = +new Date();
    this.startPos = this.dom.getBoundingClientRect()[propertyName]; //
    this.endPos = endPos;
    this.propertyName = propertyName;
    this.easing = tween[easing];
    this.duration = duration;

    var self = this;
    var  timer = setInterval(function(){
        if(self.step() == false){
            clearInterval(timer);
            return false;
        }
    },19);
}

Animate.prototype.update = function(pos){
    this.dom.style[this.propertyName] = pos + 'px';
}

Animate.prototype.step = function(){
    var t = +new Date(); //当前时间
    if(t > this.startTime + this.duration){
        this.update(this.endPos);
        return false;
    }
    var pos = this.easing( t - this.startTime, this.startPos,
                          this.endPos - this.startPos, this.duration );
    this.update(pos);
}

试一试吧

var div = document.getElementById( 'div' );
var animate = new Animate( div );
animate.start( 'left', 500, 1000, 'strongEaseOut' );
// animate.start( 'top', 1500, 500, 'strongEaseIn' );

更广义的“算法”

策略模式指的是定义一系列的算法,并且把它们封装起来。本章我们介绍的计算奖金和缓动动画的例子都封装了一些算法。

从定义上看,策略模式就是用来封装算法的。但如果把策略模式仅仅用来封装算法,未免有一点大材小用。在实际开发中,我们通常会把算法的含义扩散开来,使策略模式也可以用来封装一系列的“业务规则”。只要这些业务规则指向的目标一致,并且可以被替换使用,我们就可以用策略模式来封装它们。

用策略模式表单校验

第一步我们要把这些校验逻辑都封装成策略对象:

 var strategies = {
     isNonEmpty: function( value, errorMsg ){ // 不为空
         if ( value === '' ){
             return errorMsg ;
         }
     },
     minLength: function( value, length, errorMsg ) { // 限制最小长度
         if (value.length < length) {
             return errorMsg;
         }
     },
     isMobile: function( value, errorMsg ){ // 手机号码格式
         if ( !/(^1[3|5|8][0-9]{9}$)/.test( value ) ){
             return errorMsg;
         }
     }
 }

接下来我们准备实现 Validator 类。 Validator 类在这里作为 Context,负责接收用户的请求并委托给 strategy 对象。在给出 Validator 类的代码之前,有必要提前了解用户是如何向 Validator类发送请求的,这有助于我们知道如何去编写 Validator 类的代码。代码如下:

var validataFunc = function(){
    var validator = new Validator(); // 创建一个 validator 对象
    /***************添加一些校验规则****************/
    validator.add( registerForm.userName, 'isNonEmpty', '用户名不能为空' );
    validator.add( registerForm.password, 'minLength:6', '密码长度不能少于 6 位' );
    validator.add( registerForm.phoneNumber, 'isMobile', '手机号码格式不正确' );
    var errorMsg = validator.start(); // 获得校验结果
    return errorMsg; // 返回校验结果
}

var registerForm = document.getElementById( 'registerForm' );
registerForm.onsubmit = function(){
    var errorMsg = validataFunc(); // 如果 errorMsg 有确切的返回值,说明未通过校验
    if ( errorMsg ){
        alert ( errorMsg );
        return false; // 阻止表单提交
    }
};

最后是 Validator 类的实现

var Validator = function(){
    this.cache = []; // 保存校验规则
};

Validator.prototype.add = function( dom, rule, errorMsg ){
    var ary = rule.split( ':' ); // 把 strategy 和参数分开
    this.cache.push(function(){ // 把校验的步骤用空函数包装起来,并且放入 cache
        var strategy = ary.shift(); // 用户挑选的 strategy
        ary.unshift( dom.value ); // 把 input 的 value 添加进参数列表
        ary.push( errorMsg ); // 把 errorMsg 添加进参数列表
        return strategies[ strategy ].apply( dom, ary );
    });
};

策略模式的优缺点

策略模式是一种常用且有效的设计模式,本章提供了计算奖金、缓动动画、表单校验这三个例子来加深大家对策略模式的理解。从这三个例子中,我们可以总结出策略模式的一些优点。

  1. 策略模式利用组合、委托和多态等技术和思想,可以有效地避免多重条件选择语句。

  2. 策略模式提供了对开放 — 封闭原则的完美支持,将算法封装在独立的 strategy 中,使得它们易于切换,易于理解,易于扩展。

  3. 策略模式中的算法也可以复用在系统的其他地方,从而避免许多重复的复制粘贴工作。

  4. 在策略模式中利用组合和委托来让 Context拥有执行算法的能力,这也是继承的一种更轻便的替代方案。

当然,策略模式也有一些缺点,但这些缺点并不严重。

首先,使用策略模式会在程序中增加许多策略类或者策略对象,但实际上这比把它们负责的逻辑堆砌在 Context中要好。

其次,要使用策略模式,必须了解所有的 strategy ,必须了解各个 strategy 之间的不同点,这样才能选择一个合适的 strategy 。比如,我们要选择一种合适的旅游出行路线,必须先了解选择飞机、火车、自行车等方案的细节。此时 strategy 要向客户暴露它的所有实现,这是违反最少知识原则的。

一等函数对象与策略模式

本章提供的几个策略模式示例,既有模拟传统面向对象语言的版本,也有针对 JavaScript语言的特有实现。在以类为中心的传统面向对象语言中,不同的算法或者行为被封装在各个策略类中,Context 将请求委托给这些策略对象,这些策略对象会根据请求返回不同的执行结果,这样便能表现出对象的多态性。

Peter Norvig 在他的演讲中曾说过:“在函数作为一等对象的语言中,策略模式是隐形的。strategy 就是值为函数的变量。”在 JavaScript中,除了使用类来封装算法和行为之外,使用函数当然也是一种选择。这些“算法”可以被封装到函数中并且四处传递,也就是我们常说的“高阶函数”。实际上在 JavaScript这种将函数作为一等对象的语言里,策略模式已经融入到了语言本身当中,我们经常用高阶函数来封装不同的行为,并且把它传递到另一个函数中。当我们对这些函数发出“调用”的消息时,不同的函数会返回不同的执行结果。在 JavaScript中,“函数对象的多态性”来得更加简单。

在前面的学习中,为了清楚地表示这是一个策略模式,我们特意使用了 strategies 这个名字。如果去掉 strategies ,我们还能认出这是一个策略模式的实现吗?代码如下:

var S = function( salary ){
return salary * 4;
};
var A = function( salary ){
return salary * 3;
};
var B = function( salary ){
return salary * 2;
};
var calculateBonus = function( func, salary ){
    return func( salary );
};
calculateBonus( S, 10000 ); // 输出:40000

小结

本章我们既提供了接近传统面向对象语言的策略模式实现,也提供了更适合 JavaScript语言的策略模式版本。在 JavaScript语言的策略模式中,策略类往往被函数所代替,这时策略模式就成为一种“隐形”的模式。尽管这样,从头到尾地了解策略模式,不仅可以让我们对该模式有更加透彻的了解,也可以使我们明白使用函数的好处。

代理模式

定义

代理模式是为一个对象提供一个代用品或占位符,以便控制对它的访问

代理模式是一种非常有意义的模式,在生活中可以找到很多代理模式的场景。比如,明星都有经纪人作为代理。如果想请明星来办一场商业演出,只能联系他的经纪人。经纪人会把商业演出的细节和报酬都谈好之后,再把合同交给明星签。

代理模式的关键是,当客户不方便直接访问一个对象或者不满足需要的时候,提供一个替身对象来控制对这个对象的访问,客户实际上访问的是替身对象。替身对象对请求做出一些处理之后,再把请求转交给本体对象。

保护代理和虚拟代理

mark

代理 B 可以帮助 A过滤掉一些请求,比如送花的人中年龄太大的或者没有宝马的,这种请求就可以直接在代理 B处被拒绝掉。这种代理叫作保护代理。A 和 B 一个充当白脸,一个充当黑脸。白脸 A 继续保持良好的女神形象,不希望直接拒绝任何人,于是找了黑脸 B来控制对 A的访问。

另外,假设现实中的花价格不菲,导致在程序世界里, new Flower 也是一个代价昂贵的操作,那么我们可以把 new Flower 的操作交给代理 B 去执行,代理 B 会选择在 A 心情好时再执行 newFlower ,这是代理模式的另一种形式,叫作虚拟代理虚拟代理把一些开销很大的对象,延迟到真正需要它的时候才去创建。代码如下:

function Flower(){};

var xiaoming = {
    sendFlower: function( target){
        var flower = new Flower();
        target.receiveFlower( flower );
    }
};

var A = {
    receiveFlower: function( flower ){
        console.log( '收到花 ' + flower );
    },
    listenGoodMood: function( fn ){
        setTimeout(function() { // 假设 10 秒之后 A 的心情变好
            fn();
        },1000);
    }
}

// 虚拟代理
var B = {
    receiveFlower: function( flower ){
        A.listenGoodMood(function(){ // 监听 A 的好心情
            var flower = new Flower(); // 延迟创建 flower 对象
            A.receiveFlower( flower );
        });
    }
};

xiaoming.sendFlower(B);

保护代理用于控制不同权限的对象对目标对象的访问,但在 JavaScript并不容易实现保护代理,因为我们无法判断谁访问了某个对象。而虚拟代理是最常用的一种代理模式。

虚拟代理实现图片预加载

在 Web开发中,图片预加载是一种常用的技术,如果直接给某个 img 标签节点设置 src 属性,由于图片过大或者网络不佳,图片的位置往往有段时间会是一片空白。常见的做法是先用一张loading图片占位,然后用异步的方式加载图片,等图片加载好了再把它填充到 img 节点里,这种场景就很适合使用虚拟代理。

var myImage = (function(){
    var imgNode = document.createElement( 'img' );
    document.body.appendChild( imgNode );
    return {
        setSrc: function( src ){
            imgNode.src = src;
        }
    }
})();

var imageProxy = (function(){
    var img = new Image();
    img.onload = function(){
        myImage.setSrc(this.src);
    }
    return {
        setSrc: function( src ){
            myImage.setSrc('./a.jpg');
            img.src = src;
        }
    }
})();

imageProxy.setSrc( 'https://ss1.baidu.com/6ONXsjip0QIZ8tyhnq/it/u=605082013,2663851599&fm=173&app=25&f=JPEG?w=218&h=146&s=934041A15E124FD620393D1A03001050' );

缓存代理

缓存代理可以为一些开销大的运算结果提供暂时的存储,在下次运算时,如果传递进来的参数跟之前一致,则可以直接返回前面存储的运算结果

用高阶函数动态创建代理

/**************** 计算乘积 *****************/
var mult = function(){
    var a = 1;
    for ( var i = 0, l = arguments.length; i < l; i++ ){
        a = a * arguments[i];
    }
    return a;
};
/**************** 计算加和 *****************/
var plus = function(){
    var a = 0;
    for ( var i = 0, l = arguments.length; i < l; i++ ){
        a = a + arguments[i];
    }
    return a;
};
/**************** 创建缓存代理的工厂 *****************/
var createProxyFactory = function( fn ){
    var cache = {};
    return function(){
        var args = Array.prototype.join.call( arguments, ',' );
        if ( args in cache ){
            return cache[ args ];
        }
        return cache[ args ] = fn.apply( this, arguments );
    }
};
var proxyMult = createProxyFactory( mult ),
    proxyPlus = createProxyFactory( plus );
alert ( proxyMult( 1, 2, 3, 4 ) ); // 输出:24
alert ( proxyMult( 1, 2, 3, 4 ) ); // 输出:24
alert ( proxyPlus( 1, 2, 3, 4 ) ); // 输出:10
alert ( proxyPlus( 1, 2, 3, 4 ) ); // 输出:10

小结

代理模式包括许多小分类,在 JavaScript开发中最常用的是虚拟代理和缓存代理。虽然代理模式非常有用,但我们在编写业务代码的时候,往往不需要去预先猜测是否需要使用代理模式。当真正发现不方便直接访问某个对象的时候,再编写代理也不迟。

迭代器模式

定义

迭代器模式是指提供一种方法顺序访问一个聚合对象中的各个元素,而又不需要暴露该对象的内部表示。

迭代器模式可以把迭代的过程从业务逻辑中分离出来,在使用迭代器模式之后,即使不关心对象的内部构造,也可以按顺序访问其中的每个元素。

mark

jQuery 中的迭代器

$.each( [1, 2, 3], function( i, n ){
    console.log( '当前下标为: '+ i );
    console.log( '当前值为:' + n );
});

实现自己的迭代器

 var each = function(arr, callback){
     for(var i=0; i<arr.length; i++){
         callback.call(arr[i], i , arr[i]);
     }
 }

 each([1,2,3], function(i,n){
     console.log('坐标:'+i);
     console.log('元素:'+n);
 })

迭代类数组对象和字面量对象

迭代器模式不仅可以迭代数组,还可以迭代一些类数组的对象。比如 arguments 、{"0":'a',"1":'b'} 等。 通过上面的代码可以观察到,无论是内部迭代器还是外部迭代器,只要被迭代的聚合对象拥有 length 属性而且可以用下标访问,那它就可以被迭代。

在 JavaScript 中, for in 语句可以用来迭代普通字面量对象的属性。jQuery 中提供了$.each`函数来封装各种迭代行为:

$.each = function( obj, callback ) {
    var value,
        i = 0,
        length = obj.length,
        isArray = isArraylike( obj );
    if ( isArray ) { // 迭代类数组
        for ( ; i < length; i++ ) {
            value = callback.call( obj[ i ], i, obj[ i ] );
            if ( value === false ) {
                break;
            }
        }
    } else {
        for ( i in obj ) { // 迭代 object 对象
            value = callback.call( obj[ i ], i, obj[ i ] );
            if ( value === false ) {
                break;
            }
        }
    }
    return obj;
};

迭代器模式的应用举例

某一个根据不同的浏览器获取相应的上传组件对象:

var getUploadObj = function(){
    try{
        return new ActiveXObject("TXFTNActiveX.FTNUpload"); // IE 上传控件
    }catch(e){
        if ( supportFlash() ){ // supportFlash 函数未提供
            var str = '<object type="application/x-shockwave-flash"></object>';
            return $( str ).appendTo( $('body') );
        }else{
            var str = '<input name="file" type="file"/>'; // 表单上传
            return $( str ).appendTo( $('body') );
        }
    }
};

看看上面的代码,为了得到一个 upload 对象,这个 getUploadObj 函数里面充斥了 try,catch以及 if 条件分支。缺点是显而易见的。第一是很难阅读,第二是严重违反开闭原则。 在开发和调试过程中,我们需要来回切换不同的上传方式,每次改动都相当痛苦。后来我们还增加支持了一些另外的上传方式,比如,HTML5 上传,这时候唯一的办法是继续往 getUploadObj 函数里增加条件分支。

这里可以使用惰性载入,或者以下说的迭代器优化。

我们把每种获取 upload 对象的方法都封装在各自的函数里,然后使用一个迭代器,迭代获取这些 upload 对象,直到获取到一个可用的为止:

var getActiveUploadObj = function(){
    try{
        return new ActiveXObject( "TXFTNActiveX.FTNUpload" ); // IE 上传控件
    }catch(e){
        return false;
    }
};
var getFlashUploadObj = function(){
    if ( supportFlash() ){ // supportFlash 函数未提供
        var str = '<object type="application/x-shockwave-flash"></object>';
        return $( str ).appendTo( $('body') );
    }
    return false;
};
var getFormUpladObj = function(){
    var str = '<input name="file" type="file" class="ui-file"/>'; // 表单上传
    return $( str ).appendTo( $('body') );
};

var iteratorUploadObj = function(){
    for(var i=0; i<arguments.length; i++){
        var fn = arguments[i];
        var fnObj = fn();
        if(fnObj !== false){
            return fnObj;
        }
    }
}

var uploadObj = iteratorUploadObj(getActiveUploadObj, getFlashUploadObj, getFormUpladObj)

重构代码之后,我们可以看到,获取不同上传对象的方法被隔离在各自的函数里互不干扰,try 、 catch 和 if 分支不再纠缠在一起,使得我们可以很方便地的维护和扩展代码。

小结

迭代器模式是一种相对简单的模式,简单到很多时候我们都不认为它是一种设计模式。目前的绝大部分语言都内置了迭代器。

发布 — 订阅模式

定义

发布 — 订阅模式又叫观察者模式,它定义对象间的一种一对多的依赖关系,当一个对象的状态发生改变时,所有依赖于它的对象都将得到通知。

在 JavaScript开发中,我们一般用事件模型来替代传统的发布 — 订阅模式。

DOM 事件

实际上,只要我们曾经在 DOM 节点上面绑定过事件函数,那我们就曾经使用过发布 — 订阅模式,来看看下面这两句简单的代码发生了什么事情···

document.addEventListener('click',function(){
    alert(1);
})

document.body.click();

在这里需要监控用户点击 document.body 的动作,但是我们没办法预知用户将在什么时候点击。所以我们订阅 document.body 上的 click 事件,当 body 节点被点击时, body 节点便会向订阅者发布这个消息。这很像购房的例子,购房者不知道房子什么时候开售,于是他在订阅消息后等待售楼处发布消息。

自定义事件

除了 DOM 事件,我们还会经常实现一些自定义的事件,这种依靠自定义事件完成的发布 —订阅模式可以用于任何 JavaScript代码中。
现在看看如何一步步实现发布 — 订阅模式。

  1. 首先要指定好谁充当发布者(比如售楼处);
  2. 然后给发布者添加一个缓存列表,用于存放回调函数以便通知订阅者(售楼处的花名册);
  3. 最后发布消息的时候,发布者会遍历这个缓存列表,依次触发里面存放的订阅者回调函数(遍历花名册,挨个发短信)。
var salesOffices = {}; //售楼部

salesOffices.clientList = {}; // 缓存列表

salesOffices.listen = function(key,fn){
    if(!this.clientList[key]){
        this.clientList[key] = [];
    }
    this.clientList[key].push(fn);
}

salesOffices.trigger = function(){
    var key = Array.prototype.shift.call(arguments); // 取出消息key
    var fns = this.clientList[key];

    if( !fns || fns.length === 0 ){
        return false;
    }

    for(var i=0; i<fns.length; i++){
        fns[i].apply(this,arguments);  // arguments是发布消息的的时候带的参数
    }
}

// 测试
salesOffices.listen('seqaureMetar88', function(price){
    console.log('seqaureMetar88 价格:' +price);
})

salesOffices.listen('seqaureMetar88', function(price){
    console.log('zz seqaureMetar88 价格:' +price);
})

salesOffices.listen('seqaureMetar110', function(price){
    console.log('seqaureMetar110 价格:' +price);
})

salesOffices.trigger('seqaureMetar88', 20000);
salesOffices.trigger('seqaureMetar110', 30000);

发布-订阅模式的通用实现

我们先抽出来放到一个对象中

var event = {
    clientList: {},
    listen: function(key,fn){
        if(!this.clientList[key]){
            this.clientList[key] = [];
        }
        this.clientList[key].push(fn);
    },
    trigger: function(){
        var key = Array.prototype.shift.call(arguments); // 取出消息key
        var fns = this.clientList[key];
        if( !fns || fns.length === 0 ){
            return false;
        }
        for(var i=0; i<fns.length; i++){
            fns[i].apply(this,arguments);  // arguments是发布消息的的时候带的参数
        }
    }
}

在定义一个installEvent函数,可以给所有函数安装发布-订阅功能(也可以用继承)

var installEvent = function(obj){
    for(var i in event){
        obj[i] = event[i]
    }
}

// 测试
var salesOffices = {};
installEvent(salesOffices);

salesOffices.listen('seqaureMetar88', function(price){
    console.log('seqaureMetar88 价格:' +price);
})

salesOffices.listen('seqaureMetar88', function(price){
    console.log('zz seqaureMetar88 价格:' +price);
})

salesOffices.listen('seqaureMetar110', function(price){
    console.log('seqaureMetar110 价格:' +price);
})

salesOffices.trigger('seqaureMetar88', 20000);  // seqaureMetar88 价格:20000  seqaureMetar88 价格:20000
salesOffices.trigger('seqaureMetar110', 30000); // seqaureMetar110 价格:30000

小结

发布 — 订阅模式的优点非常明显,一为时间上的解耦,二为对象之间的解耦。它的应用非常广泛,既可以用在异步编程中,也可以帮助我们完成更松耦合的代码编写。发布 — 订阅模式还可以用来帮助实现一些别的设计模式,比如中介者模式。 从架构上来看,无论是 MVC还是 MVVM,都少不了发布 — 订阅模式的参与,而且 JavaScript本身也是一门基于事件驱动的语言。

当然,发布 — 订阅模式也不是完全没有缺点。创建订阅者本身要消耗一定的时间和内存,而且当你订阅一个消息后,也许此消息最后都未发生,但这个订阅者会始终存在于内存中。另外,发布 — 订阅模式虽然可以弱化对象之间的联系,但如果过度使用的话,对象和对象之间的必要联系也将被深埋在背后,会导致程序难以跟踪维护和理解。特别是有多个发布者和订阅者嵌套到一起的时候,要跟踪一个 bug不是件轻松的事情。

命令模式

定义:

命令模式中的命令(command)指的是一个执行某些特定事情的指令。

mark

命令模式最常见的应用场景是:有时候需要向某些对象发送请求,但是并不知道请求的接收者是谁,也不知道被请求的操作是什么。此时希望用一种松耦合的方式来设计程序,使得请求发送者和请求接收者能够消除彼此之间的耦合关系。

拿订餐来说,客人需要向厨师发送请求,但是完全不知道这些厨师的名字和联系方式,也不知道厨师炒菜的方式和步骤。 命令模式把客人订餐的请求封装成 command 对象,也就是订餐中的订单对象。这个对象可以在程序中被四处传递,就像订单可以从服务员手中传到厨师的手中。这样一来,客人不需要知道厨师的名字,从而解开了请求调用者和请求接收者之间的耦合关系。

命令模式的例子——菜单程序

在大型项目开发中,这是很正常的分工。对于绘制按钮的程序员来说,他完全不知道某个按钮未来将用来做什么,可能用来刷新菜单界面,也可能用来增加一些子菜单,他只知道点击这个按钮会发生某些事情。那么当完成这个按钮的绘制之后,应该如何给它绑定 onclick 事件呢?

回想一下命令模式的应用场景:

有时候需要向某些对象发送请求,但是并不知道请求的接收者是谁,也不知道被请求的操作是什么,此时希望用一种松耦合的方式来设计软件,使得请求发送者和请求接收者能够消除彼此之间的耦合关系。

我们很快可以找到在这里运用命令模式的理由:点击了按钮之后,必须向某些负责具体行为的对象发送请求,这些对象就是请求的接收者。但是目前并不知道接收者是什么对象,也不知道接收者究竟会做什么。此时我们需要借助命令对象的帮助,以便解开按钮和负责具体行为对象之间的耦合。

var button1 = document.getElementById( 'button1' );
var button2 = document.getElementById( 'button2' );
var button3 = document.getElementById( 'button3' );

var setCommand = function(button, command){
    button.onclick = function(){
        command.execute();
    }
}

var MenuBar = {
    refresh: function(){
        console.log( '刷新菜单目录' );
    }
};
var SubMenu = {
    add: function(){
        console.log( '增加子菜单' );
    },
    del: function(){
        console.log( '删除子菜单' );
    }
};

var RefreshMenuCommand = function(receiver){
    this.receiver = receiver;
}

RefreshMenuCommand.prototype.execute = function(){
    this.receiver.refresh();
}

var AddSubMenuCommand = function(receiver){
    this.receiver = receiver;
}

AddSubMenuCommand.prototype.execute = function(){
    this.receiver.add();
}

var DelSubMenuCommand = function(receiver){
    this.receiver = receiver;
}

DelSubMenuCommand.prototype.execute = function(){
    this.receiver.del();
}

var refreshMenuCommand = new RefreshMenuCommand( MenuBar );
var addSubMenuCommand = new AddSubMenuCommand( SubMenu );
var delSubMenuCommand = new DelSubMenuCommand( SubMenu );

setCommand(button1, refreshMenuCommand);
setCommand(button2, addSubMenuCommand);
setCommand(button3, delSubMenuCommand);

JavaScript 中的命令模式

也许我们会感到很奇怪,所谓的命令模式,看起来就是给对象的某个方法取了 execute 的名字。引入 command 对象和 receiver 这两个无中生有的角色无非是把简单的事情复杂化了,即使不用什么模式,用下面寥寥几行代码就可以实现相同的功能:

var bindClick = function( button, func ){
    button.onclick = func;
};
var MenuBar = {
    refresh: function(){
        console.log( '刷新菜单界面' );
    }
};
var SubMenu = {
    add: function(){
        console.log( '增加子菜单' );
    },
    del: function(){
        console.log( '删除子菜单' );
    }
};
bindClick( button1, MenuBar.refresh );

这种说法是正确的,9.2 节中的示例代码是模拟传统面向对象语言的命令模式实现。命令模式将过程式的请求调用封装在 command 对象的 execute 方法里,通过封装方法调用,我们可以把运算块包装成形。 command 对象可以被四处传递,所以在调用命令的时候,客户(Client)不需要关心事情是如何进行的。

命令模式的由来,其实是回调( callback )函数的一个面向对象的替代品。

JavaScript 作为将函数作为一等对象的语言,跟策略模式一样,命令模式也早已融入到了JavaScript语言之中。运算块不一定要封装在 command.execute 方法中,也可以封装在普通函数中。函数作为一等对象,本身就可以被四处传递。即使我们依然需要请求“接收者”,那也未必使用面向对象的方式,闭包可以完成同样的功能。

在面向对象设计中,命令模式的接收者被当成 command 对象的属性保存起来,同时约定执行命令的操作调用 command.execute 方法。在使用闭包的命令模式实现中,接收者被封闭在闭包产生的环境中,执行命令的操作可以更加简单,仅仅执行回调函数即可。无论接收者被保存为对象的属性,还是被封闭在闭包产生的环境中,在将来执行命令的时候,接收者都能被顺利访问。用闭包实现的命令模式如下代码所示:

如果想更明确地表达当前正在使用命令模式,或者除了执行命令之外,将来有可能还要提供撤销命令等操作。那我们最好还是把执行函数改为调用 execute 方法:

var button1 = document.getElementById( 'button1' );

var setCommand = function(button, command){
    button.onclick = function(){
        command.execute();
    }
}

var MenuBar = {
    refresh: function(){
        console.log( '刷新菜单目录' );
    }
};

var RefreshMenuCommand = function(receiver){
    return {
        execute: function(){
            receiver.refresh();
        }
    }
}

var refreshMenuCommand = new RefreshMenuCommand( MenuBar );

setCommand(button1, refreshMenuCommand);

宏命令

宏命令是一组命令的集合,可以一次执行一批命令。

var closeDoorCommand = {
    execute: function(){
        console.log('关门')
    }
}

var openPcCommand = {
    execute: function(){
        console.log('开电脑')
    }
}

var MacroCommand = function(){
    return {
        commandList: [],
        add: function(command){
            this.commandList.push(command)
        },
        execute: function(){
            for(var i=0; i<this.commandList.length; i++){
                this.commandList[i].execute();
            }
        }
    }
}

var macroCommand = new MacroCommand();
macroCommand.add(closeDoorCommand);
macroCommand.add(openPcCommand);
macroCommand.execute();

小结

本章我们学习了命令模式。跟许多其他语言不同,JavaScript 可以用高阶函数非常方便地实现命令模式。命令模式在 JavaScript语言中是一种隐形的模式。

组合模式

定义

组合模式就是由相似的子事物组成,而同时这些子事物,有可能是由孙事物组成

mark

更强大的宏命令

var MacroComand = function(){
    return {
        commandList:[],
        add: function(command){
            this.commandList.push(command);
        },
        execute: function(){
            for(var i=0,command; i<this.commandList.length; i++){
                command = this.commandList[i];
                command.execute();
            }
        }
    }
}

var openAcCommand = {
    execute: function(){
        console.log('打开空调')
    }
}

var openTvCommand = {
    execute: function(){
        console.log('打开电视')
    }
}

var openSoundCommand = {
    execute: function(){
        console.log('打开音响')
    }
}

// 电视和音响在一起
var macroCommand1 = new MacroComand();
macroCommand1.add(openTvCommand);
macroCommand1.add(openSoundCommand);

// 关门打开电脑登录qq
var closeDoorCommand = {
    execute: function(){
        console.log('关门')
    }
}

var openPcCommand = {
    execute: function(){
        console.log('打开电脑')
    }
}

var openQQCommand = {
    execute: function(){
        console.log('登录qq')
    }
}

var macroCommand2 = new MacroComand();
macroCommand2.add(closeDoorCommand);
macroCommand2.add(openPcCommand);
macroCommand2.add(openQQCommand);

// 把所有组合都组在一起的超级命令
var macroCommand3 = new MacroComand();
macroCommand3.add(openAcCommand);
macroCommand3.add(macroCommand1);
macroCommand3.add(macroCommand2);

var setCommand = (function(command){
    document.querySelector('#button1').addEventListener('click', function(){
        command.execute();
    })
})(macroCommand3);

组合模式的用途

组合模式将对象组合成树形结构,以表示“部分整体”的层次结构。 除了用来表示树形结构之外,组合模式的另一个好处是通过对象的多态性表现,使得用户对单个对象和组合对象的使用具有一致性,下面分别说明。

  • 表示树形结构。通过回顾上面的例子,我们很容易找到组合模式的一个优点:提供了一种遍历树形结构的方案,通过调用组合对象的 execute 方法,程序会递归调用组合对象下面的叶对象的 execute 方法,所以我们的万能遥控器只需要一次操作,便能依次完成关门、打开电脑、登录 QQ这几件事情。组合模式可以非常方便地描述对象部分整体层次结构。
  • 利用对象多态性统一对待组合对象和单个对象。利用对象的多态性表现,可以使客户端忽略组合对象和单个对象的不同。在组合模式中,客户将统一地使用组合结构中的所有对象,而不需要关心它究竟是组合对象还是单个对象。

这在实际开发中会给客户带来相当大的便利性,当我们往万能遥控器里面添加一个命令的时候,并不关心这个命令是宏命令还是普通子命令。这点对于我们不重要,我们只需要确定它是一个命令,并且这个命令拥有可执行的 execute 方法,那么这个命令就可以被添加进万能遥控器。
当宏命令和普通子命令接收到执行 execute 方法的请求时,宏命令和普通子命令都会做它们各自认为正确的事情。这些差异是隐藏在客户背后的,在客户看来,这种透明性可以让我们非常自由地扩展这个万能遥控器。

请求在树中传递的过程

以宏命令为例,请求从树最顶端的对象往下传递,如果当前处理请求的对象是叶对象(普通子命令),叶对象自身会对请求作出相应的处理;如果当前处理请求的对象是组合对象(宏命令),组合对象则会遍历它属下的子节点,将请求继续传递给这些子节点。

总而言之,如果子节点是叶对象,叶对象自身会处理这个请求,而如果子节点还是组合对象,请求会继续往下传递。叶对象下面不会再有其他子节点,一个叶对象就是树的这条枝叶的尽头,组合对象下面可能还会有子节点,

mark

组合模式的例子--扫描文件夹

何时使用组合模式

组合模式如果运用得当,可以大大简化客户的代码。一般来说,组合模式适用于以下这两种情况。

  • 表示对象的部分整体层次结构。组合模式可以方便地构造一棵树来表示对象的部分整体结构。特别是我们在开发期间不确定这棵树到底存在多少层次的时候。在树的构造最终完成之后,只需要通过请求树的最顶层对象,便能对整棵树做统一的操作。在组合模式中增加和删除树的节点非常方便,并且符合开放-封闭原则。
  • 客户希望统一对待树中的所有对象。组合模式使客户可以忽略组合对象和叶对象的区别,客户在面对这棵树的时候,不用关心当前正在处理的对象是组合对象还是叶对象,也就不用写一堆 if 、 else 语句来分别处理它们。组合对象和叶对象会各自做自己正确的事情,这是组合模式最重要的能力。

小结

本章我们了解了组合模式在 JavaScript开发中的应用。组合模式可以让我们使用树形方式创建对象的结构。我们可以把相同的操作应用在组合对象和单个对象上。在大多数情况下,我们都可以忽略掉组合对象和单个对象之间的差别,从而用一致的方式来处理它们。

然而,组合模式并不是完美的,它可能会产生一个这样的系统:系统中的每个对象看起来都与其他对象差不多。它们的区别只有在运行的时候会才会显现出来,这会使代码难以理解。此外,如果通过组合模式创建了太多的对象,那么这些对象可能会让系统负担不起。

模板方法模式

定义

一种基于继承的设计模式——模板方法(Template Method)模式。

模板方法模式由两部分结构组成,第一部分是抽象父类,第二部分是具体的实现子类。通常在抽象父类中封装了子类的算法框架,包括实现一些公共方法以及封装子类中所有方法的执行顺序。子类通过继承这个抽象类,也继承了整个算法结构,并且可以选择重写父类的方法。

Coffee or Tea

先泡一杯咖啡

首先,我们先来泡一杯咖啡,如果没有什么太个性化的需求,泡咖啡的步骤通常如下:
(1) 把水煮沸
(2) 用沸水冲泡咖啡
(3) 把咖啡倒进杯子
(4) 加糖和牛奶

泡一壶茶

接下来,开始准备我们的茶,泡茶的步骤跟泡咖啡的步骤相差并不大:
(1) 把水煮沸
(2) 用沸水浸泡茶叶
(3) 把茶水倒进杯子
(4) 加柠檬

分离出共同点

我们找到泡咖啡和泡茶主要有以下不同点。

  • 原料不同。一个是咖啡,一个是茶,但我们可以把它们都抽象为“饮料”。
  • 泡的方式不同。咖啡是冲泡,而茶叶是浸泡,我们可以把它们都抽象为“泡”。
  • 加入的调料不同。一个是糖和牛奶,一个是柠檬,但我们可以把它们都抽象为“调料”。

经过抽象之后,不管是泡咖啡还是泡茶,我们都能整理为下面四步:

(1) 把水煮沸
(2) 用沸水冲泡饮料
(3) 把饮料倒进杯子
(4) 加调料

var Beverage = function(){}

Beverage.prototype.boilWater = function(){
    console.log('把水煮沸');
}

Beverage.prototype.brew = function(){}
Beverage.prototype.pourInCup = function(){};
Beverage.prototype.addCondiments = function(){};

Beverage.prototype.init = function(){
    this.boilWater();
    this.brew();
    this.pourInCup();
    this.addCondiments();
}


var Coffee = function(){};
Coffee.prototype = new Beverage();
Coffee.prototype.brew = function(){
    console.log('用沸水冲泡咖啡');
}
Coffee.prototype.pourInCup = function(){
    console.log('把咖啡倒进杯子里');
}
Coffee.prototype.addCondiments = function(){
    console.log('加糖和牛奶');
}

var coffee = new Coffee();
coffee.init();

var Tea = function(){};
Tea.prototype = new Beverage();
Tea.prototype.brew = function(){
    console.log('用沸水冲泡茶叶');
}
Tea.prototype.pourInCup = function(){
    console.log('把茶倒进杯子里');
}
Tea.prototype.addCondiments = function(){
    console.log('加柠檬');
}

var tea = new Tea();
tea.init();

JavaScript 没有抽象类的缺点和解决方案

JavaScript 并没有从语法层面提供对抽象类的支持。抽象类的第一个作用是隐藏对象的具体类型,由于 JavaScript 是一门“类型模糊”的语言,所以隐藏象的类型在 JavaScript 中并不重要。

另一方面, 当我们在 JavaScript中使用原型继承来模拟传统的类式继承时,并没有编译器帮助我们进行任何形式的检查,我们也没有办法保证子类会重写父类中的“抽象方法”。

下面提供两种变通的解决方案。

  • 第 1种方案是用鸭子类型来模拟接口检查,以便确保子类中确实重写了父类的方法。但模拟接口检查会带来不必要的复杂性,而且要求程序员主动进行这些接口检查,这就要求我们在业务代码中添加一些跟业务逻辑无关的代码。
  • 第 2种方案是让 Beverage.prototype.brew 等方法直接抛出一个异常,如果因为粗心忘记编写 Coffee.prototype.brew 方法,那么至少我们会在程序运行时得到一个错误:

模板方法模式的使用场景

从大的方面来讲,模板方法模式常被架构师用于搭建项目的框架,架构师定好了框架的骨架,程序员继承框架的结构之后,负责往里面填空。

钩子方法

过模板方法模式,我们在父类中封装了子类的算法框架。这些算法框架在正常状态下是适用于大多数子类的,但如果有一些特别“个性”的子类呢?

钩子方法( hook )可以用来解决这个问题,放置钩子是隔离变化的一种常见手段。我们在父类中容易变化的地方放置钩子,钩子可以有一个默认的实现,究竟要不要“挂钩”,这由子类自行决定。钩子方法的返回结果决定了模板方法后面部分的执行步骤,也就是程序接下来的走向,

Beverage.prototype.customerWantsCondiments = function(){
    return true; // 默认需要调料
};
Beverage.prototype.init = function(){
    this.boilWater();
    this.brew();
    this.pourInCup();
    if ( this.customerWantsCondiments() ){ // 如果挂钩返回 true,则需要调料
        this.addCondiments();
    }
};

小结

模板方法模式是一种典型的通过封装变化提高系统扩展性的设计模式。在传统的面向对象语言中,一个运用了模板方法模式的程序中,子类的方法种类和执行顺序都是不变的,所以我们把这部分逻辑抽象到父类的模板方法里面。而子类的方法具体怎么实现则是可变的,于是我们把这部分变化的逻辑封装到子类中。通过增加新的子类,我们便能给系统增加新的功能,并不需要改动抽象父类以及其他子类,这也是符合开放封闭原则的。
但在 JavaScript中,我们很多时候都不需要依样画瓢地去实现一个模版方法模式,高阶函数是更好的选择。

享元模式

定义:

享元(flyweight)模式是一种用于性能优化的模式,“fly”在这里是苍蝇的意思,意为蝇量级。享元模式的核心是运用共享技术来有效支持大量细粒度的对象。

内部状态与外部状态

享元模式要求将对象的属性划分为内部状态与外部状态(状态在这里通常指属性)。享元模式的目标是尽量减少共享对象的数量,关于如何划分内部状态和外部状态,下面的几条经验提供了一些指引。

  • 内部状态存储于对象内部。
  • 内部状态可以被一些对象共享。
  • 内部状态独立于具体的场景,通常不会改变。
  • 外部状态取决于具体的场景,并根据场景而变化,外部状态不能被共享。

剥离了外部状态的对象成为共享对象,外部状态在必要时被传入共享对象来组装成一个完整的对象。虽然组装外部状态成为一个完整对象的过程需要花费一定的时间,但却可以大大减少系统中的对象数量,相比之下,这点时间或许是微不足道的。因此,享元模式是一种用时间换空间的优化模式。

文件上传的例子

对象爆炸

在微云上传模块的开发中,我曾经经历过对象爆炸的问题。微云的文件上传功能虽然可以选择依照队列,一个一个地排队上传,但也支持同时选择 2000 个文件。每一个文件都对应着一个JavaScript上传对象的创建,在第一版开发中,的确往程序里同时 new了 2000个 upload 对象,结果可想而知,Chrome中还勉强能够支撑,IE下直接进入假死状态。

微云支持好几种上传方式,比如浏览器插件、Flash 和表单上传等,为了简化例子,我们先假设只有插件和 Flash 这两种。不论是插件上传,还是 Flash 上传,原理都是一样的,当用户选择了文件之后,插件和 Flash 都会通知调用 Window 下的一个全局 JavaScript 函数,它的名字是startUpload ,用户选择的文件列表被组合成一个数组 files 塞进该函数的参数列表里,代码如下:

var id = 0;

var startUpload = function(uploadType,files){
    for(var i=0; i<files.length; i++){
        var file = files[i];
        var uploadObj = new Upload(uploadType, file.fileName, file.fileSize);
        uploadObj.init(id++);
    }
}

var Upload = function(uploadType,fileName, fileSize){
    this.uploadType = uploadType;
    this.fileName = fileName;
    this.fileSize = fileSize;
}

Upload.prototype.init = function(id){
    this.id = id;
    this.dom = document.createElement("div");
    this.dom.innerHTML = `<span>文件名: ${this.fileName},文件大小${this.fileSize}</span>`
    document.body.appendChild(this.dom);
}

startUpload( 'plugin', [
    {
        fileName: '1.txt',
        fileSize: 1000
    },
    {
        fileName: '2.html',
        fileSize: 3000
    },
    {
        fileName: '3.txt',
        fileSize: 5000
    }
]);

startUpload( 'flash', [
    {
        fileName: '4.txt',
        fileSize: 1000
    },
    {
        fileName: '5.html',
        fileSize: 3000
    },
    {
        fileName: '6.txt',
        fileSize: 5000
    }
]);
享元模式重构文件上传
var id = 0;
// 剥离外部状态
var Upload = function(uploadType){
    this.uploadType = uploadType;
}


// 工厂进行对象实例化
var UploadFactory = (function(){
    var createFlyWeightObj = {};

    return {
        create: function(uploadType){
            if(createFlyWeightObj[uploadType]){
                return createFlyWeightObj[uploadType];
            }
            return createFlyWeightObj[uploadType] = new Upload(uploadType);
        }
    }
})();

// 管理器封装外部状态
var uploadManger = (function(){
    var uploadDatabase = {};

    return {
        add: function(id, uploadType,fileName,fileSize){
            var flyWeightObj = UploadFactory.create(uploadType);
            var dom = document.createElement('div');
            dom.innerHTML =   `<span>文件类型:${flyWeightObj.uploadType}文件名: ${fileName},文件大小${fileSize}</span>`;
            document.body.appendChild(dom);
            uploadDatabase[id++] = {
                fileName: fileName,
                fileSize: fileSize,
                dom: dom
            }

            return flyWeightObj;
        }
    }
})();

var startUpload = function(uploadType,files){
    for(var i=0; i<files.length; i++){
        var file = files[i];
        var uploadObj = uploadManger.add(id, uploadType, file.fileName, file.fileSize);
    }
}



startUpload( 'plugin', [
    {
        fileName: '1.txt',
        fileSize: 1000
    },
    {
        fileName: '2.html',
        fileSize: 3000
    },
    {
        fileName: '3.txt',
        fileSize: 5000
    }
]);

startUpload( 'flash', [
    {
        fileName: '4.txt',
        fileSize: 1000
    },
    {
        fileName: '5.html',
        fileSize: 3000
    },
    {
        fileName: '6.txt',
        fileSize: 5000
    }
]);

享元模式重构之前的代码里一共创建了 6个 upload 对象,而通过享元模式重构之后,对象的数量减少为 2,更幸运的是, 就算现在同时上传 2000个文件,需要创建的 upload 对象数量依然是 2。

享元模式的适用性

享元模式是一种很好的性能优化方案,但它也会带来一些复杂性的问题,从前面两组代码的比较可以看到,使用了享元模式之后,我们需要分别多维护一个 factory 对象和一个 manager 对象,在大部分不必要使用享元模式的环境下,这些开销是可以避免的。享元模式带来的好处很大程度上取决于如何使用以及何时使用,一般来说,以下情况发生时便可以使用享元模式。

  • 一个程序中使用了大量的相似对象。
  • 由于使用了大量对象,造成很大的内存开销。
  • 对象的大多数状态都可以变为外部状态。
  • 剥离出对象的外部状态之后,可以用相对较少的共享对象取代大量对象。

对象池

对象池维护一个装载空闲对象的池子,如果需要对象的时候,不是直接 new,而是转从对象池里获取。如果对象池里没有空闲对象,则创建一个新的对象,当获取出的对象完成它的职责之后, 再进入池子等待被下次获取。

对象池技术的应用非常广泛,HTTP连接池和数据库连接池都是其代表应用。在 Web前端开发中,对象池使用最多的场景大概就是跟 DOM 有关的操作。很多空间和时间都消耗在了 DOM节点上,如何避免频繁地创建和删除 DOM节点就成了一个有意义的话题。

对象池实现

假设我们在开发一个地图应用, 地图上经常会出现一些标志地名的小气泡,我们叫它 toolTip 。

mark

在搜索我家附近地图的时候,页面里出现了 2个小气泡。当我再搜索附近的兰州拉面馆时,页面中出现了 6个小气泡。按照对象池的思想,在第二次搜索开始之前,并不会把第一次创建的2个小气泡删除掉,而是把它们放进对象池。这样在第二次的搜索结果页面里,我们只需要再创建 4个小气泡而不是 6个,

mark

先定义一个获取小气泡节点的工厂,作为对象池的数组成为私有属性被包含在工厂闭包里,这个工厂有两个暴露对外的方法, create 表示获取一个 div 节点, recover 表示回收一个 div 节点:

var toolTipFactory = (function(){
    var toolTipPool = [];
    return {
        create: function(){
            // 如果池子是空的
            if(toolTipPool.length === 0){
                var div = document.createElement('div');
                document.body.appendChild(div);
                return div;
            } else {
                return toolTipPool.shift();
            }
        },
        recover: function(){
            return toolTipPool.push(toolTipPool); //对象池回收dom
        }
    }
})();

var ary = [];
for(var i=0,str; str = ['a','b'][i++];){
    var toolTip = toolTipFactory.create();
    toolTip.innerHTML = str;
    ary.push(toolTip);
}

// 这里可以看到有两个A与B

// 重新绘制后,进行回收
for(var j=0; j<ary.length; j++){
    toolTipFactory.recover(ary[j])
};

// 在创建6个气泡
for(i=0,str; str = ['a','b','c','d','e','f'][i++];){
    var toolTip = toolTipFactory.create();
    toolTip.innerHTML = str;
    ary.push(toolTip);
}

通用对象池实现

var objectPoolFactory = (function(createObjFn){
    var objectPool = [];
    return {
        create: function(){
            // 如果池子是空的
            var obj = objectPool.length === 0 ? createObjFn.apply(this,arguments) : objectPool.shift()
            return obj
        },
        recover: function(obj){
            return objectPool.push(obj); //对象池回收dom
        }
    }
})();

对象池是另外一种性能优化方案,它跟享元模式有一些相似之处,但没有分离内部状态和外部状态这个过程。本章用享元模式完成了一个文件上传的程序,其实也可以用对象池+事件委托来代替实现。

小结

享元模式是为解决性能问题而生的模式,这跟大部分模式的诞生原因都不一样。在一个存在大量相似对象的系统中,享元模式可以很好地解决大量对象带来的性能问题。

责任链模式

定义

使多个对象都有机会处理请求,从而避免请求的发送者和接受者之间的耦合关系,将这些对象连成一条链,并沿着这条链传递该请求。直到有一个对象处理它为止。

职责链模式的名字非常形象,一系列可能会处理请求的对象被连接成一条链,请求在这些对象之间依次传递,直到遇到一个可以处理它的对象,我们把这些对象称为链中的节点

mark

现实中的职责链模式

如果早高峰能顺利挤上公交车的话,那么估计这一天都会过得很开心。因为公交车上人实在太多了,经常上车后却找不到售票员在哪,所以只好把两块钱硬币往前面递。除非你运气够好,站在你前面的第一个人就是售票员,否则,你的硬币通常要在 N 个人手上传递,才能最终到达售票员的手里。

mark

实际开发中的职责链模式

假设我们负责一个售卖手机的电商网站,经过分别交纳 500元定金和 200元定金的两轮预定后(订单已在此时生成),现在已经到了正式购买的阶段。
公司针对支付过定金的用户有一定的优惠政策。在正式购买后,已经支付过 500元定金的用户会收到 100元的商城优惠券,200元定金的用户可以收到 50元的优惠券,而之前没有支付定金的用户只能进入普通购买模式,也就是没有优惠券,且在库存有限的情况下不一定保证能买到。我们的订单页面是 PHP吐出的模板,在页面加载之初,PHP会传递给页面几个字段。

  • orderType :表示订单类型(定金用户或者普通购买用户), code 的值为 1的时候是 500元定金用户,为 2的时候是 200元定金用户,为 3的时候是普通购买用户。

  • pay :表示用户是否已经支付定金,值为 true 或者 false , 虽然用户已经下过 500元定金的订单,但如果他一直没有支付定金,现在只能降级进入普通购买模式。

  • stock :表示当前用于普通购买的手机库存数量,已经支付过 500 元或者 200 元定金的用户不受此限制

var order500 = function(orderType, pay, stock){
    if(orderType ==  1 && pay == true){
        console.log('500定金已交, 得到100优惠券');
    } else {
        return 'nextSuccessor'; // 我们不知道下一个节点是谁,反正把请求往后面传递
    }
}

var order200 = function(orderType, pay, stock) {
    if(orderType ==  2 && pay == true){
        console.log('200定金已交, 得到50优惠券');
    } else {
        return 'nextSuccessor'; // 我们不知道下一个节点是谁,反正把请求往后面传递
    }
}

var orderNormal = function(orderType, pay, stock) {
    if(stock > 0){
        console.log('普通够买, 无优惠券');
    } else {
        console.log('手机库存不足')
    }
}

var Chain = function(fn){
    this.fn = fn;
    this.nextSuccessor = null;
}

Chain.prototype.setNextSuccessor = function(successor){
    this.nextSuccessor = successor;
}

Chain.prototype.passRequest = function(){
    var ret = this.fn.apply(this,arguments);
    if(ret == 'nextSuccessor'){
        return this.nextSuccessor && this.nextSuccessor.passRequest.apply(this.nextSuccessor, arguments);
    }
}

var chainOrder500 = new Chain(order500);
var chainOrder200 = new Chain(order200);
var chainOrderNormal = new Chain(orderNormal);

chainOrder500.setNextSuccessor(chainOrder200);
chainOrder200.setNextSuccessor(chainOrderNormal);

chainOrder500.passRequest( 1, true, 500 ); // 输出:500 元定金预购,得到 100 优惠券
chainOrder500.passRequest( 2, true, 500 ); // 输出:200 元定金预购,得到 50 优惠券
chainOrder500.passRequest( 3, true, 500 ); // 输出:普通购买,无优惠券
chainOrder500.passRequest( 1, false, 0 ); // 输出:手机库存不足

用AOP实现责任链

Function.prototype.after = function(fn){
    var self = this;
    return function(){
        var ret = self.apply(this,arguments);
        if(ret == 'nextSuccessor'){
            return fn.apply(this, arguments)
        }
        return ret;
    }
}
var order = order500.after(order200).after(orderNormal)

用 AOP 来实现职责链既简单又巧妙,但这种把函数叠在一起的方式,同时也叠加了函数的作用域,如果链条太长的话,也会对性能有较大的影响。

小结

在 JavaScript开发中,职责链模式是最容易被忽视的模式之一。实际上只要运用得当,职责链模式可以很好地帮助我们管理代码,降低发起请求的对象和处理请求的对象之间的耦合性。职责链中的节点数量和顺序是可以自由变化的,我们可以在运行时决定链中包含哪些节点。

无论是作用域链、原型链,还是 DOM节点中的事件冒泡,我们都能从中找到职责链模式的影子。职责链模式还可以和组合模式结合在一起,用来连接部件和父部件,或是提高组合对象的效率。学会使用职责链模式,相信在以后的代码编写中,将会对你大有裨益。

中介者模式

定义

中介者模式的作用就是解除对象与对象之间的紧耦合关系。增加一个中介者对象后,所有的相关对象都通过中介者对象来通信。
中介者使各对象之间耦合松散,而且可以独立地改变它们之间的交互。中介者模式使网状的多对多关系变成了相对简单的一对多关系。

mark

现实中的中介者

机场指挥塔

中介者也被称为调停者,我们想象一下机场的指挥塔,如果没有指挥塔的存在,每一架飞机要和方圆 100公里内的所有飞机通信,才能确定航线以及飞行状况,后果是不可想象的。现实中的情况是,每架飞机都只需要和指挥塔通信。指挥塔作为调停者,知道每一架飞机的飞行状况,所以它可以安排所有飞机的起降时间,及时做出航线调整。

mark

中介者模式的例子--泡泡堂游戏

我们需要实现这个中介者 playerDirector 对象,一般有以下两种方式。

  • 利用发布 — 订阅模式。将 playerDirector 实现为订阅者,各 player 作为发布者,一旦 player的状态发生改变,便推送消息给 playerDirector , playerDirector 处理消息后将反馈发送给其他 player 。

  • 在 playerDirector 中开放一些接收消息的接口,各 player 可以直接调用该接口来给playerDirector 发送消息, player 只需传递一个参数给 playerDirector ,这个参数的目的是使 playerDirector 可以识别发送者。同样, playerDirector 接收到消息之后会将处理结果反馈给其他 player

function Player(name, teamColor){
    this.name = name; //角色名字
    this.teamColor = teamColor; // 队伍颜色
    this.state = 'alive'; //玩家生存状态
}

Player.prototype.win = function(){
    console.log(this.name + 'won');
}

Player.prototype.lose = function(){
    console.log(this.name + 'lost');
}

/*玩家死亡*/
Player.prototype.die = function(){
    this.state = 'died';
    playerDirector.receviceMessage('playerDead',this); //给中介者发信息,玩家死亡
}


var playerFactory = function(name, teamcolor){
    var newPlayer = new Player(name, teamcolor);  // 创造玩家
    playerDirector.receviceMessage('addPlayer',newPlayer); // 给中介发消息,新增玩家
    return newPlayer;
}



var playerDirector = (function(){
    var players = {};
    var operation = {}; //中介可以执行的动作

    operation.addPlayer = function(player){
        var teamColor = player.teamColor;
        players[teamColor] = players[teamColor] || [];
        players[teamColor].push(player);
    }

    operation.playerDead = function(player){
        var teamColor = player.teamColor;
        var teamPlayer = players[teamColor];
        var all_dead = true;


        for(var i=0; i<teamPlayer.length; i++){
            if(teamPlayer[i].state != 'died'){
                all_dead = false;
                break;
            }
        }

        if(all_dead == true){
            for(var i=0; i<teamPlayer.length; i++){
                teamPlayer[i].lose(); //本队所有玩家都输
            }

            for(color in players){
                if(color !== teamColor){
                    var teamPlayer = players[color];
                    for(var i=0; i<teamPlayer.length; i++ ){
                        teamPlayer[i].win();
                    }
                }
            }
        }
    }

    var ReceviceMessage = function(){
        var message = Array.prototype.shift.call(arguments);
        operation[message].apply(this,arguments);
    }

    return {
        receviceMessage: ReceviceMessage
    }
})();


// 红队:
var player1 = playerFactory( '皮蛋', 'red' ),
    player2 = playerFactory( '小乖', 'red' ),
    player3 = playerFactory( '宝宝', 'red' ),
    player4 = playerFactory( '小强', 'red' );
// 蓝队:
var player5 = playerFactory( '黑妞', 'blue' ),
    player6 = playerFactory( '葱头', 'blue' ),
    player7 = playerFactory( '胖墩', 'blue' ),
    player8 = playerFactory( '海盗', 'blue' );
player1.die();
player2.die();
player3.die();
player4.die();

小结

中介者模式是迎合迪米特法则的一种实现。迪米特法则也叫最少知识原则,是指一个对象应该尽可能少地了解另外的对象(类似不和陌生人说话)。如果对象之间的耦合性太高,一个对象发生改变之后,难免会影响到其他的对象,跟“城门失火,殃及池鱼”的道理是一样的。而在中介者模式里,对象之间几乎不知道彼此的存在,它们只能通过中介者对象来互相影响对方。

因此,中介者模式使各个对象之间得以解耦,以中介者和对象之间的一对多关系取代了对象之间的网状多对多关系。各个对象只需关注自身功能的实现,对象之间的交互关系交给了中介者对象来实现和维护。

不过,中介者模式也存在一些缺点。其中,最大的缺点是系统中会新增一个中介者对象,因为对象之间交互的复杂性,转移成了中介者对象的复杂性,使得中介者对象经常是巨大的。中介者对象自身往往就是一个难以维护的对象。

中介者模式可以非常方便地对模块或者对象进行解耦,但对象之间并非一定需要解耦。在实际项目中,模块或对象之间有一些依赖关系是很正常的。毕竟我们写程序是为了快速完成项目交付生产,而不是堆砌模式和过度设计。关键就在于如何去衡量对象之间的耦合程度。一般来说,如果对象之间的复杂耦合确实导致调用和维护出现了困难,而且这些耦合度随项目的变化呈指数增长曲线,那我们就可以考虑用中介者模式来重构代码。

装饰者模式

定义

对象动态地增加职责的方式称为装饰者(decorator)模式。装饰者模式能够在不改变对象自身的基础上,在程序运行期间给对象动态地添加职责。

mark

JavaScript 的装饰者

假设我们在编写一个飞机大战的游戏,随着经验值的增加,我们操作的飞机对象可以升级成更厉害的飞机,一开始这些飞机只能发射普通的子弹,升到第二级时可以发射导弹,升到第三级时可以发射原子弹。

var Plane = {
    fire: function(){
        console.log('发射普通子弹');
    }
}

var missileDecorator = function(){
    console.log('发射导弹');
}

var atomDecorator = function(){
    console.log('发色原子弹');
}

var fire1 = Plane.fire;
Plane.fire = function(){
    fire1();
    missileDecorator();
}

var fire2 = Plane.fire;
Plane.fire = function(){
    fire2();
    atomDecorator();
}

Plane.fire();  // 分别输出: 发射普通子弹、发射导弹、发射原子弹

使用AOP装饰函数

Function.prototype.before = function( beforefn ){
    var __self = this; // 保存原函数的引用
    return function(){ // 返回包含了原函数和新函数的"代理"函数
        beforefn.apply( this, arguments ); // 执行新函数,且保证 this 不被劫持,新函数接受的参数
        // 也会被原封不动地传入原函数,新函数在原函数之前执行
        return __self.apply( this, arguments ); // 执行原函数并返回原函数的执行结果,
        // 并且保证 this 不被劫持
    }
}
Function.prototype.after = function( afterfn ){
    var __self = this;
    return function(){
        var ret = __self.apply( this, arguments );
        afterfn.apply( this, arguments );
        return ret;
    }
};

AOP的应用实例

数据上报

分离业务代码和数据统计代码,无论在什么语言中,都是 AOP的经典应用之一。在项目开发的结尾阶段难免要加上很多统计数据的代码,这些过程可能让我们被迫改动早已封装好的函数。

<html>
<button tag="login" id="button">点击打开登录浮层</button>
<script>
    Function.prototype.after = function( afterfn ){
        var __self = this;
        return function(){
            var ret = __self.apply( this, arguments );
            afterfn.apply( this, arguments );
            return ret;
        }
    };
    var showLogin = function(){
        console.log( '打开登录浮层' );
    }
    var log = function(){
        console.log( '上报标签为: ' + this.getAttribute( 'tag' ) );
    }
    showLogin = showLogin.after( log ); // 打开登录浮层之后上报数据
    document.getElementById( 'button' ).onclick = showLogin;
</script>
</html>
用AOP动态改变函数的参数

从这段代码的(1)处和(2)处可以看到, beforefn 和原函数 _self 共用一组参数列表arguments ,当我们在 beforefn 的函数体内改变 arguments 的时候,原函数 _self 接收的参数列表自然也会变化。

Function.prototype.before = function( beforefn ){
    var __self = this;
    return function(){
        beforefn.apply( this, arguments ); // (1)
        return __self.apply( this, arguments ); // (2)
    }
}

var ajax= function( type, url, param ){
    console.log(param); // 发送 ajax 请求的代码略
};

var getToken = function(){
    return 'Token';
}
ajax = ajax.before(function( type, url, param ){
    param.Token = getToken();
});
ajax( 'get', 'http:// xxx.com/userinfo', { name: 'sven' } );
插件式的表单验证
Function.prototype.before = function( beforefn ){
    var __self = this;
    return function(){
        if ( beforefn.apply( this, arguments ) === false ){
            // beforefn 返回 false 的情况直接 return,不再执行后面的原函数
            return;
        }
        return __self.apply( this, arguments );
    }
}
var validata = function(){
    if ( username.value === '' ){
        alert ( '用户名不能为空' );
        return false;
    }
    if ( password.value === '' ){
        alert ( '密码不能为空' );
        return false;
    }
}
var formSubmit = function(){
    var param = {
        username: username.value,
        password: password.value
    }
    ajax( 'http:// xxx.com/login', param );
}
formSubmit = formSubmit.before( validata );
submitBtn.onclick = function(){
    formSubmit();
}

另外,这种装饰方式也叠加了函数的作用域,如果装饰的链条过长,性能上也会受到一些影响。

装饰者模式和代理模式

装饰者模式和第 6章代理模式的结构看起来非常相像,这两种模式都描述了怎样为对象提供一定程度上的间接引用,它们的实现部分都保留了对另外一个对象的引用,并且向那个对象发送请求。

代理模式和装饰者模式最重要的区别在于它们的意图和设计目的。代理模式的目的是,当直接访问本体不方便或者不符合需要时,为这个本体提供一个替代者。本体定义了关键功能,而代理提供或拒绝对它的访问,或者在访问本体之前做一些额外的事情。装饰者模式的作用就是为对象动态加入行为。换句话说,代理模式强调一种关系(Proxy与它的实体之间的关系),这种关系可以静态的表达,也就是说,这种关系在一开始就可以被确定。而装饰者模式用于一开始不能确定对象的全部功能时。代理模式通常只有一层代理本体的引用,而装饰者模式经常会形成一条长长的装饰链。

小结

本章通过数据上报、动态改变函数参数以及插件式的表单验证这 3个例子,我们了解了装饰函数,它是 JavaScript中独特的装饰者模式。这种模式在实际开发中非常有用,除了上面提到的例子,它在框架开发中也十分有用。作为框架作者,我们希望框架里的函数提供的是一些稳定而方便移植的功能,那些个性化的功能可以在框架之外动态装饰上去,这可以避免为了让框架拥有更多的功能,而去使用一些 if 、 else 语句预测用户的实际需要。

状态模式

定义:

状态模式的关键是区分事物内部的状态,事物内部状态的改变往往会带来事物的行为变换。

初识状态模式

我们来想象这样一个场景:有一个电灯,电灯上面只有一个开关。当电灯开着的时候,此时按下开关,电灯会切换到关闭状态;再按一次开关,电灯又将被打开。同一个开关按钮,在不同的状态下,表现出来的行为是不一样的。

mark

状态模式改进电灯程序

现在我们学习使用状态模式改进电灯的程序。有意思的是,通常我们谈到封装,一般都会优先封装对象的行为,而不是对象的状态。但在状态模式中刚好相反,状态模式的关键是把事物的每种状态都封装成单独的类,跟此种状态有关的行为都被封装在这个类的内部,所以 button被按下的的时候,只需要在上下文中,把这个请求委托给当前的状态对象即可,该状态对象会负责渲染它自身的行为

mark
var OfflightState = function(light){
    this.light = light;
}

OfflightState.prototype.buttonWasPressed = function(){
    console.log('弱光')
    this.light.setState(this.light.weakLightState);
}

var WeakLightState = function(light){
    this.light = light;
}

WeakLightState.prototype.buttonWasPressed = function(){
    console.log('强光')
    this.light.setState(this.light.strongLightState);
}

var StrongLightState = function(light){
    this.light = light;
}

StrongLightState.prototype.buttonWasPressed = function(){
    console.log('关灯')
    this.light.setState(this.light.offlightState);
}

var Light = function(){
    this.offlightState = new OfflightState(this);
    this.weakLightState = new WeakLightState(this);
    this.strongLightState = new StrongLightState(this);
    this.button = null;
}

Light.prototype.init = function(){
    var button = document.createElement('button')
    var self = this;
    this.button = document.body.appendChild(button);
    this.button.innerHTML = '开关';

    this.currentState = this.offlightState; //设置当前状态

    this.button.onclick = function(){
        self.currentState.buttonWasPressed();
    }
}

Light.prototype.setState = function(state){
    this.currentState = state;
}

var light = new Light();
light.init();

状态模式的定义

通过电灯的例子,相信我们对于状态模式已经有了一定程度的了解。现在回头来看 GoF 中对状态模式的定义:

允许一个对象在其内部状态改变时改变它的行为,对象看起来似乎修改了它的类。

我们以逗号分割,把这句话分为两部分来看。第一部分的意思是将状态封装成独立的类,并将请求委托给当前的状态对象,当对象的内部状态改变时,会带来不同的行为变化。电灯的例子足以说明这一点,在 off和 on这两种不同的状态下,我们点击同一个按钮,得到的行为反馈是截然不同的。

第二部分是从客户的角度来看,我们使用的对象,在不同的状态下具有截然不同的行为,这个对象看起来是从不同的类中实例化而来的,实际上这是使用了委托的效果。

状态模式的优缺点

到这里我们已经学习了两个状态模式的例子,现在是时候来总结状态模式的优缺点了。状态模式的优点如下。

  • 状态模式定义了状态与行为之间的关系,并将它们封装在一个类里。通过增加新的状态类,很容易增加新的状态和转换。

  • 避免 Context 无限膨胀,状态切换的逻辑被分布在状态类中,也去掉了 Context 中原本过多的条件分支。

  • 用对象代替字符串来记录当前状态,使得状态的切换更加一目了然。

  • Context中的请求动作和状态类中封装的行为可以非常容易地独立变化而互不影响。

状态模式的缺点是会在系统中定义许多状态类,编写 20 个状态类是一项枯燥乏味的工作,而且系统中会因此而增加不少对象。另外,由于逻辑分散在状态类中,虽然避开了不受欢迎的条件分支语句,但也造成了逻辑分散的问题,我们无法在一个地方就看出整个状态机的逻辑。

状态模式和策略模式的关系

策略模式和状态模式的相同点是,它们都有一个上下文、一些策略或者状态类,上下文把请求委托给这些类来执行。

它们之间的区别是策略模式中的各个策略类之间是平等又平行的,它们之间没有任何联系,所以客户必须熟知这些策略类的作用,以便客户可以随时主动切换算法;而在状态模式中,状态和状态对应的行为是早已被封装好的,状态之间的切换也早被规定完成,“改变行为”这件事情发生在状态模式内部。对客户来说,并不需要了解这些细节。这正是状态模式的作用所在。

JavaScript 版本的状态机

状态模式是状态机的实现之一,但在 JavaScript这种“无类”语言中,没有规定让状态对象一定要从类中创建而来。另外一点,JavaScript 可以非常方便地使用委托技术,并不需要事先让一个对象持有另一个对象。下面的状态机选择了通过 Function.prototype.call 方法直接把请求委托给某个字面量对象来执行。

下面改写电灯的例子,来展示这种更加轻巧的做法:

var Light = function(){
    this.button = null;
    this.state = FSM.off;
}

Light.prototype.init = function(){
    var button = document.createElement('button')
    var self = this;
    this.button = document.body.appendChild(button);
    this.button.innerHTML = '已关灯';

    this.button.onclick = function(){
        self.state.buttonWasPressed.call(self); // 把请求委托给状态机
    }
}

// finite state machine
var FSM = {
    off: {
        buttonWasPressed: function(){
            console.log('关灯');
            this.button.innerHTML = '下一次按我就是关灯';
            this.state = FSM.on;
        }
    },
    on: {
        buttonWasPressed: function(){
            console.log('开灯');
            this.button.innerHTML = '下一次按我就是开灯';
            this.state = FSM.off;
        }
    }
}

var light = new Light();
light.init();

表驱动的有限状态机

其实还有另外一种实现状态机的方法,这种方法的核心是基于表驱动的。我们可以在表中很清楚地看到下一个状态是由当前状态和行为共同决定的。这样一来,我们就可以在表中查找状态,而不必定义很多条件分支

mark

小结

这一章通过几个例子,讲解了状态模式在实际开发中的应用。状态模式也许是被大家低估的模式之一。实际上,通过状态模式重构代码之后,很多杂乱无章的代码会变得清晰。虽然状态模式一开始并不是非常容易理解,但我们有必须去好好掌握这种设计模式。

适配器模式

定义:

适配器模式的作用是解决两个软件实体间的接口不兼容的问题。使用适配器模式之后,原本由于接口不兼容而不能工作的两个软件实体可以一起工作。

现实中的适配器

电源适配器

Mac book电池支持的电压是 20V,我们日常生活中的交流电压一般是 220V。除了我们了解的 220V交流电压,日本和韩国的交流电压大多是 100V,而英国和澳大利亚的是 240V。笔记本电脑的电源适配器就承担了转换电压的作用,电源适配器使笔记本电脑在 100V~240V 的电压之内都能正常工作,这也是它为什么被称为电源“适配器”的原因。

mark
USB 转接口

在以前的电脑上,PS2接口是连接鼠标、键盘等其他外部设备的标准接口。但随着技术的发展,越来越多的电脑开始放弃了 PS2 接口,转而仅支持 USB 接口。所以那些过去生产出来的只拥有 PS2接口的鼠标、键盘、游戏手柄等,需要一个 USB转接口才能继续正常工作,这是 PS2-USB适配器诞生的原因。

mark

适配器模式的应用

但第三方的接口方法并不在我们自己的控制范围之内,假如 baiduMap 提供的显示地图的方法不叫 show 而叫display 呢?baiduMap 这个对象来源于第三方,正常情况下我们都不应该去改动它。此时我们可以通过增加 baiduMapAdapter 来解决问题:

var googleMap = {
    show: function(){
        console.log( '开始渲染谷歌地图' );
    }
}

var baiduMap = {
    display: function(){
        console.log( '开始渲染百度地图' );
    }
}

var baiduMapAdapter = {
    show: function () {
        return baiduMap.display();
    }
}

renderMap( googleMap ); // 输出:开始渲染谷歌地图
renderMap( baiduMapAdapter ); // 输出:开始渲染百度地图

小结

适配器模式是一对相对简单的模式。在本书提到的设计模式中,有一些模式跟适配器模式的结构非常相似,比如装饰者模式、代理模式和外观模式(参见第 19章)。这几种模式都属于“包装模式”,都是由一个对象来包装另一个对象。区别它们的关键仍然是模式的意图。

  • 适配器模式主要用来解决两个已有接口之间不匹配的问题,它不考虑这些接口是怎样实现的,也不考虑它们将来可能会如何演化。适配器模式不需要改变已有的接口,就能够使它们协同作用。
  • 装饰者模式和代理模式也不会改变原有对象的接口,但装饰者模式的作用是为了给对象增加功能。装饰者模式常常形成一条长的装饰链,而适配器模式通常只包装一次。代理模式是为了控制对对象的访问,通常也只包装一次。
  • 外观模式的作用倒是和适配器比较相似,有人把外观模式看成一组对象的适配器,但外观模式最显著的特点是定义了一个新的接口。

设计原则和编程技巧

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

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

单一责任原则

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

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

何时应该分离职责

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

要明确的是,并不是所有的职责都应该一一分离。

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

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

SRP 原则的优缺点

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

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

最小知识原则

最少知识原则(LKP)说的是一个软件实体应当尽可能少地与其他实体发生相互作用。

这里的软件实体是一个广义的概念,不仅包括对象,还包括系统、类、模块、函数、变量等。本节我们主要针对对象来说明这个原则,下面引用《面向对象设计原理与模式》一书中的例子来解释最少知识原则:

某军队中的将军需要挖掘一些散兵坑。下面是完成任务的一种方式:将军可以通知上校让他叫来少校,然后让少校找来上尉,并让上尉通知一个军士,最后军士唤来一个士兵,然后命令士兵挖掘一些散兵坑。

这种方式十分荒谬,不是吗?不过,我们还是先来看一下这个过程的等价代码:

gerneral.getColonel( c ).getMajor( m ).getCaptain( c ) .getSergeant( s ).getPrivate( p ).digFoxhole();

让代码通过这么长的消息链才能完成一个任务,这就像让将军通过那么多繁琐的步骤才能命令别人挖掘散兵坑一样荒谬!而且,这条链中任何一个对象的改动都会影响整条链的结果。

最有可能的是,将军自己根本就不会考虑挖散兵坑这样的细节信息。但是如果将军真的考虑了这个问题的话,他一定会通知某个军官:“我不关心这个工作如何完成,但是你得命令人去挖散兵坑。”

减少对象之间的联系

单一职责原则指导我们把对象划分成较小的粒度,这可以提高对象的可复用性。但越来越多的对象之间可能会产生错综复杂的联系,如果修改了其中一个对象,很可能会影响到跟它相互引用的其他对象。对象和对象耦合在一起,有可能会降低它们的可复用性。在程序中,对象的“朋友”太多并不是一件好事,“城门失火,殃及池鱼”和“一人犯法,株连九族”的故事时有发生。

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

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

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

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

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

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

原则只是一种指导,没有哪条原则是在实际开发中必须遵守的。比如,虽然遵守最小知识原则减少了对象之间的依赖,但也有可能增加一些庞大到难以维护的第三者对象。跟单一职责原则一样,在实际开发中,是否选择让代码符合最少知识原则,要根据具体的环境来定。

开放-封闭原则

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

开放-封闭原则最早由 Eiffel语言的设计者 Bertrand Meyer在其著作 Object-Oriented Software Construction中提出。它的定义如下:

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

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

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

发布-订阅模式

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

模板方法模式

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

策略模式

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

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

代理模式

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

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

接受第一次愚弄

下面这段话引自 Bob大叔的《敏捷软件开发原则、模式与实践》。

有句古老的谚语说:“愚弄我一次,应该羞愧的是你。再次愚弄我,应该羞愧的是我。”这也是一种有效的对待软件设计的态度。为了防止软件背着不必要的复杂性,我们会允许自己被愚弄一次

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

推荐阅读更多精彩内容