文章出處

前面的話

  在程序設計中,常常遇到類似的情況,要實現某一個功能有多種方案可以選擇。比如一個壓縮文件的程序,既可以選擇zip算法,也可以選擇gzip算法。這些算法靈活多樣,而且可以隨意互相替換。這種解決方案就是本文將要介紹的策略模式。策略模式是指定義一系列的算法,把它們一個個封裝起來,并且使它們可以相互替換

 

獎金計算

  策略模式有著廣泛的應用。以年終獎的計算為例進行介紹。很多公司的年終獎是根據員工的工資基數和年底績效情況來發放的。例如,績效為S的人年終獎有4倍工資,績效為A的人年終獎有3倍工資,而績效為B的人年終獎是2倍工資

  下面是一個名為calculateBonus的函數來計算每個人的獎金數額。該函數接收兩個參數:員工的工資數額和他的績效考核等級

var calculateBonus = function( performanceLevel, salary ){
    if ( performanceLevel === 'S' ){
        return salary * 4;
    }
    if ( performanceLevel === 'A' ){
        return salary * 3;
    }
    if ( performanceLevel === 'B' ){
        return salary * 2;
    }
};
calculateBonus( 'B', 20000 ); // 輸出:40000
calculateBonus( 'S', 6000 ); // 輸出:24000

  這段代碼十分簡單,但是存在著顯而易見的缺點:該函數比較龐大,包含了很多if-else語句,這些語句需要覆蓋所有的邏輯分支;該函數缺乏彈性,如果增加了一種新的績效等級C,或者想把績效S的獎金系數改為5,必須深入calculateBonus函數的內部實現,違反開放封閉原則;算法復用性差,如果在程序其他地方需要重用這些計算獎金的算法,只能選擇復制粘貼

  下面使用組合函數來重構代碼,把各種算法封裝到一個個的小函數里面,這些小函數有著良好的命名,可以一目了然地知道它對應著哪種算法,它們也可以被復用在程序的其他地方

var performanceS = function( salary ){
    return salary * 4;
};
var performanceA = function( salary ){
    return salary * 3;
};
var performanceB = function( salary ){
    return salary * 2;
};
var calculateBonus = function( performanceLevel, salary ){
    if ( performanceLevel === 'S' ){
        return performanceS( salary );
    }
    if ( performanceLevel === 'A' ){
        return performanceA( salary );
    }
    if ( performanceLevel === 'B' ){
        return performanceB( salary );
    }
};
calculateBonus( 'A' , 10000 ); // 輸出:30000

  目前,程序得到了一定的改善,但這種改善非常有限,依然沒有解決最重要的問題:calculateBonus函數有可能越來越龐大,而且在系統變化的時候缺乏彈性

  策略模式指的是定義一系列的算法,把它們一個個封裝起來。策略模式的目的就是將算法的使用與算法的實現分離開來

  在這個例子里,算法的使用方式是不變的,都是根據某個算法取得計算后的獎金數額。而算法的實現是各異和變化的,每種績效對應著不同的計算規則

  一個基于策略模式的程序至少由兩部分組成。第一個部分是一組策略類,策略類封裝了具體的算法,并負責具體的計算過程。第二個部分是環境類Context,Context接受客戶的請求,隨后把請求委托給某一個策略類。要做到這點,說明Context中要維持對某個策略對象的引用

  下面用策略模式來重構上面的代碼

//定義策略類
var performanceS = function(){};
performanceS.prototype.calculate = function( salary ){
    return salary * 4;
};
var performanceA = function(){};
performanceA.prototype.calculate = function( salary ){
    return salary * 3;
};
var performanceB = function(){};
performanceB.prototype.calculate = function( salary ){
    return salary * 2;
};
//定義獎金類Bonus:
var Bonus = function(){
    this.salary = null; // 原始工資
    this.strategy = null; // 績效等級對應的策略對象
};
Bonus.prototype.setSalary = function( salary ){
    this.salary = salary; // 設置員工的原始工資
};
Bonus.prototype.setStrategy = function( strategy ){
    this.strategy = strategy; // 設置員工績效等級對應的策略對象
};
Bonus.prototype.getBonus = function(){ // 取得獎金數額
    return this.strategy.calculate( this.salary ); // 把計算獎金的操作委托給對應的策略對象
};
var bonus = new Bonus();
bonus.setSalary( 10000 );

bonus.setStrategy( new performanceS() ); // 設置策略對象
console.log( bonus.getBonus() ); // 輸出:40000
bonus.setStrategy( new performanceA() ); // 設置策略對象
console.log( bonus.getBonus() ); // 輸出:30000

 

