16、外观模式

组合模式(Composite Pattern)又叫整体-部分模式,它允许你将对象组合成树形结构来表现整体-部分层次结构,让使用者可以以一致的方式处理组合对象以及部分对象。

1. 你曾见过的组合模式

大家电脑里的文件夹结构相比很熟悉了,文件夹下面可以有子文件夹,也可以有文件,子文件夹下面还可以有文件夹和文件,以此类推,共同组成了一个文件树,结构如下:

Folder 1
*** Folder 2
*   *** File 1.txt
*   *** File 2.txt
*   *** File 3.txt
*** Folder 3
    *** File 4.txt
    *** File 5.txt
    *** File 6.txt

文件夹是树形结构的容器节点,容器节点可以继续包含其他容器节点,像树枝上还可以有其他树枝一样;也可以包含文件,不再增加新的层级,就像树的叶子一样处于末端,因此被称为叶节点。本文中,叶节点又称为叶对象,容器节点因为可以包含容器节点和非容器节点,又称为组合对象。

类似这样的结构还有公司的组织层级,比如企业下面可以有部门,部门有部门员工和科室,科室可以有科室员工和团队,团队下面又可以有团队员工和组,依次类推,共同组成完整的企业。还有生活中的容器,比如柜子可以直接放东西,也可以放盆,盆里可以放东西也可以放碗,以此类推。甚至我们的家庭结构也属于这种结构,祖父家庭有父亲家庭、伯伯家庭、叔叔家庭、姑姑家庭等,父亲家庭又有哥哥家庭、弟弟家庭,这也是很典型的整体-部分层次的结构。

当我们在某个文件夹下搜索某个文件的时候,通常我们希望搜索的结果包含组合对象的所有子孙对象;开家族会议的时候,开会的命令会被传达到家族中的每一个成员;领导希望我们 996 的时候,只要跟部门领导说一声,部门领导就会通知所有的员工来修福报,无论你是下属哪个组织的,都跑不掉…?

在类似的场景中,有以下特点:

1、 结构呈整体-部分的树形关系,整体部分一般称为组合对象,组合对象下还可以有组合对象和叶对象;

2、 组合对象和叶对象有一致的接口和数据结构,以保证操作一致;

3、 请求从树的最顶端往下传递,如果当前处理请求的对象是叶对象,叶对象自身会对请求作出相应的处理;如果当前处理的是组合对象,则遍历其下的子节点(叶对象),将请求继续传递给这些子节点;

2. 实例的代码实现

我们可以使用 JavaScript 来将之前的文件夹例子实现一下。

在本地一个「电影」文件夹下有两个子文件夹「漫威英雄电影」和「DC英雄电影」,分别各自有一些电影文件,我们要做的就是在这个电影文件夹里找大于 2G 的电影文件,无论是在这个文件夹下还是在子文件夹下,并输出它的文件名和文件大小。

// 创建文件夹
var createFolder = function (name) {
    return {
        name: name,
        _children: [],

        // 在文件夹下增加文件或文件夹
        add(fileOrFolder) {
            this._children.push(fileOrFolder)
        },

        // 扫描方法
        scan(cb) {
            this._children.forEach(function (child) {
                child.scan(cb)
            })
        }
    }
}

// 创建文件
var createFile = function (name, size) {
    return {
        name: name,
        size: size,

        // 在文件下增加文件,应报错
        add() {
            throw new Error('文件下面不能再添加文件')
        },

        // 执行扫描方法
        scan(cb) {
            cb(this)
        }
    }
}

// 创建总文件夹
var foldMovies = createFolder('电影')

// 创建子文件夹,并放入根文件夹
var foldMarvelMovies = createFolder('漫威英雄电影')
foldMovies.add(foldMarvelMovies)

var foldDCMovies = createFolder('DC英雄电影')
foldMovies.add(foldDCMovies)

// 为两个子文件夹分别添加电影
foldMarvelMovies.add(createFile('钢铁侠.mp4', 1.9))
foldMarvelMovies.add(createFile('蜘蛛侠.mp4', 2.1))
foldMarvelMovies.add(createFile('金刚狼.mp4', 2.3))
foldMarvelMovies.add(createFile('黑寡妇.mp4', 1.9))
foldMarvelMovies.add(createFile('美国队长.mp4', 1.4))

foldDCMovies.add(createFile('蝙蝠侠.mp4', 2.4))
foldDCMovies.add(createFile('超人.mp4', 1.6))

console.log('size 大于2G的文件有:')
foldMovies.scan(function (item) {
    if (item.size > 2) {
        console.log('name:' + item.name + ' size:' + item.size + 'GB')
    }
})

