您现在的位置是:首页 >其他 >设计模式之组合模式网站首页其他

设计模式之组合模式

开心就是最好 2023-05-23 16:00:02
简介设计模式之组合模式

参考资料

定义

组合模式就是用小的子对象来构建成更大的对象,而这些小的子对象本身也许是更小的“孙对象”构成。

又叫 “部分整体” 模式,将对象组合成树形结构,以表示 “部分-整体” 的层次结构。通过对象的多态性表现,使得用户对单个对象和组合对象的使用具有一致性。

使用场景:

  • 文件目录;
  • DOM 文档树;
  • Tree组件;

模式特点

  1. 表示 “部分-整体” 的层次结构,生成 “树叶型” 结构;

  2. 一致操作性,树叶对象对外接口保存一致(操作与数据结构一致);

  3. 自上而下的的请求流向,从树对象传递给叶对象;

  4. 调用顶层对象,会自行遍历其下的叶对象执行。

在这里插入图片描述

代码实现

我们假设定义了若干个对象,需要去调用对应的execute函数来执行对应的命令,举一个例子:

var closeDoorCommand = {
    execute: function(){
        console.log( '关门' );
    }
}

var openPCCommand = {
    execute: function(){
        console.log( '开电脑' );
    }
}

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

上面我们定义了三个命令,分别为关门、开电脑、登陆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();
                }
        }
    }
};

接下来,我们执行如下操作,将三个子命令添加到MacroCommand中:

var macroCommand = MacroCommand()

macroCommand.add(closeDoorCommand)
macroCommand.add(openPCCommand)
macroCommand.add(openQQCommand)

macroCommand.execute()

观察代码,我们会发现,宏命令子命令组成了一个树形结构。macroCommand对象称为组合对象closeDoorCommand、openPCCommand、openQQCommand都是叶对象。

execute执行时,函数将会遍历commandsList中的所有子命令,并调用每个子命令的execute方法。而这种模式在开发中会带来相当大的便利性,因为,当我们新增一个命令时,我们并不需要关心他是宏命令还是子命令,只要关心他是否拥有可执行的execute函数就可以了。

我们发现,如果节点是叶对象,那么将自行处理execute,如果子节点还是组合对象,那么将继续向下传递,直到树的尽头。

所以,可以总结出:组合模式的核心是通过递归的方式访问整个树形结构

文件目录

树对象和叶对象接口统一,树对象增加一个缓存数组,存储叶对象。执行树对象方法时,将请求传递给其下叶对象执行。

// 树对象 - 文件目录
class CFolder {
    constructor(name) {
        this.name = name;
        this.files = [];
    }

    add(file) {
        this.files.push(file);
    }

    scan() {
        for (let file of this.files) {
            file.scan();
        }
    }
}

// 叶对象 - 文件
class CFile {
    constructor(name) {
        this.name = name;
    }

    add(file) {
        throw new Error('文件下面不能再添加文件');
    }

    scan() {
        console.log(`开始扫描文件:${this.name}`);
    }
}

let mediaFolder = new CFolder('娱乐');
let movieFolder = new CFolder('电影');
let musicFolder = new CFolder('音乐');

let file1 = new CFile('钢铁侠.mp4');
let file2 = new CFile('再谈记忆.mp3');
movieFolder.add(file1);
musicFolder.add(file2);
mediaFolder.add(movieFolder);
mediaFolder.add(musicFolder);
mediaFolder.scan();

/* 输出:
开始扫描文件:钢铁侠.mp4
开始扫描文件:再谈记忆.mp3
*/

CFolderCFile 接口保持一致。执行 scan() 时,若发现是树对象,则继续遍历其下的叶对象,执行 scan()

JavaScript 不同于其它静态编程语言,实现组合模式的难点是保持树对象与叶对象之间接口保持统一,可借助 TypeScript 定制接口规范,实现类型约束。

// 定义接口规范
interface Compose {
    name: string,
    add(file: CFile): void,
    scan(): void
}

// 树对象 - 文件目录
class CFolder implements Compose {
    fileList = [];
    name: string;

    constructor(name: string) {
        this.name = name;
    }

    add(file: CFile) {
        this.fileList.push(file);
    }

    scan() {
        for (let file of this.fileList) {
            file.scan();
        }
    }
}

// 叶对象 - 文件
class CFile implements Compose {
    name: string;

    constructor(name: string) {
        this.name = name;
    }

    add(file: CFile) {
        throw new Error('文件下面不能再添加文件');
    }

    scan() {
        console.log(`开始扫描:${this.name}`)
    }
}

