设计模式:有助于提高代码的复用性和可维护性
组合模式将对象组合成树形结构,以表示“部分-整体”的层次结构。 除了用来表示树形结构之外,组合模式的另一个好处是通过对象的多态性表现,使得用户对单个对象和组合对象的使用具有一致性
宏命令:对象包含了一组具体的字命令对象,不管是宏命令对象,还是字命令对象,都有一个execute方法负责执行命令。现在看一下这段安装在万能遥控器上的宏命令代码
// 新建一个关门的命令
var closeDoorCommand = {
execute: function(){
console.log( '关门' );
}
};
// 新建一个开电脑的命令
var openPcCommand = {
execute: function(){
console.log( '开电脑' );
}
};
// 登陆QQ的命令
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都是叶对象,在marcoCommand的execute方法里,并不执行真正的操作,而是遍历它所包含的叶对象,把真正的execute请求委托给这些叶对象
请求在树中传递的过程
在组合模式中,请求在树中传递的过程总是遵循一种逻辑
以宏命令为例,请求从树最顶端的对象往下传递,如果当前处理请求的对象是叶对象(普通的子命令),叶对象自身会对请求作出相应对处理,如果当前处理请求对对象是组合对象(宏命令),组合模式则会遍历它属下对子节点,将请求继续传递给这些子节点
更强大的宏命令
目前的万能遥控器,包含了关门、开电脑、登录QQ这3个命令。现在我们需要一个“超级万能遥控器”,可以控制家里所有的电器,这个遥控器拥有以下功能:
1、打开空调
2、打开电视和音响
3、关门、开电脑、登录QQ
首先在节点中放置一个按钮button来表示这个超级万能遥控器,超级万能遥控器上安装了一个宏命令,当执行这个宏命令时,会依次遍历执行它所包含的子命令,代码如下:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
</head>
<body>
<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>
</html>
执行结果如下
从这个例子中可以看到,基本对象可以被组合成更复杂的组合对象,组合对象又可以被组合,这样不断递归下去,这棵树的结构可以支持任意多的复杂度。在树最终被构造完成之后,让整颗树最终运转起来的步骤非常简单,只需要调用最上层对象的execute方法。每当对最上层的对象进行一次请求时,实际上是在对整个树进行深度优先的搜索,而创建组合对象的程序员并不关心这些内在的细节,往这棵树里面添加一些新的节点对象是非常容易的事情
扫描文件夹
文件夹和文件之间的关系,非常适合用组合模式来描述。文件夹里既可以包含文件,又可以 包含其他文件夹,最终可能组合成一棵树
当使用用杀毒软件扫描该文件夹时,往往不会关心里面有多少文件和子文件夹,组合模式使得我们只需要操作最外层的文件夹进行扫描。
现在我们来编写代码,首先分别定义好文件夹Folder和文件File这两个类。见如下代码:
/* Folder */
class Folder{
constructor(name){
this.name = name;
this.files = [];
}
add(file){
this.files.push(file );
}
scan(){
console.log( '开始扫描文件夹: ' + this.name );
for ( var i = 0; i<this.files.length; i++){
this.files[i].scan();
}
}
}
/*File*/
class File{
constructor(name){
this.name = name;
}
add(){
throw new Error( '文件下面不能再添加文件' );
}
scan(){
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.组合模式不是父子关系
组合模式的树型结构容易让人误以为组合对象和叶对象是父子关系,这是不正确的。
2.对叶对象操作的一致性
组合模式除了要求组合对象和叶对象拥有相同的接口之外,还有一个必要条件,就是对一组叶对象的操作必须具有一致性。
引用父对象
在之前提到的例子中,组合对象保存了它下面的子节点的引用,这是组合模式的特点,此时树结构是从上至下的。但有时候我们需要在子节点上保持对父节点的引用,比如在组合模式中使用职责链时,有可能需要让请求从子节点往父节点上冒泡传递。还有当我们删除某个文件的时候,实际上是从这个文件所在的上层文件夹中删除该文件的。
现在来改写扫描文件夹的代码,使得在扫描整个文件夹之前,我们可以先移除某一个具体的文件。
首先改写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 = 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.prototype.remove方法里,首先会判断this.parent,如果this.parent为null,那么这个文件夹要么是树的根节点,要么是还没有添加到树的游离节点,这时候没有节点需要从树中移除,我们暂且让remove方法直接return,表示不做任何操作。
如果this.parent不为null,则说明该文件夹有父节点存在,此时遍历父节点中保存的子节点列表,删除想要删除的子节点。
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();
执行结果如图
何时使用组合模式
组合模式如果运用得当,可以大大简化客户的代码。一般来说,组合模式适用于以下这两种情况。
表示对象的部分-整体层次结构。组合模式可以方便地构造一棵树来表示对象的部分-整体结构。特别是我们在开发期间不确定这棵树到底存在多少层次的时候。在树的构造最终完成之后,只需要通过请求树的最顶层对象,便能对整棵树做统一的操作。在组合模式中增加和删除树的节点非常方便,并且符合开放-封闭原则。
客户希望统一对待树中的所有对象。组合模式使客户可以忽略组合对象和叶对象的区别,客户在面对这棵树的时候,不用关心当前正在处理的对象是组合对象还是叶对象,也就不用写一堆if、else语句来分别处理它们。组合对象和叶对象会各自做自己正确的事情,这是组合模式最重要的能力。