JavaScript設計模式系列—模式篇(六)命令模式

轉載請註明預見才能遇見的博客:http://my.csdn.net/

原文地址:https://blog.csdn.net/pcaxb/article/details/100553804

JavaScript設計模式系列—模式篇(六)命令模式

目錄

JavaScript設計模式系列—模式篇(六)命令模式

1.1 命令模式的用途

1.2 命令模式的例子——菜單程序

1.3 JavaScript 中的命令模式

1.4 撤銷命令

1.5 撤消和重做

1.6 命令隊列

1.7 宏命令

1.8 智能命令與傻瓜命令

1.9 小結


假設有一個快餐店,而我是該餐廳的點餐服務員,那麼我一天的工作應該是這樣的:當某位客人點餐或者打來訂餐電話後,我會把他的需求都寫在清單上,然後交給廚房,客人不用關心是哪些廚師幫他炒菜。我們餐廳還可以滿足客人需要的定時服務,比如客人可能當前正在回家的路上,要求 1個小時後纔開始炒他的菜,只要訂單還在,廚師就不會忘記。客人也可以很方便地打電話來撤銷訂單。另外如果有太多的客人點餐,廚房可以按照訂單的順序排隊炒菜。這些記錄着訂餐信息的清單,便是命令模式中的命令對象。

1.1 命令模式的用途

命令模式是最簡單和優雅的模式之一,命令模式中的命令(command)指的是一個執行某些特定事情的指令。

命令模式最常見的應用場景是:有時候需要向某些對象發送請求,但是並不知道請求的接收者是誰,也不知道被請求的操作是什麼。此時希望用一種松耦合的方式來設計程序,使得請求發送者和請求接收者能夠消除彼此之間的耦合關係。

拿訂餐來說,客人需要向廚師發送請求,但是完全不知道這些廚師的名字和聯繫方式,也不知道廚師炒菜的方式和步驟。 命令模式把客人訂餐的請求封裝成 command 對象,也就是訂餐中的訂單對象。這個對象可以在程序中被四處傳遞,就像訂單可以從服務員手中傳到廚師的手中。這樣一來,客人不需要知道廚師的名字,從而解開了請求調用者和請求接收者之間的耦合關係。

另外,相對於過程化的請求調用, command 對象擁有更長的生命週期。對象的生命週期是跟初始請求無關的,因爲這個請求已經被封裝在了 command 對象的方法中,成爲了這個對象的行爲。我們可以在程序運行的任意時刻去調用這個方法,就像廚師可以在客人預定 1個小時之後才幫他炒菜,相當於程序在 1個小時之後纔開始執行 command 對象的方法。

1.2 命令模式的例子——菜單程序

假設我們正在編寫一個用戶界面程序,該用戶界面上至少有數十個 Button 按鈕。因爲項目比較複雜,所以我們決定讓某個程序員負責繪製這些按鈕,而另外一些程序員則負責編寫點擊按鈕後的具體行爲,這些行爲都將被封裝在對象裏。

在大型項目開發中,這是很正常的分工。對於繪製按鈕的程序員來說,他完全不知道某個按鈕未來將用來做什麼,可能用來刷新菜單界面,也可能用來增加一些子菜單,他只知道點擊這個按鈕會發生某些事情。那麼當完成這個按鈕的繪製之後,應該如何給它綁定 onclick 事件呢?

回想一下命令模式的應用場景:
有時候需要向某些對象發送請求,但是並不知道請求的接收者是誰,也不知道被請求的操作是什麼,此時希望用一種松耦合的方式來設計軟件,使得請求發送者和請求接收者能夠消除彼此之間的耦合關係。

我們很快可以找到在這裏運用命令模式的理由:點擊了按鈕之後,必須向某些負責具體行爲的對象發送請求,這些對象就是請求的接收者。但是目前並不知道接收者是什麼對象,也不知道接收者究竟會做什麼。此時我們需要藉助命令對象的幫助,以便解開按鈕和負責具體行爲對象之間的耦合。