策略模式

  上面的代碼中,讓strategy對象從各個策略類中創建而來,這是模擬一些傳統面向對象語言的實現。實際上在javascript語言中,函數也是對象,所以更簡單和直接的做法是把strategy直接定義為函數

var strategies = {
    "S": function( salary ){
        return salary * 4;
    },
    "A": function( salary ){
        return salary * 3;
    },
    "B": function( salary ){
        return salary * 2;

    }
};

  同樣,Context也沒有必要必須用Bonus類來表示,用calculateBonus函數充當Context來接受用戶的請求。經過改造,代碼的結構變得更加簡潔

var calculateBonus = function( level, salary ){
    return strategies[ level ]( salary );
};
console.log( calculateBonus( 'S', 20000 ) ); // 輸出:80000
console.log( calculateBonus( 'A', 10000 ) ); // 輸出:30000

 

緩動動畫

  通過使用策略模式重構代碼,消除了原程序中大片的條件分支語句。所有跟計算獎金有關的邏輯不再放在Context 中,而是分布在各個策略對象中。Context并沒有計算獎金的能力,而是把這個職責委托給了某個策略對象。每個策略對象負責的算法已被各自封裝在對象內部。當對這些策略對象發出“計算獎金”的請求時,它們會返回各自不同的計算結果,這正是對象多態性的體現,也是“它們可以相互替換”的目的。替換 Context 中當前保存的策略對象,便能執行不同的算法來得到想要的結果

  有一段時間網頁游戲非常流行,HTML5版本的游戲可以達到不遜于Flash游戲的效果。用javascript實現動畫效果的原理跟動畫片的制作一樣,動畫片是把一些差距不大的原畫以較快的幀數播放,來達到視覺上的動畫效果。在javascript中,可以通過連續改變元素的某個CSS屬性,比如left、top、background-position來實現動畫效果

  目標是編寫一個動畫類和一些緩動算法,讓小球以各種各樣的緩動效果在頁面中運動。 現在來分析實現這個程序的思路。在運動開始之前,需要提前記錄一些有用的信息,至少包括以下信息:動畫開始時,小球所在的原始位置;小球移動的目標位置;動畫開始時的準確時間點;小球運動持續的時間

  隨后,用setInterval創建一個定時器,定時器每隔19ms循環一次。在定時器的每一幀里,把動畫已消耗的時間、小球原始位置、小球目標位置和動畫持續的總時間等信息傳入緩動算法。該算法會通過這幾個參數,計算出小球當前應該所在的位置。最后再更新該div對應的CSS屬性,小球就能夠順利地運動起來了

  在實現完整的功能之前,先了解一些常見的緩動算法,這些算法最初來自Flash,但可以非常方便地移植到其他語言中。這些算法都接受4個參數,這4個參數的含義分別是動畫已消耗的時間、小球原始位置、小球目標位置、動畫持續的總時間,返回的值則是動畫元素應該處在的當前位置。代碼如下:

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

  接下來,開始編寫完整的代碼,首先在頁面中放置一個 div

<div style="position:absolute;background:blue" id="div">我是div</div>

  接下來定義Animate類,Animate的構造函數接受一個參數:即將運動起來的dom節點

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方法負責啟動這個動畫,在動畫被啟動的瞬間,要記錄一些信息,供緩動算法在以后計算小球當前位置的時候使用。在記錄完這些信息之后,此方法還要負責啟動定時器。代碼如下:

Animate.prototype.start = function( propertyName, endPos, duration, easing ){
    this.startTime = +new Date; // 動畫啟動時間
    this.startPos = this.dom.getBoundingClientRect()[ propertyName ]; // dom 節點初始位置
    this.propertyName = propertyName; // dom 節點需要被改變的CSS 屬性名
    this.endPos = endPos; // dom 節點目標位置
    this.duration = duration; // 動畫持續事件
    this.easing = tween[ easing ]; // 緩動算法
    var self = this;
    var timeId = setInterval(function(){ // 啟動定時器,開始執行動畫
        if ( self.step() === false ){ // 如果動畫已結束,則清除定時器
            clearInterval( timeId );
        }
    }, 19 );
};

  Animate.prototype.start方法接受以下4個參數

propertyName:要改變的 CSS 屬性名,比如'left''top',分別表示左右移動和上下移動。
endPos: 小球運動的目標位置。
duration: 動畫持續時間。
easing: 緩動算法

  再接下來是Animate.prototype.step方法,該方法代表小球運動的每一幀要做的事情。在此處,這個方法負責計算小球的當前位置和調用更新CSS屬性值的方法Animate.prototype.update。代碼如下:

