13、MVC中的组合模式:视图的树形结构管理
组合模式,说白了就是「部分-整体」的层次结构。在MVC里,视图层天然就是一棵树——窗口里有面板,面板里有按钮,按钮里可能还有图标。这种嵌套关系,用组合模式来管理再合适不过了。
我记得刚入行那会儿,接手一个老项目。视图层代码写得那叫一个乱,每个组件都自己管自己的子组件,增删改查的逻辑散落在各处。后来重构时用了组合模式,整个视图管理瞬间清爽了。嗯,今天我们就来聊聊这个。
什么是组合模式?
组合模式允许你将对象组合成树形结构,来表示「部分-整体」的层次关系。它让客户端可以统一对待单个对象和组合对象。
说白了,就是让叶子节点和容器节点用同样的接口。你调用一个按钮的render(),它自己画自己。你调用一个面板的render(),它会遍历所有子组件,让每个子组件自己画自己。调用方式完全一致。
核心思想: 让客户端代码不需要区分「这是一个叶子节点」还是「这是一个容器节点」。统一调用,统一处理。
MVC视图层的树形结构
在MVC里,视图层天然就是一棵树。我画了一张图,你看看就明白了:
你看,这棵树从根节点(主窗口)开始,往下分叉出工具栏、内容面板、状态栏。工具栏里又有按钮,内容面板里又有表单区域。每个节点要么是容器(可以挂子节点),要么是叶子(不能再挂子节点)。
组合模式的核心角色
组合模式有三个核心角色,我一个个说:
| 角色 | 说明 | 在MVC视图中的体现 |
|---|---|---|
| Component(抽象组件) | 定义叶子节点和容器节点的统一接口 | 抽象的View基类,定义render()、add()、remove()等方法 |
| Leaf(叶子节点) | 没有子节点的具体组件 | Button、Label、Input等不可再拆分的UI元素 |
| Composite(容器节点) | 有子节点的具体组件,管理子组件的增删改 | Panel、Window、Form等可以容纳子视图的容器 |
代码实现:从抽象到具体
我直接上代码。先定义一个抽象的View基类:
// 抽象组件 - 所有视图的基类
class View {
constructor(name) {
this.name = name;
this.parent = null;
}
// 渲染自己
render() {
throw new Error('子类必须实现 render 方法');
}
// 添加子视图(叶子节点不需要实现)
add(child) {
throw new Error('当前节点不支持添加子视图');
}
// 移除子视图
remove(child) {
throw new Error('当前节点不支持移除子视图');
}
// 获取子视图
getChild(index) {
throw new Error('当前节点不支持获取子视图');
}
}
然后实现叶子节点——比如一个按钮:
// 叶子节点 - 按钮
class Button extends View {
constructor(name, label) {
super(name);
this.label = label;
}
render() {
console.log(`渲染按钮: ${this.label}`);
// 实际项目中这里会生成 DOM 元素
return `<button>${this.label}</button>`;
}
}
再实现容器节点——比如一个面板:
// 容器节点 - 面板
class Panel extends View {
constructor(name) {
super(name);
this.children = [];
}
add(child) {
child.parent = this;
this.children.push(child);
return this; // 支持链式调用
}
remove(child) {
const index = this.children.indexOf(child);
if (index !== -1) {
child.parent = null;
this.children.splice(index, 1);
}
}
getChild(index) {
return this.children[index];
}
render() {
console.log(`渲染面板: ${this.name}`);
const childHtml = this.children
.map(child => child.render())
.join('\n');
return `<div class="panel">\n${childHtml}\n</div>`;
}
}
你看,Panel的render()方法会遍历所有子节点,让每个子节点自己渲染自己。这就是组合模式的精髓——统一接口,递归处理。
组合模式在MVC中的实际应用
我个人习惯在MVC框架里这样组织视图层:
// 构建视图树
const mainWindow = new Panel('mainWindow');
const toolbar = new Panel('toolbar');
const contentArea = new Panel('contentArea');
const statusBar = new Panel('statusBar');
const newBtn = new Button('newBtn', '新建');
const saveBtn = new Button('saveBtn', '保存');
const deleteBtn = new Button('deleteBtn', '删除');
// 组装视图树
mainWindow
.add(toolbar)
.add(contentArea)
.add(statusBar);
toolbar
.add(newBtn)
.add(saveBtn)
.add(deleteBtn);
// 一键渲染整棵树
const html = mainWindow.render();
console.log(html);
输出结果:
<div class="panel">
<div class="panel">
<button>新建</button>
<button>保存</button>
<button>删除</button>
</div>
<div class="panel">
</div>
<div class="panel">
</div>
</div>
你想想看,如果不用组合模式,你要怎么渲染这棵树?要么写一堆递归遍历,要么每个容器自己写一套子节点管理逻辑。有了组合模式,一切都统一了。
避坑指南
我曾经踩过的坑:
- 不要滥用组合模式。 如果你的视图层级只有一两层,用组合模式反而增加了复杂度。我见过有人连两个按钮都要用组合模式包装,完全没必要。
- 注意循环引用。 在
add()方法里设置parent引用时,要小心别形成循环。比如A添加了B,B又添加了A,渲染时会死循环。 - 叶子节点也要实现所有接口。 虽然叶子节点不需要
add()和remove(),但接口还是要有的,只是抛出异常或返回空。这样客户端代码才能统一调用。
组合模式与MVC的联动
在MVC里,组合模式不仅管理视图结构,还和Model层有交互。我举个例子:
// 带数据绑定的视图节点
class BoundView extends View {
constructor(name, model, property) {
super(name);
this.model = model;
this.property = property;
}
// 当 model 变化时,自动重新渲染
update() {
const newValue = this.model.get(this.property);
this.render(newValue);
}
}
// 容器节点递归通知所有子节点更新
class ReactivePanel extends Panel {
update() {
this.children.forEach(child => {
if (typeof child.update === 'function') {
child.update();
}
});
}
}
这样一来,当Model层数据变化时,只需要调用根节点的update()方法,整棵树都会自动刷新。这就是组合模式在MVC里的威力——递归操作,一键搞定。
小技巧: 在实际项目中,我通常会给容器节点加一个dirty标记。只有标记为脏的节点才重新渲染,这样可以避免不必要的DOM操作,提升性能。
总结
组合模式在MVC视图层的作用,说白了就是三件事:
- 统一接口:叶子节点和容器节点用同样的方法名,客户端不用关心具体类型
- 递归处理:渲染、更新、销毁等操作,从根节点开始递归执行,省心省力
- 灵活组装:视图树可以动态增删节点,适应复杂的UI变化
我在好几个项目里都用这个模式重构过视图层。每次重构完,代码量至少减少30%,而且新增视图类型时完全不用改现有代码。嗯,这就是设计模式的魅力——用结构化的方式解决重复出现的问题。
公众号:蓝海资料掘金营,微信deep3321