設計模式的主題總是把不變的事物和變化的事物分離開來,命令模式也不例外。按下按鈕之後會發生一些事情是不變的,而具體會發生什麼事情是可變的。通過 command 對象的幫助,將來我們可以輕易地改變這種關聯,因此也可以在將來再次改變按鈕的行爲。

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

	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( '刪除子菜單' );
		}
	};
	// 在讓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( '刪除子菜單' );
	};

	var refreshMenuBarCommand = new RefreshMenuBarCommand( MenuBar );
	var addSubMenuCommand = new AddSubMenuCommand( SubMenu );
	var delSubMenuCommand = new DelSubMenuCommand( SubMenu );
	setCommand( button1, refreshMenuBarCommand );
	setCommand( button2, addSubMenuCommand );
	setCommand( button3, delSubMenuCommand );
</script>

以上只是一個很簡單的命令模式示例,但從中可以看到我們是如何把請求發送者和請求接收者解耦開的。

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

這種說法是正確的,9.2 節中的示例代碼是模擬傳統面嚮對象語言的命令模式實現。命令模式將過程式的請求調用封裝在 command 對象的 execute 方法裏,通過封裝方法調用,我們可以把運算塊包裝成形。 command 對象可以被四處傳遞,所以在調用命令的時候,客戶(Client)不需要關心事情是如何進行的。

命令模式的由來,其實是回調( callback )函數的一個面向對象的替代品。

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 RefreshMenuBarCommand = function( receiver ){
	return {
		execute: function(){
			receiver.refresh();
		}
	}
};
var setCommand = function( button, command ){
	button.onclick = function(){
		command.execute();
	}
};
var refreshMenuBarCommand = RefreshMenuBarCommand( MenuBar );
setCommand( button1, refreshMenuBarCommand );

 

1.4 撤銷命令

命令模式的作用不僅是封裝運算塊,而且可以很方便地給命令對象增加撤銷操作。就像訂餐時客人可以通過電話來取消訂單一樣。下面來看撤銷命令的例子。本節的目標是利用 5.4 節中的 Animate 類來編寫一個動畫,這個動畫的表現是讓頁面上的小球移動到水平方向的某個位置。現在頁面中有一個 input 文本框和一個 button 按鈕,文本框中可以輸入一些數字,表示小球移動後的水平位置,小球在用戶點擊按鈕後立刻開始移動,代碼如下:

<body>
	<div id="ball" style="position:absolute;background:#000;width:50px;height:50px"></div>
	輸入小球移動後的位置:<input id="pos"/>
	<button id="moveBtn">開始移動</button>
	<button id="cancelBtn">cancel</cancel> <!--增加取消按鈕-->
</body>

var ball = document.getElementById( 'ball' );
var pos = document.getElementById( 'pos' );
var moveBtn = document.getElementById( 'moveBtn' );
moveBtn.onclick = function(){
	var animate = new Animate( ball );
	animate.start( 'left', pos.value, 1000, 'strongEaseOut' );
};

頁面上最好有一個撤銷按鈕,點擊撤銷按鈕之後,小球便能回到上一次的位置。在給頁面中增加撤銷按鈕之前,先把目前的代碼改爲用命令模式實現:

var ball = document.getElementById( 'ball' );
var pos = document.getElementById( 'pos' );
var moveBtn = document.getElementById( 'moveBtn' );

var MoveCommand = function( receiver, pos ){
	this.receiver = receiver;
	this.pos = pos;
};
MoveCommand.prototype.execute = function(){
	this.receiver.start( 'left', this.pos, 1000, 'strongEaseOut' );
};
var moveCommand;
moveBtn.onclick = function(){
	var animate = new Animate( ball );
	moveCommand = new MoveCommand( animate, pos.value );
	moveCommand.execute();
};

撤銷操作的實現一般是給命令對象增加一個名爲 unexecude 或者 undo 的方法,在該方法裏執行 execute 的反向操作。在 command.execute 方法讓小球開始真正運動之前,我們需要先記錄小球的當前位置,在 unexecude 或者 undo 操作中,再讓小球回到剛剛記錄下的位置,代碼如下:

