前面的話
在程序設計中,有一些和“事物是由相似的子事物構成”類似的思想。組合模式就是用小的子對象來構建更大的對象,而這些小的子對象本身也許是由更小的“孫對象”構成的。本文將詳細介紹組合模式
宏命令
宏命令對象包含了一組具體的子命令對象,不管是宏命令對象,還是子命令對象,都有一個execute方法負責執行命令。現在回顧一下命令模式中關于萬能遙控器的宏命令代碼:
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();
宏命令中包含了一組子命令,它們組成了一個樹形結構,這里是一棵結構非常簡單的樹

其中,marcoCommand被稱為組合對象,closeDoorCommand、openPcCommand、openQQCommand都是子對象。在macroCommand的execute方法里,并不執行真正的操作,而是遍歷它所包含的子對象,把真正的execute請求委托給這些子對象。macroCommand表現得像一個命令,但它實際上只是一組真正命令的“代理”。并非真正的代理,雖然結構上相似,但macroCommand只負責傳遞請求給子對象,它的目的不在于控制對子對象的訪問
組合模式將對象組合成樹形結構,以表示“部分——整體”的層次結構。除了用來表示樹形結構之外,組合模式的另一個好處是通過對象的多態性表現,使得用戶對單個對象和組合對象的使用具有一致性
1、表示樹形結構。組合模式的優點:提供了一種遍歷樹形結構的方案,通過調用組合對象的execute方法,程序會遞歸調用組合對象下面的子對象的execute方法。組合模式可以非常方便地描述對象部分——整體層次結構
2、利用對象多態性統一對待組合對象和單個對象。利用對象的多態性表現,可以使客戶端忽略組合對象和單個對象的不同。在組合模式中,客戶將統一地使用組合結構中的所有對象,而不需要關心它究竟是組合對象還是單個對象
這在實際開發中會給客戶帶來相當大的便利性,往萬能遙控器里面添加一個命令時,并不關心這個命令是宏命令還是普通子命令。只需要確定它是一個命令,并且這個命令擁有可執行的execute方法,那么這個命令就可以被添加
當宏命令和普通子命令接收到執行execute方法的請求時,宏命令和普通子命令都會做它們各自認為正確的事情。這些差異是隱藏在客戶背后的,在客戶看來,這種透明性可以非常自由地擴展程序
在組合模式中,請求在樹中傳遞的過程總是遵循一種邏輯。以宏命令為例,請求從樹最頂端的對象往下傳遞,如果當前處理請求的對象是子對象(普通子命令),子對象自身會對請求作出相應的處理;如果當前處理請求的對象是組合對象(宏命令),組合對象則會遍歷它屬下的子節點,將請求繼續傳遞給這些子節點
總而言之,如果子節點是子對象,子對象自身會處理這個請求,而如果子節點還是組合對象,請求會繼續往下傳遞。子對象下面不會再有其他子節點,一個子對象就是樹的這條枝葉的盡頭,組合對象下面可能還會有子節點
請求從上到下沿著樹進行傳遞,直到樹的盡頭。作為客戶,只需要關心樹最頂層的組合對象,客戶只需要請求這個組合對象,請求便會沿著樹往下傳遞,依次到達所有的子對象
目前的萬能遙控器,包含了關門、開電腦、登錄QQ這3個命令。現在需要一個“超級萬能遙控器”,可以控制家里所有的電器,這個遙控器擁有以下功能:打開空調、打開電視和音響、關門、開電腦、登錄QQ
首先在節點中放置一個按鈕button來表示這個超級萬能遙控器,超級萬能遙控器上安裝了一個宏命令,當執行這個宏命令時,會依次遍歷執行它所包含的子命令,代碼如下:
<button id="button">按我</button> <script> 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 openAcCommand = { execute: function(){ console.log( '打開空調' ); } }; /**********家里的電視和音響是連接在一起的,所以可以用一個宏命令來組合打開電視和打開音響的命令 *********/ var openTvCommand = { execute: function(){ console.log( '打開電視' ); } }; var openSoundCommand = { execute: function(){ console.log( '打開音響' ); } }; var macroCommand1 = MacroCommand(); 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 = MacroCommand(); macroCommand2.add( closeDoorCommand ); macroCommand2.add( openPcCommand ); macroCommand2.add( openQQCommand ); /*********現在把所有的命令組合成一個“超級命令”**********/ var macroCommand = MacroCommand(); macroCommand.add( openAcCommand ); macroCommand.add( macroCommand1 ); macroCommand.add( macroCommand2 ); /*********最后給遙控器綁定“超級命令”**********/ var setCommand = (function( command ){ document.getElementById( 'button' ).onclick = function(){ command.execute(); } })( macroCommand ); </script>
當按下遙控器的按鈕時,所有命令都將被依次執行
基本對象可以被組合成更復雜的組合對象,組合對象又可以被組合,這樣不斷遞歸下去,這棵樹的結構可以支持任意多的復雜度。在樹最終被構造完成之后,讓整顆樹最終運轉起來的步驟非常簡單,只需要調用最上層對象的execute方法。每當對最上層的對象進行一次請求時,實際上是在對整個樹進行深度優先的搜索,而創建組合對象的程序員并不關心這些內在的細節,往這棵樹里面添加一些新的節點對象是非常容易的事情
組合模式最大的優點在于可以一致地對待組合對象和基本對象。客戶不需要知道當前處理的是宏命令還是普通命令,只要它是一個命令,并且有execute方法,這個命令就可以被添加到樹中
在javascript這種動態類型語言中,對象的多態性是與生俱來的,也沒有編譯器去檢查變量的類型,javascript中實現組合模式的難點在于要保證組合對象和子對象對象擁有同樣的方法,這通常需要用鴨子類型的思想對它們進行接口檢查
在javascript中實現組合模式,看起來缺乏一些嚴謹性,代碼算不上安全,但能更快速和自由地開發,這既是javascript的缺點,也是它的優點
組合模式的透明性使得發起請求的客戶不用去顧忌樹中組合對象和基本對象的區別,但它們在本質上是有區別的
組合對象可以擁有子節點,基本對象下面就沒有子節點,所以也許會發生一些誤操作,比如試圖往基本對象中添加子節點。解決方案通常是給基本對象也增加add方法,并且在調用這個方法時,拋出一個異常來及時提醒客戶
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 openTvCommand = { execute: function(){ console.log( '打開電視' ); }, add: function(){ throw new Error( '基本對象不能添加子節點' ); } }; var macroCommand = MacroCommand(); macroCommand.add( openTvCommand ); openTvCommand.add( macroCommand );
掃描文件夾
文件夾和文件之間的關系,非常適合用組合模式來描述。文件夾里既可以包含文件,又可以包含其他文件夾,最終可能組合成一棵樹,組合模式在文件夾的應用中有以下兩層好處
1、組合模式讓Ctrl+V、Ctrl+C成為了一個統一的操作。例如,在移動硬盤里找到了一些電子書,想把它們復制到F盤中的學習資料文件夾。在復制這些電子書的時候,并不需要考慮這批文件的類型,不管它們是單獨的電子書還是被放在了文件夾中
2、用殺毒軟件掃描該文件夾時,往往不會關心里面有多少文件和子文件夾,組合模式使得只需要操作最外層的文件夾進行掃描
現在來編寫代碼,首先分別定義好文件夾Folder和文件File這兩個類。見如下代碼:
var Folder = function( name ){ this.name = name; this.files = []; }; Folder.prototype.add = function( file ){ this.files.push( file ); }; Folder.prototype.scan = function(){ console.log( '開始掃描文件夾: ' + this.name ); for ( var i = 0, file, files = this.files; file = files[ i++ ]; ){ file.scan(); } }; var File = function( name ){ this.name = name; }; File.prototype.add = function(){ throw new Error( '文件下面不能再添加文件' ); }; File.prototype.scan = function(){ console.log( '開始掃描文件: ' + this.name ); };
接下來創建一些文件夾和文件對象,并且讓它們組合成一棵樹,這棵樹就是F盤里的現有文件目錄結構
var folder = new Folder( '學習資料' ); var folder1 = new Folder( 'JavaScript' ); var folder2 = new Folder ( 'jQuery' ); var file1 = new File( 'JavaScript 設計模式與開發實踐' ); var file2 = new File( '精通jQuery' ); var file3 = new File( '重構與模式' ) folder1.add( file1 ); folder2.add( file2 ); folder.add( folder1 ); folder.add( folder2 ); folder.add( file3 );
現在的需求是把移動硬盤里的文件和文件夾都復制到這棵樹中,假設已經得到了這些文件對象:
var folder3 = new Folder( 'Nodejs' ); var file4 = new File( '深入淺出Node.js' ); folder3.add( file4 ); var file5 = new File( 'JavaScript 語言精髓與編程實踐' );
接下來就是把這些文件都添加到原有的樹中:
folder.add( folder3 );
folder.add( file5 );
通過這個例子,再次看到客戶是如何同等對待組合對象和基本對象的。在添加一批文件的操作過程中,客戶不用分辨它們到底是文件還是文件夾。新增加的文件和文件夾能夠很容易地添加到原來的樹結構中,和樹里已有的對象一起工作。改變了樹的結構,增加了新的數據,卻不用修改任何一句原有的代碼,這是符合開放——封閉原則的
運用了組合模式之后,掃描整個文件夾的操作也是輕而易舉的,只需要操作樹的最頂端對象:
folder.scan();
注意事項
在使用組合模式的時候,還有以下幾個值得注意的地方
1、組合模式不是父子關系組合模式的樹型結構容易讓人誤以為組合對象和基本對象是父子關系。組合模式是一種HAS-A(聚合)的關系,而不是IS-A。組合對象包含一組基本對象,但Leaf并不是Composite的子類。組合對象把請求委托給它所包含的所有基本對象,它們能夠合作的關鍵是擁有相同的接口
2、對子對象操作的一致性。組合模式除了要求組合對象和子對象擁有相同的接口之外,還有一個必要條件,就是對一組子對象的操作必須具有一致性。比如公司要給全體員工發放元旦的過節費1000塊,這個場景可以運用組合模式,但如果公司給今天過生日的員工發送一封生日祝福的郵件,組合模式在這里就沒有用武之地了,除非先把今天過生日的員工挑選出來。只有用一致的方式對待列表中的每個子對象的時候,才適合使用組合模式
3、雙向映射關系。發放過節費的通知步驟是從公司到各個部門,再到各個小組,最后到每個員工的郵箱里。這本身是一個組合模式的好例子,但要考慮的一種情況是,也許某些員工屬于多個組織架構。比如某位架構師既隸屬于開發組,又隸屬于架構組,對象之間的關系并不是嚴格意義上的層次結構,在這種情況下,是不適合使用組合模式的,該架構師很可能會收到兩份過節費。這種復合情況下,必須給父節點和子節點建立雙向映射關系,一個簡單的方法是給小組和員工對象都增加集合來保存對方的引用。但是這種相互間的引用相當復雜,而且對象之間產生了過多的耦合性,修改或者刪除一個對象都變得困難,此時可以引入中介者模式來管理這些對象
4、用職責鏈模式提高組合模式性能。在組合模式中,如果樹的結構比較復雜,節點數量很多,在遍歷樹的過程中,性能方面也許表現得不夠理想。有時候確實可以借助一些技巧,在實際操作中避免遍歷整棵樹,有一種現成的方案是借助職責鏈模式。職責鏈模式一般需要手動去設置鏈條,但在組合模式中,父對象和子對象之間實際上形成了天然的職責鏈。讓請求順著鏈條從父對象往子對象傳遞,或者是反過來從子對象往父對象傳遞,直到遇到可以處理該請求的對象為止,這也是職責鏈模式的經典運用場景之一
引用父對象
組合對象保存了它下面的子節點的引用,這是組合模式的特點,此時樹結構是從上至下的。但有時候需要在子節點上保持對父節點的引用,比如在組合模式中使用職責鏈時,有可能需要讓請求從子節點往父節點上冒泡傳遞。還有當刪除某個文件時,實際上是從這個文件所在的上層文件夾中刪除該文件的
現在來改寫掃描文件夾的代碼,使得在掃描整個文件夾之前,可以先移除某一個具體的文件
首先改寫Folder類和File類,在這兩個類的構造函數中,增加this.parent屬性,并且在調用add方法的時候,正確設置文件或者文件夾的父節點:
var Folder = function( name ){ this.name = name; this.parent = null; //增加this.parent 屬性 this.files = []; }; Folder.prototype.add = function( file ){ file.parent = this; //設置父對象 this.files.push( file ); }; Folder.prototype.scan = function(){ console.log( '開始掃描文件夾: ' + this.name ); for ( var i = 0, file, files = this.files; file = files[ i++ ]; ){ file.scan(); } };
接下來增加Folder.prototype.remove方法,表示移除該文件夾。在Folder.prototype.remove方法里,首先會判斷this.parent,如果this.parent為null,那么這個文件夾要么是樹的根節點,要么是還沒有添加到樹的游離節點,這時候沒有節點需要從樹中移除,暫且讓remove方法直接return,表示不做任何操作。如果this.parent不為null,則說明該文件夾有父節點存在,此時遍歷父節點中保存的子節點列表,刪除想要刪除的子節點
Folder.prototype.remove = function(){ if ( !this.parent ){ //根節點或者樹外的游離節點 return; } for ( var files = this.parent.files, l = files.length - 1; l >=0; l-- ){ var file = files[ l ]; if ( file === this ){ files.splice( l, 1 ); } } };
File類的實現基本一致:
var File = function( name ){ this.name = name; this.parent = null; }; File.prototype.add = function(){ throw new Error( '不能添加在文件下面' ); }; File.prototype.scan = function(){ console.log( '開始掃描文件: ' + this.name ); }; File.prototype.remove = function(){ if ( !this.parent ){ //根節點或者樹外的游離節點 return; } for ( var files = this.parent.files, l = files.length - 1; l >=0; l-- ){ var file = files[ l ]; if ( file === this ){ files.splice( l, 1 ); } } };
下面測試一下移除文件功能:
var folder = new Folder( '學習資料' ); var folder1 = new Folder( 'JavaScript' ); var file1 = new Folder ( '深入淺出Node.js' ); folder1.add( new File( 'JavaScript 設計模式與開發實踐' ) ); folder.add( folder1 ); folder.add( file1 ); folder1.remove(); //移除文件夾 folder.scan();
組合模式如果運用得當,可以大大簡化客戶的代碼。一般來說,組合模式適用于以下這兩種情況
1、表示對象的部分——整體層次結構。組合模式可以方便地構造一棵樹來表示對象的部分——整體結構。特別是在開發期間不確定這棵樹到底存在多少層次的時候。在樹的構造最終完成之后,只需要通過請求樹的最頂層對象,便能對整棵樹做統一的操作。在組合模式中增加和刪除樹的節點非常方便,并且符合開放——封閉原則
2、客戶希望統一對待樹中的所有對象。組合模式使客戶可以忽略組合對象和子對象的區別,客戶在面對這棵樹的時候,不用關心當前正在處理的對象是組合對象還是子對象,也就不用寫一堆if、else語句來分別處理它們。組合對象和子對象會各自做自己正確的事情,這是組合模式最重要的能力
然而,組合模式并不是完美的,它可能會產生一個這樣的系統:系統中的每個對象看起來都與其他對象差不多。它們的區別只有在運行的時候會才會顯現出來,這會使代碼難以理解。此外,如果通過組合模式創建了太多的對象,那么這些對象可能會讓系統負擔不起
文章列表