let mediaFolder = new CFolder('娱乐');
let movieFolder = new CFolder('电影');
let musicFolder = new CFolder('音乐');

let file1 = new CFile('钢铁侠.mp4');
let file2 = new CFile('再谈记忆.mp3');
movieFolder.add(file1);
musicFolder.add(file2);
mediaFolder.add(movieFolder);
mediaFolder.add(musicFolder);
mediaFolder.scan();

/* 输出:
开始扫描文件:钢铁侠.mp4
开始扫描文件:再谈记忆.mp3
*/

透明性的安全问题:

组合模式的透明性,指的是树叶对象接口保持统一,外部调用时无需区分。但是这会带来一些问题,如上述文件目录的例子,文件(叶对象)下不可再添加文件,因此需在文件类的 add() 方法中抛出异常,以作提醒。

误区规避

  1. 组合不是继承,树叶对象并不是父子对象

    组合模式的树型结构是一种 HAS-A(聚合)的关系,而不是 IS-A 。树叶对象能够合作的关键,是它们对外保持统一接口,而不是叶对象继承树对象的属性方法,两者之间不是父子关系。

  2. 叶对象操作保持一致性

    叶对象除了与树对象接口一致外,操作也必须保持一致性。一片叶子只能生在一颗树上。调用顶层对象时,每个叶对象只能接收一次请求,一个叶对象不能从属多个树对象。

  3. 叶对象实现冒泡传递

    请求传递由树向叶传递,如果想逆转传递过程,需在叶对象中保留对树对象的引用,冒泡传递给树对象处理。

  4. 不只是简单的子集遍历

    调用对象的接口方法时,如果该对象是树对象,则会将请求传递给叶对象,由叶对象执行方法,以此类推。不同于迭代器模式,迭代器模式遍历并不会做请求传导。

源码中的组合模式-antd vue的Tree组件

根组件

为了更直观的看到组件的递归方式,我们传入如下数据结构:

const treeData = [
  {
    title: '0-0',
    key: '0-0',
    children: [
      {
        title: '0-0-0',
        key: '0-0-0',
        children: [
          { title: '0-0-0-0', key: '0-0-0-0' },
          { title: '0-0-0-1', key: '0-0-0-1' },
          { title: '0-0-0-2', key: '0-0-0-2' },
        ],
      },
      {
        title: '0-0-1',
        key: '0-0-1',
        children: [
          { title: '0-0-1-0', key: '0-0-1-0' },
          { title: '0-0-1-1', key: '0-0-1-1' },
          { title: '0-0-1-2', key: '0-0-1-2' },
        ],
      },
      {
        title: '0-0-2',
        key: '0-0-2',
      },
    ],
  },
  {
    title: '0-1',
    key: '0-1',
    children: [
      { title: '0-1-0-0', key: '0-1-0-0' },
      { title: '0-1-0-1', key: '0-1-0-1' },
      { title: '0-1-0-2', key: '0-1-0-2' },
    ],
  },
  {
    title: '0-2',
    key: '0-2',
  },
];
复制代码

并设置选中key['0-0-0'],此时,可以看到Tree组件的展示效果:

在这里插入图片描述

对应在antd源码中,我们的根组件代码在vc-tree/Tree.jsx,代码如下:

render() {
    const { _treeNode: treeNode } = this.$data;
    const { prefixCls, focusable, showLine, tabIndex = 0 } = this.$props;

    return (
      <ul
        class={classNames(prefixCls, {
          [`${prefixCls}-show-line`]: showLine,
        })}
        role="tree"
        unselectable="on"
        tabIndex={focusable ? tabIndex : null}
      >
        {mapChildren(treeNode, (node, index) => this.renderTreeNode(node, index))}
      </ul>
    );
  },
复制代码

在源码中可以看到根组件有一个标识role=tree, 通过mapChildren遍历渲染其子节点。

遍历子节点

先来看下mapChildren函数:

export function mapChildren(children = [], func) {
  const list = children.map(func);
  if (list.length === 1) {
    return list[0];
  }
  return list;
}
复制代码

非常简单,遍历treeNode,然后执行renderTreeNode渲染子节点。那么treeNode是什么呢?

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-p5WTWaJr-1681604092619)(assets/617f55b7132d4142bffc71746abf5370tplv-k3u1fbpfcp-zoom-in-crop-mark1512000.webp)]

查看组件的data数据,可以发现是在getDerivedState中对treeNode进行赋值的,为了简化逻辑,直接贴出treeNode赋值的函数:

// vc-tree/src/Tree.jsx

getDerivedState(props, prevState) {
    ...
    
    if (needSync('treeData')) {
       treeNode = convertDataToTree(this.$createElement, props.treeData);
    }
    
   ...
}