Animate.prototype.step = function(){
    var t = +new Date; // 取得當前時間
    if ( t >= this.startTime + this.duration ){ // (1)
        this.update( this.endPos ); // 更新小球的CSS 屬性值
        return false;
    }
    var pos = this.easing( t - this.startTime, this.startPos, this.endPos - this.startPos, this.duration );
    // pos 為小球當前位置
    this.update( pos ); // 更新小球的CSS 屬性值
};

  在這段代碼中,如果當前時間大于動畫開始時間加上動畫持續時間之和,說明動畫已經結束,此時要修正小球的位置。因為在這一幀開始之后,小球的位置已經接近了目標位置,但很可能不完全等于目標位置。此時要主動修正小球的當前位置為最終的目標位置。此外讓Animate.prototype.step方法返回false,可以通知Animate.prototype.start方法清除定時器

  最后是負責更新小球CSS屬性值的Animate.prototype.update方法

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

  下面來進行一些小小的測試:

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

  通過這段代碼,可以看到小球按照期望以各種各樣的緩動算法在頁面中運動

 

表單校驗

  在一個Web項目中,注冊、登錄、修改用戶信息等功能的實現都離不開提交表單。在將用戶輸入的數據交給后臺之前,常常要做一些客戶端力所能及的校驗工作,比如注冊的時候需要校驗是否填寫了用戶名,密碼的長度是否符合規定,等等。這樣可以避免因為提交不合法數據而帶來的不必要網絡開銷。假設正在編寫一個注冊的頁面,在點擊注冊按鈕之前,有如下幾條校驗邏輯:1、用戶名不能為空;2、密碼長度不能少于6位;3、手機號碼必須符合格式

  現在編寫表單校驗的第一個版本

<form action="http://xx.com/register" id="registerForm" method="post">
    請輸入用戶名:<input type="text" name="userName"/ >
    請輸入密碼:<input type="text" name="password"/ >
    請輸入手機號碼:<input type="text" name="phoneNumber"/ >
    <button>提交</button>
</form>
<script>
    var registerForm = document.getElementById( 'registerForm' );
    registerForm.onsubmit = function(){
        if ( registerForm.userName.value === '' ){
            alert ( '用戶名不能為空' );
            return false;
        }
        if ( registerForm.password.value.length < 6 ){
            alert ( '密碼長度不能少于6 位' );
            return false;
        }
        if ( !/(^1[3|5|8][0-9]{9}$)/.test( registerForm.phoneNumber.value ) ){
            alert ( '手機號碼格式不正確' );
            return false;
        }
    }
</script>

  這是一種很常見的代碼編寫方式,它的缺點跟計算獎金的最初版本一模一樣:registerForm.onsubmit函數比較龐大,包含了很多if-else語句,這些語句需要覆蓋所有的校驗規則;registerForm.onsubmit函數缺乏彈性,如果增加了一種新的校驗規則,或者想把密碼的長度校驗從6改成8,都必須深入registerForm.onsubmit函數的內部實現,違反開放封閉原則;算法的復用性差,如果在程序中增加了另外一個表單,這個表單也需要進行一些類似的校驗,那很可能將這些校驗邏輯復制得漫天遍野

  下面用策略模式來重構表單校驗的代碼,很顯然第一步要把這些校驗邏輯都封裝成策略對象

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對象

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對象,然后通過validator.add方法,往validator對象中添加一些校驗規則。validator.add方法接受3個參數,以下面這句代碼說明:

validator.add(registerForm.password,'minLength:6','密碼長度不能少于6位');

  registerForm.password為參與校驗的input輸入框;'minLength:6'是一個以冒號隔開的字符串。冒號前面的minLength代表客戶挑選的strategy對象,冒號后面的數字6表示在校驗過程中所必需的一些參數;'minLength:6'表示校驗registerForm.password這個文本輸入框的value最小長度為6。如果這個字符串中不包含冒號,說明校驗過程中不需要額外的參數信息,比如'isNonEmpty';第3個參數是當校驗未通過時返回的錯誤信息

  往validator對象里添加完一系列的校驗規則之后,會調用validator.start()方法來啟動校驗。如果validator.start()返回了一個確切的errorMsg字符串當作返回值,說明該次校驗沒有通過,此時需讓registerForm.onsubmit方法返回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 );
    });
};

