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>`;
  }
}

你看,Panelrender()方法会遍历所有子节点,让每个子节点自己渲染自己。这就是组合模式的精髓——统一接口,递归处理。

组合模式在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