<script>
	var ball = document.getElementById( 'ball' );
	var pos = document.getElementById( 'pos' );
	var moveBtn = document.getElementById( 'moveBtn' );
	var cancelBtn = document.getElementById( 'cancelBtn' );
	var MoveCommand = function( receiver, pos ){
		this.receiver = receiver;
		this.pos = pos;
		this.oldPos = null;
	};
	MoveCommand.prototype.execute = function(){
		this.receiver.start( 'left', this.pos, 1000, 'strongEaseOut' );
		this.oldPos = this.receiver.dom.getBoundingClientRect()[ this.receiver.propertyName ];
			// 記錄小球開始移動前的位置
		};

		MoveCommand.prototype.undo = function(){
			this.receiver.start( 'left', this.oldPos, 1000, 'strongEaseOut' );
	// 回到小球移動前記錄的位置
};
var moveCommand;

moveBtn.onclick = function(){
	var animate = new Animate( ball );
	moveCommand = new MoveCommand( animate, pos.value );
	moveCommand.execute();
};
cancelBtn.onclick = function(){
		moveCommand.undo(); // 撤銷命令
	};
</script>

現在通過命令模式輕鬆地實現了撤銷功能。如果用普通的方法調用來實現,也許需要每次都手工記錄小球的運動軌跡,才能讓它還原到之前的位置。而命令模式中小球的原始位置在小球開始移動前已經作爲 command 對象的屬性被保存起來,所以只需要再提供一個 undo 方法,並且在 undo方法中讓小球回到剛剛記錄的原始位置就可以了。

1.5 撤消和重做

很多時候,我們需要撤銷一系列的命令。比如在一個圍棋程序中,現在已經下了 10 步棋,我們需要一次性悔棋到第 5 步。在這之前,我們可以把所有執行過的下棋命令都儲存在一個歷史列表中,然後倒序循環來依次執行這些命令的 undo 操作,直到循環執行到第 5個命令爲止。

然而,在某些情況下無法順利地利用 undo 操作讓對象回到 execute 之前的狀態。比如在一個Canvas 畫圖的程序中,畫布上有一些點,我們在這些點之間畫了 N 條曲線把這些點相互連接起來,當然這是用命令模式來實現的。但是我們卻很難爲這裏的命令對象定義一個擦除某條曲線的undo 操作,因爲在 Canvas畫圖中,擦除一條線相對不容易實現。

這時候最好的辦法是先清除畫布,然後把剛纔執行過的命令全部重新執行一遍,這一點同樣可以利用一個歷史列表堆棧辦到。記錄命令日誌,然後重複執行它們,這是逆轉不可逆命令的一個好辦法。

用命令模式來實現播放錄像功能,播放錄像的時候只需要從頭開始依次執行這些命令便可,代碼如下:

<body>
	<button id="replay">播放錄像</button>
</body>
<script>
	var Ryu = {
		attack: function(){
			console.log( '攻擊' );
		},
		defense: function(){
			console.log( '防禦' );
		},
		jump: function(){
			console.log( '跳躍' );
		},
		crouch: function(){
			console.log( '蹲下' );
		}
	};

	var makeCommand = function( receiver, state ){ // 創建命令
		return function(){
			receiver[ state ]();
		}
	};
	var commands = {
		"119": "jump", // W
		"115": "crouch", // S
		"97": "defense", // A
		"100": "attack" // D
	};

	var commandStack = []; // 保存命令的堆棧
	document.onkeypress = function( ev ){
		var keyCode = ev.keyCode,
		command = makeCommand( Ryu, commands[ keyCode ] );
		if ( command ){
			command(); // 執行命令
			commandStack.push( command ); // 將剛剛執行過的命令保存進堆棧
		}
	};

	document.getElementById( 'replay' ).onclick = function(){ // 點擊播放錄像
		var command;
		while( command = commandStack.shift() ){ // 從堆棧裏依次取出命令並執行
			command();
		}
	};
</script>

可以看到,當我們在鍵盤上敲下 W、A、S、D這幾個鍵來完成一些動作之後,再按下 Replay按鈕,此時便會重複播放之前的動作。

1.6 命令隊列

在訂餐的故事中,如果訂單的數量過多而廚師的人手不夠,則可以讓這些訂單進行排隊處理。第一個訂單完成之後,再開始執行跟第二個訂單有關的操作。

隊列在動畫中的運用場景也非常多,比如之前的小球運動程序有可能遇到另外一個問題:有些用戶反饋,這個程序只適合於 APM小於 20的人羣,大部分用戶都有快速連續點擊按鈕的習慣,當用戶第二次點擊 button的時候,此時小球的前一個動畫可能尚未結束,於是前一個動畫會驟然停止,小球轉而開始第二個動畫的運動過程。但這並不是用戶的期望,用戶希望這兩個動畫會排隊進行。

