今天要介紹的是 Command 模式,這是 GoF 定義的行為型模式之一。
開發複雜應用程式時,開發者會需要一個靈活的架構來處理各種操作或功能,這些操作可能來自不同模組或元件。操作某功能的觸發來源可能有很多種,例如按鈕、語音命令、鍵盤輸入等,在這些狀況下,操作的邏輯通常會直接由觸發來源控制。以音樂播放器應用程式為例,使用者可能會透過按鈕、語音命令或鍵盤輸入來控制音樂的播放、暫停或音量調整等功能。
當使用者執行某操作時,開發者希望能封裝該功能,以便更容易地管理、儲存或重用這些操作,同時也希望保持操作間的靈活性,使不同操作間互不干擾。此外,開發者還希望能輕鬆的修改操作的具體實現,例如修改操作的名稱或參數等,而不需要對系統的其他部分進行大量修改。
如何設計一個系統,使不同操作能獨立於其觸發來源進行處理,同時能在必要時修改操作的具體實現?此外,如何確保這些操作能被有效封裝以便管理或重用,並且在不干擾系統其他部分的情況下修改?
在實際設計過程中,開發者需根據具體的應用場景來權衡這些因素,確保系統能既有彈性又穩定,同時保持相對簡單的結構。
Command 模式的核心理念是「提供一種方法,將命令的發出與執行分開處理」,也就是由不同的對象負責發出命令與執行命令的邏輯。為了實現這點,我們可以將方法的呼叫、請求或運算封裝到一個單一物件中,此物件能接收各種參數,以彈性的執行對應操作。
先來看一個沒有 Command 模式的範例。假設現在有個電商的應用程式,需要一個購物車管理系統來管理購物車的相關操作,使用者可加入購物車、移除購物車商品以及查看購物車,並且這些操作會在應用程式的不同地方被觸發,例如在商品詳細頁或商品分類頁都可加入購物車,在購物車詳細頁可以查看購物車內容或刪除商品。
我們定義一個 cartManager
物件來包含這些操作:
const cartManager = {
cart: [],
addItemToCart(item, id) {
this.cart.push({ id, item });
return `You have successfully added ${item} (${id}) to your cart.`;
},
removeItemFromCart(id) {
const initialLength = this.cart.length;
this.cart = this.cart.filter(item => item.id !== id);
return this.cart.length < initialLength
? `Item (${id}) has been removed from your cart.`
: `Item with ID ${id} not found in your cart.`;
},
viewCart() {
return this.cart.length === 0
? "Your cart is empty."
: `Your cart contains: ${this.cart.map(item => item.item).join(", ")}.`;
}
};
可這樣使用:
console.log(cartManager.addItemToCart("Laptop", "001")); // 加入購物車
console.log(cartManager.addItemToCart("Mouse", "002")); // 加入購物車
console.log(cartManager.viewCart()); // 查看購物車內容
console.log(cartManager.removeItemFromCart("002")); // 移除購物車商品
console.log(cartManager.viewCart()); // 查看購物車內容
目前的購物車管理系統可正常運作👌,但有一個潛在的問題:這種寫法緊密耦合了 cartManager
的方法與應用程式的其他部分。何謂緊密耦合? 意思是,如果 cartManager
中的方法名稱或參數有改變,則應用程式中所有直接呼叫這些方法的程式碼都需要更新。舉例來說,如果我們將 viewCart
方法重命名為 getCartInfo
,那專案中所有使用 cartManager.viewCart
的地方都需要修改為 cartManager.getCartInfo
,否則就會出錯。
這種緊密耦合違背了「盡可能鬆散耦合」的設計原則,導致程式碼維護較困難。因此我們需要一個更好的方式來解耦他們,而這就是 Command 模式派上用場的地方~
接下來就來看看如何以 Command 模式來優化。
我們希望可以在 cartManager
物件上執行任何命名的方法,並傳遞任何可能使用的資料/參數,預期的呼叫方式:
cartManager.execute('addItemToCart', 'Laptop', '001');
execute
實作方式如下:
cartManager.execute = function(name) {
return (
cartManager[name] &&
cartManager[name].apply(cartManager, [].slice.call(arguments, 1))
);
};
這樣我們就可以改用 execute
的方式呼叫購物車管理系統,並根據需要傳入想要的方法名稱參數。
console.log(cartManager.execute('addItemToCart', 'Laptop', '001')); // 加入購物車
console.log(cartManager.execute('addItemToCart', 'Mouse', '002')); // 加入購物車
console.log(cartManager.execute('viewCart')); // 查看購物車內容
console.log(cartManager.execute('removeItemFromCart', '002')); // 移除購物車商品
console.log(cartManager.execute('viewCart')); // 查看購物車內容
最近偶然發現一個 Web API 叫 execCommand()
,發現它的運作方式其實與上面提到的 Command 解決方式非常類似,我們可以將想要執行的操作名稱傳入 execCommand()
,瀏覽器就會自動執行相應的操作。例如,如果要將一段文字複製到剪貼簿,可以這樣使用:
document.execCommand('copy');
不過目前這個 API 已被標記為 Deprecated,不建議大家再繼續使用這個方法,但還是提出來介紹給大家,讓大家了解 Command 模式的實際應用案例。
如果想了解更多關於 execCommand()
的資訊,可以參考官方文件。
以 Command 作為解決方案優點如下:
以 Command 作為解決方案缺點如下: