『面试的底气』—— 设计模式之组合模式

1,245 阅读7分钟

前言

上一篇介绍命令模式中,使用setCommand给按钮添加命令对象后,在执行命令对象中的execute时,只执行了请求接收者对象中的某个方法,那么要执行接收者对象中的多个方法呢?

或许你很容易就想到这么实现。

<body>
  <button id="button">到家了</button>
</body>
<script>
const button = document.getElementById( 'button' ),
const Home = {
  open(){
    console.log( '开门' );
  },
  close(){
    console.log( '关门' );
  },
  openLight(){
    console.log( '打开电灯' );
  },
  openTelevision(){
    console.log( '打开电视' );
  }
};
const HomeCommand = (receiver) =>{
  return {
    execute() {
      receiver.open();
      receiver.close();
      receiver.openLight();
      receiver.openTelevision();
    }
  }
};
const homeCommand = HomeCommand(Home);
const setCommand = (button, command) =>{
  button.onclick = () =>{
    command.execute();
  }
};
setCommand(button, homeCommand);
</script>

如何你这么实现,就违背了单一职责原则,在orderCommand命令对象中execute方法中执行了多个职责,会导致orderCommand命令对象无法在程序中多处使用,比如一处只要执行“开门”和“打开电灯”的命令。不符合命令模式中对命令对象的要求。

用组合模式来解决

把多个命令对象组装成一个命令对象,也称这个命令对象为宏命令。宏命令是一组命令的集合,通过执行宏命令的方式,可以一次执行一批命令。

<body>
  <button id="button">到家了</button>
</body>
<script>
const button = document.getElementById( 'button' ),
const openCommand = {
  execute() {
    console.log('开门');
  }
}
const closeCommand = {
  execute() {
    console.log('关门');
  }
};
const openLightCommand = {
  execute() {
    console.log('开灯');
  }
};
const openTelevisionCommand = {
  execute() {
    console.log('打开电视');
  }
};
const HomeCommand = function () {
  return {
    commandsList: [],
    add(command) {
      this.commandsList.push(command);
    },
    execute() {
      for (var i = 0, command; command = this.commandsList[i++];) {
        command.execute();
      }
    }
  }
};
const homeCommand = HomeCommand();
homeCommand.add(openCommand);
homeCommand.add(closeCommand);
homeCommand.add(openLightCommand);
homeCommand.add(openTelevisionCommand);
const setCommand = (button, command) =>{
  button.onclick = () =>{
    command.execute();
  }
};
setCommand(button, homeCommand);
</script>

在上述代码中假设已经用Home请求接收者对象中的方法创建了openCommand开门命令、closeCommand关门命令、openLightCommand开灯命令、openTelevisionCommand打开电视命令。

HomeCommand函数用来创建一个宏命令homeCommand,用来执行到家后做的一些事情。其中homeCommand.add把要做的事情(执行的命令)添加到宏命令homeCommand中的commandsList命令列表中,然后要执行宏命令homeCommand时,只要执行其execute方法即可,在其中会遍历commandsList命令列表执行其中的命令对象的execute方法,即执行各命令。

以上相当实现了一个非常简单的组合模式。

组合模式的用途

组合模式将对象组合成树形结构,就像上面的例子中,其中,homeCommand称为组合对象,openCommandcloseCommandopenLightCommandopenTelevisionCommand都是叶对象。在homeCommandexecute方法里,并不是真正地执行命令,而是遍历它所包含的叶对象,执行其中的execute方法,才是真正地执行命令。

通过以上,很容易找到组合模式的一个优点:提供了一种遍历树形结构的方案,通过调用组合对象的execute 方法,程序会递归调用组合对象下面的叶对象的execute方法,组合模式可以非常方便地描述对象的部分——整体层次结构。

同时利用对象多态性统一对待组合对象和单各对象。利用对象的多态性表现,可以使用户忽略组合对象和单个对象的不同。在组合模式中,客户端将统一地使用组合结构中的所有对象,而不需要关心它究竟是组合对象还是单个对象。

这会给实际开发带来相当大的便利性,当我们往组合对象里面添加一个叶对象的时候,并不关心这个命令是组合对象还是叶对象。这点对于我们不重要,我们只需要确定这个对象拥有一个和组合对象相同的执行的方法,那么这个对象就可以被添加进组合对象中。