把請求封裝成命令對象的優點在這裏再次體現了出來,對象的生命週期幾乎是永久的,除非我們主動去回收它。也就是說,命令對象的生命週期跟初始請求發生的時間無關, command 對象的 execute 方法可以在程序運行的任何時刻執行,即使點擊按鈕的請求早已發生,但我們的命令對象仍然是有生命的。

所以我們可以把 div的這些運動過程都封裝成命令對象,再把它們壓進一個隊列堆棧,當動畫執行完,也就是當前 command 對象的職責完成之後,會主動通知隊列,此時取出正在隊列中等待的第一個命令對象,並且執行它。

我們比較關注的問題是,一個動畫結束後該如何通知隊列。通常可以使用回調函數來通知隊列,除了回調函數之外,還可以選擇發佈訂閱模式。即在一個動畫結束後發佈一個消息,訂閱者接收到這個消息之後,便開始執行隊列裏的下一個動畫。讀者可以嘗試按照這個思路來自行實現一個隊列動畫。

1.7 宏命令

宏命令是一組命令的集合,通過執行宏命令的方式,可以一次執行一批命令。想象一下,家裏有一個萬能遙控器,每天回家的時候,只要按一個特別的按鈕,它就會幫我們關上房間門,順便打開電腦並登錄 QQ。下面我們看看如何逐步創建一個宏命令。首先,我們依然要創建好各種 Command :

var closeDoorCommand = {
	execute: function(){
		console.log( '關門' );
	}
};
var openPcCommand = {
	execute: function(){
		console.log( '開電腦' );
	}
};

var openQQCommand = {
	execute: function(){
		console.log( '登錄QQ' );
	}
};

var MacroCommand = function(){
	return {
		commandsList: [],
		add: function( command ){
			this.commandsList.push( command );
		},
		execute: function(){
			for ( var i = 0, command; command = this.commandsList[ i++ ]; ){
				command.execute();
			}
		}
	}
};
var macroCommand = MacroCommand();
macroCommand.add( closeDoorCommand );
macroCommand.add( openPcCommand );
macroCommand.add( openQQCommand );
macroCommand.execute();

當然我們還可以爲宏命令添加撤銷功能,跟 macroCommand.execute 類似,當調用macroCommand.undo 方法時,宏命令裏包含的所有子命令對象要依次執行各自的 undo 操作。宏命令是命令模式與組合模式的聯用產物。

1.8 智能命令與傻瓜命令

 再看一下我們在 9.7節創建的命令:

var closeDoorCommand = {
	execute: function(){
		console.log( '關門' );
	}
};

很奇怪, closeDoorCommand 中沒有包含任何 receiver 的信息,它本身就包攬了執行請求的行爲,這跟我們之前看到的命令對象都包含了一個 receiver 是矛盾的。

一般來說,命令模式都會在 command 對象中保存一個接收者來負責真正執行客戶的請求,這種情況下命令對象是“傻瓜式”的,它只負責把客戶的請求轉交給接收者來執行,這種模式的好處是請求發起者和請求接收者之間儘可能地得到了解耦。

但是我們也可以定義一些更“聰明”的命令對象,“聰明”的命令對象可以直接實現請求,這樣一來就不再需要接收者的存在,這種“聰明”的命令對象也叫作智能命令。沒有接收者的智能命令,退化到和策略模式非常相近,從代碼結構上已經無法分辨它們,能分辨的只有它們意圖的不同。策略模式指向的問題域更小,所有策略對象的目標總是一致的,它們只是達到這個目標的不同手段,它們的內部實現是針對“算法”而言的。而智能命令模式指向的問題域更廣, command對象解決的目標更具發散性。命令模式還可以完成撤銷、排隊等功能。

1.9 小結

 本章我們學習了命令模式。跟許多其他語言不同,JavaScript 可以用高階函數非常方便地實現命令模式。命令模式在 JavaScript語言中是一種隱形的模式。

 

JavaScript設計模式系列—模式篇(六)命令模式

博客地址:https://blog.csdn.net/pcaxb/article/details/100553804

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章