Validator.prototype.start = function(){
    for ( var i = 0, validatorFunc; validatorFunc = this.cache[ i++ ]; ){
        var msg = validatorFunc(); // 開始校驗,并取得校驗后的返回信息
        if ( msg ){ // 如果有確切的返回值,說明校驗沒有通過
            return msg;
        }
    }
};

  使用策略模式重構代碼之后,僅僅通過”配置“的方式就可以完成一個表單的校驗, 這些校驗規則也可以復用在程序的任何地方,還能作為插件的形式,方便地被移植到其他項 目中。在修改某個校驗規則的時候,只需要編寫或者改寫少量的代碼。比如想將用戶名輸入框的校驗規則改成用戶名不能少于10 個字符。可以看到,這時候的修改是毫不費力的。代碼如下:

validator.add( registerForm.userName, 'isNonEmpty', '用戶名不能為空' );
// 改成:
validator.add( registerForm.userName, 'minLength:10', '用戶名長度不能小于 10 位' );

  目前表單校驗實現留有一點小遺憾:一 個文本輸入框只能對應一種校驗規則,比如,用戶名輸入框只能校驗輸入是否為空:

validator.add( registerForm.userName, 'isNonEmpty', '用戶名不能為空' );

  如果既想校驗它是否為空,又想校驗它輸入文本的長度不小于 10 呢?期望以這樣的形式進行校驗:

validator.add( registerForm.userName, [{ strategy: 'isNonEmpty', errorMsg: '用戶名不能為空'}, {
  strategy: 'minLength:6',
  errorMsg: '用戶名長度不能小于 10 位'
}] );

  下面提供的代碼可用于一個文本輸入框對應多種校驗規則:

<form action="http:// xxx.com/register" id="registerForm" method="post">
    請輸入用戶名:<input type="text" name="userName"/ >
    請輸入密碼:<input type="text" name="password"/ >
    請輸入手機號碼:<input type="text" name="phoneNumber"/ >
    <button>提交</button>
</form>
<script>
    /***********************策略對象**************************/
    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 類**************************/
    var Validator = function(){
        this.cache = [];
    };
    Validator.prototype.add = function( dom, rules ){
        var self = this;
        for ( var i = 0, rule; rule = rules[ i++ ]; ){
            (function( rule ){
                var strategyAry = rule.strategy.split( ':' );
                var errorMsg = rule.errorMsg;
                self.cache.push(function(){
                    var strategy = strategyAry.shift();
                    strategyAry.unshift( dom.value );
                    strategyAry.push( errorMsg );
                    return strategies[ strategy ].apply( dom, strategyAry );
                });
            })( rule )
        }
    };
    Validator.prototype.start = function(){
        for ( var i = 0, validatorFunc; validatorFunc = this.cache[ i++ ]; ){
            var errorMsg = validatorFunc();
            if ( errorMsg ){
                return errorMsg;
            }
        }
    };
    /***********************客戶調用代碼**************************/
    var registerForm = document.getElementById( 'registerForm' );
    var validataFunc = function(){
        var validator = new Validator();
        validator.add( registerForm.userName, [{
            strategy: 'isNonEmpty',
            errorMsg: '用戶名不能為空'
        }, {
            strategy: 'minLength:6',
            errorMsg: '用戶名長度不能小于10 位'
        }]);
        validator.add( registerForm.password, [{
            strategy: 'minLength:6',
            errorMsg: '密碼長度不能小于6 位'
        }]);
        var errorMsg = validator.start();
        return errorMsg;
    }
    registerForm.onsubmit = function(){
        var errorMsg = validataFunc();
        if ( errorMsg ){
            alert ( errorMsg );
            return false;
        }

    };
</script>

 

總結

  策略模式是一種常用且有效的設計模式,它利用組合、委托和多態等技術和思想,可以有效地避免多重條件選擇語句。它提供了對開放—封閉原則的完美支持,將算法封裝在獨立的strategy中,使得它們易于切換,易于理解,易于擴展。策略模式中的算法也可以復用在系統的其他地方,從而避免許多重復的復制粘貼工作。.在策略模式中利用組合和委托來讓Context擁有執行算法的能力,這也是繼承的一種更輕便的替代方案

  當然,策略模式也有一些缺點,但這些缺點并不嚴重。首先,使用策略模式會在程序中增加許多策略類或者策略對象,但實際上這比把它們負責的邏輯堆砌在Context中要好。其次,要使用策略模式,必須了解所有的strategy,必須了解各個strategy之間的不同點,這樣才能選擇一個合適的strategy

 


文章列表




Avast logo

Avast 防毒軟體已檢查此封電子郵件的病毒。
www.avast.com


arrow
arrow
    全站熱搜
    創作者介紹
    創作者 大師兄 的頭像
    大師兄

    IT工程師數位筆記本

    大師兄 發表在 痞客邦 留言(0) 人氣()