比如在上面的例子中当homeCommand组合对象和openCommand叶对象接收到执行execute方法的请求时,homeCommand组合对象和openCommand叶对象都会做它们各自认为正确的事情。这些差异是隐藏在客户端背后的,在客户端看来,这种透明性可以让我们非常自由地扩展这个homeCommand组合对象。

组合模式中的请求

上面介绍了组合模式将对象组合成树形结构,那么请求在这些对象中传递也要遵循一个规则。

以宏命令为例,请求从树最顶端的对象往下传递,如果当前处理请求的对象是叶对象(普通 子命令),叶对象自身会对请求作出相应的处理;如果当前处理请求的对象是组合对象(宏命令), 组合对象则会遍历它属下的子节点,将请求继续传递给这些子节点。

总而言之,如果子节点是叶对象,叶对象自身会处理这个请求,而如果子节点还是组合对象, 请求会继续往下传递。叶对象下面不会再有其他子节点,一个叶对象就是树的这条枝叶的尽头, 组合对象下面可能还会有子节点。

这样请求从上到下沿着树进行传递,直到树的尽头。作为客户端,只需要关心树最顶层的组合对象, 客户只需要请求这个组合对象,请求便会沿着树往下传递,依次到达所有的叶对象。

使用JavaScript实现组合模式的严谨性

前面说到,组合模式最大的优点在于可以一致地对待组合对象和叶对象。比如在宏命令的例子中,客户端不需要知道当前处理的是宏命令还是普通命令,只要它是一个命令,并且有execute方法,这个命令就可以 被添加到树中。

但是有没有注意到一点,叶对象是不能添加组合对象或叶对象,所以还是区分一下组合对象和叶对象,在叶对象(普通子命令)中也加一个add的方法。例如openCommand命令中

const openCommand = {
  execute() {
    console.log('开门');
  },
  add(){
    throw new Error( '普通命令无法添加子命令' ); 
  }
}

使用组合模式的注意点

1. 组合模式不是父子关系

组合对象只是把请求委托给它所包含的所有叶对象,不是父子关系。

2. 叶对象的操作必须具有一致性

比如公司要给全体员工发送元旦的过节费1000块通知邮件,这个场景可以运用组合模式,但如果公司给今天过生日的员工发送一封生日祝福的邮件,组合模式在这里就没有用武之地了,除非先把今天过生日的员工挑选出来。只有用一致的方式对待列表中的每个叶对象的时候,才适合使用组合模式。

3. 是否需要单一从属关系

比如上面的发过节费的通知步骤是从公司到各个部门,再到各个小组,最后到每个员工的邮箱里。这本身是一个组合模式的好例子,但要考虑的一种情况是,也许某些员工属于多个组织架构。比如某位架构师既隶属于开发组,又隶属于架构组,对象之间的关系并不是严格意义上的层次结构,在这种情况下,是不适合使用组合模式的,该架构师很可能会收到两份过节费通知邮件。应该使用中介模式了。

4. 叶对象保存添加的组合对象的对象

在叶对象中执行方法中可以从组合对象中得到同组的叶对象相关数据。

const openCommand = {
  parent:null,
  execute() {
    console.log(parent.commandsList)// 可获得组合对象中其他叶对象的情况
    console.log('开门');
  },
  add(){
    throw new Error( '普通命令无法添加子命令' ); 
  }
}
const HomeCommand = function () {
  return {
    commandsList: [],
    add(command) {
      command.parent = this;
      this.commandsList.push(command);
    },
    execute() {
      for (let i = 0, command; command = this.commandsList[i++];) {
        command.execute();
      }
    }
  }
};

使用组合模式的场景

组合模式如果运用得当,可以大大简化代码。一般来说,组合模式适用于以下这两种场景。

  • 表示对象的部分--整体层次结构。组合模式可以方便地构造一棵树来表示对象的部分--整体结构。特别是我们在开发期间不确定这棵树到底存在多少层次的时候。在树的构造最终完成之后,只需要通过请求树的最顶层对象,便能对整棵树做统一的操作。在组合模式中增加和删除树的节点非常方便,并且符合开放--封闭原则。

  • 希望统一对待树中的所有对象。组合模式可以忽略组合对象和叶对象的区别,在面对这棵树的时候,不用关心当前正在处理的对象是组合对象还是叶对象,也就不用写一堆 if、else 语句来分别处理它们。组合对象和叶对象会各自做自己正确的事情,这是组合模式最重要的能力。