// vc-tree/src/util.js
export function convertDataToTree(h, treeData, processor) {
  if (!treeData) return [];

  const { processProps = internalProcessProps } = processor || {};
  const list = Array.isArray(treeData) ? treeData : [treeData];
  return list.map(({ children, ...props }) => {
    const childrenNodes = convertDataToTree(h, children, processor);
    return <TreeNode {...processProps(props)}>{childrenNodes}</TreeNode>;
  });
}
复制代码

通过convertDataToTree函数,我们可以看到,组件中会将我们传入的treeData进行递归处理,返回树形结构的VNode,如图:

在这里插入图片描述

TreeNode渲染

查看TreeNode组件源码:

  render(h) {
    const {
      dragOver,
      dragOverGapTop,
      dragOverGapBottom,
      isLeaf,
      expanded,
      selected,
      checked,
      halfChecked,
      loading,
    } = this.$props;
    const {
      vcTree: { prefixCls, filterTreeNode, draggable },
    } = this;
    const disabled = this.isDisabled();
    return (
      <li
        class={{
          [`${prefixCls}-treenode-disabled`]: disabled,
          [`${prefixCls}-treenode-switcher-${expanded ? 'open' : 'close'}`]: !isLeaf,
          [`${prefixCls}-treenode-checkbox-checked`]: checked,
          [`${prefixCls}-treenode-checkbox-indeterminate`]: halfChecked,
          [`${prefixCls}-treenode-selected`]: selected,
          [`${prefixCls}-treenode-loading`]: loading,
          'drag-over': !disabled && dragOver,
          'drag-over-gap-top': !disabled && dragOverGapTop,
          'drag-over-gap-bottom': !disabled && dragOverGapBottom,
          'filter-node': filterTreeNode && filterTreeNode(this),
        }}
        role="treeitem"
        onDragenter={draggable ? this.onDragEnter : noop}
        onDragover={draggable ? this.onDragOver : noop}
        onDragleave={draggable ? this.onDragLeave : noop}
        onDrop={draggable ? this.onDrop : noop}
        onDragend={draggable ? this.onDragEnd : noop}
      >
        {this.renderSwitcher()}
        {this.renderCheckbox()}
        {this.renderSelector(h)}
        {this.renderChildren()}
      </li>
    );
  },
  
复制代码

可以看到几个关键点:

  • TreeNode组件由一个li标签包裹,并且role=treeitem
  • <li role="treeitem">标签中有renderSwitcher、renderCheckbox、renderSelector、renderChildren。其中renderSelector显示树形节点的图标和标题,renderChildren显示组件下的子组件,并且由<ul role="group">包裹
  • <ul role="group">遍历渲染多个TreeNode组件

对应下图可更清晰的看到其结构:

在这里插入图片描述

其中,蓝色表示TreeNode节点,橙色表示节点中的图标、title、checkbox等基本信息紫色表示子节点的根元素也就是ul,内部包含着对应的子节点。

由于遍历渲染TreeNode组件,操作子节点时参数和方法保持统一,从而提高了组件的可扩展性,这就是组合模式的一个典型应用

优缺点

优点:

  • 忽略组合对象和单个对象的差别,对外一致接口使用;
  • 解耦调用者与复杂元素之间的联系,处理方式变得简单。

缺点:

  • 树叶对象接口一致,无法区分,只有在运行时方可辨别;
  • 包裹对象创建太多,额外增加内存负担。

总结

,并且由<ul role="group">包裹

  • <ul role="group">遍历渲染多个TreeNode组件

对应下图可更清晰的看到其结构:

[外链图片转存中…(img-WrF4CM6K-1681604092620)]

其中,蓝色表示TreeNode节点,橙色表示节点中的图标、title、checkbox等基本信息紫色表示子节点的根元素也就是ul,内部包含着对应的子节点。

由于遍历渲染TreeNode组件,操作子节点时参数和方法保持统一,从而提高了组件的可扩展性,这就是组合模式的一个典型应用

优缺点

优点:

  • 忽略组合对象和单个对象的差别,对外一致接口使用;
  • 解耦调用者与复杂元素之间的联系,处理方式变得简单。

缺点:

  • 树叶对象接口一致,无法区分,只有在运行时方可辨别;
  • 包裹对象创建太多,额外增加内存负担。

总结

组合模式是处理复杂树形结构的有用设计模式。我们可以把相同的操作应用在组合对象和单个对象上。

风语者!平时喜欢研究各种技术,目前在从事后端开发工作,热爱生活、热爱工作。