// size 大于2G的文件有:
// name:蜘蛛侠.mp4 size:2.1GB
// name:金刚狼.mp4 size:2.3GB
// name:蝙蝠侠.mp4 size:2.4GB

作为灵活的 JavaScript,我们还可以使用链模式来进行改造一下,让我们添加子文件更加直观和方便。对链模式还不熟悉的同学可以看一下后面有一篇单独介绍链模式的文章~

// 创建文件夹
const createFolder = function (name) {
    return {
        name: name,
        _children: [],

        // 在文件夹下增加文件或文件夹
        add(...fileOrFolder) {
            this._children.push(...fileOrFolder)
            return this
        },

        // 扫描方法
        scan(cb) {
            this._children.forEach(child => child.scan(cb))
        }
    }
}

// 创建文件
const createFile = function (name, size) {
    return {
        name: name,
        size: size,

        // 在文件下增加文件,应报错
        add() {
            throw new Error('文件下面不能再添加文件')
        },

        // 执行扫描方法
        scan(cb) {
            cb(this)
        }
    }
}

const foldMovies = createFolder('电影')
    .add(
        createFolder('漫威英雄电影')
            .add(createFile('钢铁侠.mp4', 1.9))
            .add(createFile('蜘蛛侠.mp4', 2.1))
            .add(createFile('金刚狼.mp4', 2.3))
            .add(createFile('黑寡妇.mp4', 1.9))
            .add(createFile('美国队长.mp4', 1.4)),
        createFolder('DC英雄电影')
            .add(createFile('蝙蝠侠.mp4', 2.4))
            .add(createFile('超人.mp4', 1.6))
    )

console.log('size 大于2G的文件有:');
foldMovies.scan(item => {
    if (item.size > 2) {
        console.log(name:${item.name} size:${item.size}GB)
    }
})

// size 大于2G的文件有:
// name:蜘蛛侠.mp4 size:2.1GB
// name:金刚狼.mp4 size:2.3GB
// name:蝙蝠侠.mp4 size:2.4GB

上面的代码比较 JavaScript 特色,如果我们使用传统的类呢,也是可以实现的,下面使用 ES6 的 class 语法来改写一下:

// 文件夹类
class Folder {
    constructor(name, children) {
        this.name = name
        this.children = children
    }

    // 在文件夹下增加文件或文件夹
    add(...fileOrFolder) {
        this.children.push(...fileOrFolder)
        return this
    }

    // 扫描方法
    scan(cb) {
        this.children.forEach(child => child.scan(cb))
    }
}

// 文件类
class File {
    constructor(name, size) {
        this.name = name
        this.size = size
    }

    // 在文件下增加文件,应报错
    add(...fileOrFolder) {
        throw new Error('文件下面不能再添加文件')
    }

    // 执行扫描方法
    scan(cb) {
        cb(this)
    }
}

const foldMovies = new Folder('电影', [
    new Folder('漫威英雄电影', [
        new File('钢铁侠.mp4', 1.9),
        new File('蜘蛛侠.mp4', 2.1),
        new File('金刚狼.mp4', 2.3),
        new File('黑寡妇.mp4', 1.9),
        new File('美国队长.mp4', 1.4)]),
    new Folder('DC英雄电影', [
        new File('蝙蝠侠.mp4', 2.4),
        new File('超人.mp4', 1.6)])
])

console.log('size 大于2G的文件有:')

foldMovies.scan(item => {
    if (item.size > 2) {
        console.log(name:${ item.name } size:${ item.size }GB)
    }
})

// size 大于2G的文件有:
// name:蜘蛛侠.mp4 size:2.1GB
// name:金刚狼.mp4 size:2.3GB
// name:蝙蝠侠.mp4 size:2.4GB

在传统的语言中,为了保证叶对象和组合对象的外观一致,还会让他们实现同一个抽象类或接口。

3. 组合模式的概念

组合模式定义的包含组合对象和叶对象的层次结构,叶对象可以被组合成更复杂的组合对象,而这个组合对象又可以被组合,这样不断地组合下去。

在实际使用时,任何用到叶对象的地方都可以使用组合对象了。使用者可以不在意到底处理的节点是叶对象还是组合对象,也就不用写一些判断语句,让客户可以一致地使用组合结构的各节点,这就是所谓面向接口编程,从而减少耦合,便于扩展和维护。

组合模式的示意图如下:

*

4. 实战中的组合模式

类似于组合模式的结构其实我们经常碰到,比如浏览器的 DOM 树,从 根节点到 、、