命令模式
假設有一個快餐店,而我是該快餐店的點餐服務員,那么我一天的工作應該是這樣的:當某位客人點餐或者打來訂餐電話后,我會把他的需求都寫在清單上,然后交給廚房,客人不用關心的是哪些廚師幫他炒菜。我們餐廳還可以滿足客人需要的定時服務,比如客人可能當前正在回家的路上,要求一個小時后才開始炒他的菜,只要訂單還在,廚師就不會忘記。客人也可以很方便地打電話來撤銷訂單。另外如果有太多的客人點餐,廚房可以按照訂單的順序排隊炒菜。
這些記錄着訂餐信息的清單,便是命令模式中的命令對象。
1. 命令模式的用途
命令模式是最簡單和優雅的模式之一,命令模式中的命令指的是一個執行某些特定事情的指令。
命令模式最常見的應用場景是:有時候需要向某些對象發送請求,但是不知道請求的接收者是誰,也不知道被請求的操作是什么。此時希望用一種松耦合的方式來設計程序,使得請求發送者和請求者能夠消除彼此之間的耦合關系。
拿訂餐來說,客人需要向廚師發送請求,但是完全不知道這些廚師的名字和聯系方式,也不知道廚師炒菜的方式和步驟。命令模式把客人訂餐的請求封裝成command對象,也就是訂餐中的訂單對象。這個對象可以在程序中被四處傳遞,就像訂單可以從服務員手中傳到廚師手中。這樣一來,客人不需要知道廚師的名字,從而解開了請求調用者和請求接收者之間的耦合關系。
另外,相對於過程化的請求調用,command對象擁有更長的生命周期。對象的生命周期是跟初始請求無關的,因為這個請求已經被封裝在了command對象的方法中,成為了這個對象的行為。我們可以在程序運行的任意時刻去調用這個方法,就像廚師可以在客人預訂一個小時之后才幫他炒菜,相當於程序在一個小時后才開始執行command對象的方法。除了這兩點之外,命令模式還支持撤銷,排隊等操作。
2. 命令模式的例子——菜單程序
假設我們正在編寫一個用戶界面程序,該用戶界面上至少有數十個button按鈕。因為項目比較復雜,所以我們決定讓某個程序員負責繪制這些按鈕,而另外一些程序員則負責編寫點擊按鈕后的具體行為,這些行為都被封裝在對象里。
在大型項目開發中,這是很正常的分工。對於繪制按鈕的程序員來說,他完全不知道某個按鈕未來將用來做什么,可能用來刷新 菜單界面,也可能用來增加一些子菜單,他只知道點擊這個按鈕會發生某些事情。那么當完成這個按鈕的繪制之后,應該如何給它綁定onclick事件呢?
回想一下命令模式的應用場景:
有時候需要向某些對象發送請求,但是並不知道請求的接收者是誰,也不知道被請求的操作是什么,此時希望用一種松耦合的方式來設計軟件,使得請求發送者和請求接收者能夠消息彼此之間的耦合關系。
我們很快可以找到在這里運用命令模式的理由:點擊了按鈕之后,必須向某些負責具體行為的對象發送請求,這些對象就是請求的接收者。但是目前並不知道接收者是什么對象,也不知道接收者究竟會做什么。此時我們需要借助命令對象的幫助,以便解開按鈕和負責具體行為對象之間的耦合。
設計模式的主題總是把不變的事物和變化的事物分離開來,命令模式也不例外。按下按鈕之后會發生一些事情是不變的,而具體會發生什么事情是可變的。通過command對象的幫助,將來我們可以輕易地改變這種關聯,因此也可以在將來再次改變按鈕的行為。
下面進入代碼編寫階段,首先在頁面中完成這些按鈕的“繪制”:
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml">
<head>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8"/>
<title></title>
</head>
<body>
<button id="button1">按鈕1</button>
<button id="button2">按鈕2</button>
<button id="button3">按鈕3</button>
<script>
var button1 = document.getElementById('button1');
var button2 = document.getElementById('button2');
var button3 = document.getElementById('button3');
</script>
</body>
</html>
接下來定義setCommand函數,setCommand函數負責往按鈕上面安裝命令。可以肯定的是,點擊按鈕會執行某個command命令,執行命令的動作被約定為調用command對象的execute()方法。雖然還不知道這些命令究竟代表什么操作,但負責繪制按鈕的程序員不關心這些事情,他只需要預留好安裝命令的接口,command對象自然知道如何和正確的對象溝通:
var setCommand = function (button, command) {
button.onclick = function () {
command.execute();
}
};
最后負責編寫點擊按鈕之后的具體行為的程序員總算交上了他們的成果,他們完成了刷新菜單界面,增加子菜單和刪除子菜單這幾個功能,這幾個功能被分布在MenuBar和SubMenu這兩個對象中:
var MenuBar = {
refresh: function () {
console.log('刷新菜單目錄');
}
};
var SubMenu = {
add: function () {
console.log('增加子菜單');
},
del: function () {
console.log('刪除子菜單');
}
};
在讓button變得有用之前,我們要先把這些行為都封裝在命令類中:
var RefreshMenuBarCommand = function (receiver) {
this.receiver = receiver;
};
RefreshMenuBarCommand.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 () {
console.log('刪除子菜單');
};
最后就是把命令接收者傳入到command對象中,並且把command對象安裝到button上面:
var refreshMenuBarCommand = new RefreshMenuBarCommand(MenuBar);
var addSubMenuCommand = new AddSubMenuCommand(SubMenu);
var delSubMenuCommand = new DelSubMenuCommand(SubMenu);
setCommand(button1, refreshMenuBarCommand);
setCommand(button2, addSubMenuCommand);
setCommand(button3, delSubMenuCommand);
以上只是一個很簡單的命令模式實例,但從中可以看到我們是如何把請求發送者和請求接收者解耦開的。
2. 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);
bindClick(button2, SubMenu.add);
bindClick(button3, SubMenu.del);
這種說法是正確的,上一節中的實例代碼是模擬傳統面向對象語言的命令模式實現。命令模式將過程式的請求調用封裝在command對象的execute方法里,通過封裝方法調用,我們可以把運算塊包裝成形。command對象可以被四處傳遞,所以在調用命令的時候,客戶不需要關心事情是如何進行的。
命令模式的由來,其實是回調函數的一個面向對象的替代品。
JavaScript作為將函數作為一等對象的語言,跟策略模式一樣,命令模式也早已融入到了JavaScript語言之中。運算塊不一定要封裝在Command.execute方法中,也可以封裝在普通函數中。函數作為一等對象,本身就可以被四處傳遞。即使我們依然需要請求“接收者”,那也未必使用面向對象的方式,閉包可以完成同樣的功能。
在面向對象設計中,命令模式的接受者被當成Command對象的屬性保存起來,同時約定執行命令的操作調用command.execute方法。在使用閉包的命令模式實現中,接受者被封閉在閉包產生的環境中,執行命令的操作可以更加簡單,僅僅執行回調函數即可。無論接受者被保存為對象的屬性,還是被封閉在閉包產生的環境中,在將來執行命令的時候,接受者都能被順利訪問。用閉包實現的命令模式如下代碼所示:
var setCommand = function (button, func) {
button.onclick = function () {
func();
};
};
var MenuBar = {
refresh: function () {
console.log('刷新菜單界面');
}
};
var RefreshMenuBarCommand = function (receiver) {
return function () {
receiver.refresh();
};
};
var refreshMenuBarCommand = RefreshMenuBarCommand(MenuBar);
setCommand(button1, refreshMenuBarCommand);
當然,如果想更明確地表達當前正在使用命令模式,或者除了執行命令之外,將來有可能還要提供撤銷命令等操作。那我們最好還是把執行函數改為調用execute方法:
var setCommand = function (button, command) {
button.onclick = function () {
command.execute();
};
};
var MenuBar = {
refresh: function () {
console.log('刷新菜單界面');
}
};
var RefreshMenuBarCommand = function (receiver) {
return {
execute: function () {
receiver.refresh();
}
};
};
var refreshMenuBarCommand = RefreshMenuBarCommand(MenuBar);
setCommand(button1, refreshMenuBarCommand);
3. 撤銷命令
命令模式的作用不僅是封裝運算塊,而且可以很方便地給命令對象增加撤銷操作。就像訂餐時客人可以通過電話來取消訂單一樣。下面來看撤銷命令的例子。
點擊移動按鈕時,記錄元素的位置后移動,點擊撤銷時,從記錄處拿出上一位置,再移動。
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml">
<head>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8"/>
<title></title>
</head>
<body>
<button id="button1">移動</button>
<button id="button2">撤銷</button>
<div id="div" style="width:50px;height:50px;background:black;position:absolute;left:0px;"></div>
<script>
var button1 = document.getElementById('button1'),
button2 = document.getElementById('button2'),
myDiv = document.getElementById('div'),
arr = []; //保存位置信息
var MoveCommand = function () { };
MoveCommand.prototype.execute = function () {
var pos = parseInt(window.div.getBoundingClientRect().left); //獲得div元素當前位置
arr.push(pos);
pos = pos + 50 + 'px';
myDiv.style.left = pos;
}
MoveCommand.prototype.undo = function () {
myDiv.style.left = arr.pop() + 'px';
};
var command = new MoveCommand;
button1.onclick = function () {
command.execute();
};
button2.onclick = function () {
command.undo();
}
</script>
</body>
</html>
4. 宏命令
宏命令是一組命令的集合,通過執行宏命令的方式,可以一次執行一批命令。想象一下,家里有一個萬能遙控器,每天回家的時候,只要按一個特別的按鈕,它就會幫我們關上房間門,順便打開電腦登上QQ。
下面我們看看如何逐步創建一個宏命令。首先,我們依然要創建好各種Command:
var closeDoorCommand = {
execute: function () {
console.log('關門');
}
};
var openPcCommand = {
execute: function () {
console.log('開電腦');
}
};
var openQQCommand = {
execute: function () {
console.log('上QQ');
}
};
接下來定義宏命令MacroCommand,它的結構也很簡單。macroCommand.add方法表示把子命令添加進宏命令對象當調用宏命令對象的execute方法時,會迭代這一組子命令對象,並且一次執行它們的execute方法:
var MacroCommand = function () {
return {
commandList: [],
add: function (command) {
this.commandList.push(command);
},
execute: function () {
for (var i = 0, command; command = this.commandList[i++];) {
command.execute();
}
}
};
};
var macroCommand = MacroCommand();
macroCommand.add(closeDoorCommand);
macroCommand.add(openPcCommand);
macroCommand.add(openQQCommand);
macroCommand.execute();
宏命令是命令模式和組合模式的聯用產物。
5. 智能命令與傻瓜命令
再看一下我們在上一節創建的命令:
var closeDoorCommand = {
execute: function () {
console.log('關門');
}
};
很奇怪,closeDoorCommand中沒有包含任何receiver的信息,它本身就包攬了執行請求的行為,這跟我們之前看到的命令對象都包含了一個receiver是矛盾的。
一般來說,命令模式都會在command對象中保存一個接收者來負責真正執行客戶的請求,這種情況下命令對象是“傻瓜式”的,它只負責吧客戶的請求轉交給接收者來執行,這種模式的好處是請求發起者和請求接收者之前盡可能地得到了解耦。
但是我們也可以定義一些更“聰明”的命令對象,“聰明”的命令對象可以直接實現請求,這樣一來就不再需要接收者的存在,這種“聰明”的命令對象也叫做智能命令。沒有接收者的智能命令,退化到和策略模式非常相近,從代碼結構上已經無法分辨它們,能分辨它們的只有它們意圖的不同。策略模式指向的問題域更小,所有策略對象的目標總是一致的,它們只是達到這個目標的不同手段,它們的內部實現是正對“算法”而言的。而只能命令模式指向的問題域更廣,command對象解決的目標更具發散性。命令模式還可以完成撤銷,排隊等功能。
參考書目:《JavaScript設計模式與開發實踐》