第四章:核心数据结构设计

好,咱们今天聊聊核心数据结构。说实话,这部分是可视化编辑器的灵魂。我见过太多项目,界面做得花里胡哨,结果底层数据结构一塌糊涂,最后改需求改到崩溃。嗯,咱们得从一开始就把地基打牢。

4.1 组件树(Component Tree)

组件树是什么?说白了,就是页面里所有组件的层级关系。你想想看,一个页面就像一棵树,根节点是页面本身,子节点是各种容器和组件。

核心要点:组件树必须支持嵌套、排序、增删改查。这是最基本的操作。

我在项目中遇到过最头疼的事,就是组件树和实际渲染不一致。后来我总结了一套规则:

// 组件树节点定义
interface TreeNode {
  id: string;           // 唯一标识
  type: string;         // 组件类型,比如 'Button', 'Container'
  props: Record<string, any>;  // 组件属性
  children: TreeNode[]; // 子节点列表
  parentId?: string;    // 父节点ID,方便向上查找
}

为什么要加 parentId?因为在实际操作中,你经常需要从子节点找到父节点。比如删除一个组件,你得通知父节点更新 children 列表。没有 parentId,你就得递归遍历整棵树,性能太差了。

我的习惯:组件树用 Map 存储,key 是 id,value 是节点。这样查找任意节点都是 O(1) 复杂度。别用数组,数组查找太慢了。

4.2 属性面板数据模型

属性面板,就是用户选中组件后,右侧弹出的那个编辑区域。这里的数据模型设计,直接决定了编辑器的易用性。

我曾经踩过一个坑:把所有属性都放在一个平铺的对象里。结果组件一多,属性面板乱成一锅粥。后来我改用分组结构:

// 属性面板数据模型
interface PropertyGroup {
  title: string;        // 分组标题,比如 '基础属性', '样式属性'
  properties: PropertyDef[];
}

interface PropertyDef {
  key: string;          // 属性名,对应组件 props 里的字段
  label: string;        // 显示名称,比如 '背景颜色'
  type: 'string' | 'number' | 'boolean' | 'color' | 'select';
  defaultValue?: any;   // 默认值
  options?: { label: string; value: any }[];  // 下拉选项
  validation?: (value: any) => boolean;       // 校验函数
}

你看,这样分组后,属性面板可以按组折叠展开。用户找属性也方便,开发者也容易维护。

注意:属性面板的数据模型,要和组件树里的 props 字段一一对应。我曾经见过有人用两套不同的命名,结果调试时对不上,浪费了好几天时间。

4.3 页面 Schema 定义

页面 Schema,就是整个页面的完整描述。它包含了组件树、全局配置、样式变量、事件绑定等等。说白了,你保存一个页面,实际上就是保存这个 Schema。

我建议用这样的结构:

// 页面 Schema
interface PageSchema {
  version: string;      // Schema 版本号,方便后续升级
  page: {
    title: string;      // 页面标题
    width: number;      // 画布宽度
    height: number;     // 画布高度
    backgroundColor: string;
  };
  components: TreeNode[];  // 组件树根节点列表
  globalStyles: Record<string, any>;  // 全局样式变量
  events: EventBinding[];  // 事件绑定列表
  dataSources: DataSource[];  // 数据源配置
}

为什么要加 version?因为随着编辑器迭代,Schema 结构可能会变。有了版本号,你可以写迁移脚本,把旧版本的 Schema 转成新版本。我在项目中就靠这个机制,平滑升级了三次大版本。

核心原则:Schema 要能完整还原页面。也就是说,你给我一个 Schema,我能渲染出完全一样的页面。少一个字段都不行。

4.4 JSON Schema 规范

JSON Schema 是啥?它是用来描述 JSON 数据结构的规范。咱们的组件属性、页面配置,本质上都是 JSON。用 JSON Schema 来定义它们,好处太多了:

  • 自动校验:用户输入不合法的值,直接报错
  • 自动生成表单:根据 Schema 生成属性面板的输入控件
  • 文档即代码:Schema 本身就是文档,不用额外写说明

举个例子,一个按钮组件的属性 Schema:

{
  "$schema": "http://json-schema.org/draft-07/schema#",
  "type": "object",
  "properties": {
    "text": {
      "type": "string",
      "title": "按钮文字",
      "default": "按钮"
    },
    "type": {
      "type": "string",
      "title": "按钮类型",
      "enum": ["primary", "default", "dashed", "text"],
      "default": "default"
    },
    "size": {
      "type": "string",
      "title": "按钮大小",
      "enum": ["large", "middle", "small"],
      "default": "middle"
    },
    "disabled": {
      "type": "boolean",
      "title": "是否禁用",
      "default": false
    }
  },
  "required": ["text"]
}

你看,这个 Schema 定义了按钮的所有属性。编辑器拿到这个 Schema,就能自动生成属性面板。用户修改属性时,还能实时校验。

我的经验:JSON Schema 的 draft-07 版本最常用。它支持条件校验、引用复用,基本够用了。别用太新的版本,兼容性不好。

4.5 数据结构的联动关系

这几个数据结构不是孤立的。它们之间有着紧密的联动:

数据结构 作用 关联关系
组件树 描述页面层级 每个节点的 props 由属性面板控制
属性面板模型 定义可编辑属性 根据组件类型,从 JSON Schema 生成
页面 Schema 完整页面描述 包含组件树、全局配置等
JSON Schema 属性校验和表单生成 为属性面板提供元数据

举个例子,用户拖入一个按钮组件:

  1. 编辑器根据按钮的 JSON Schema,生成属性面板
  2. 用户在属性面板修改文字,更新组件树中对应节点的 props
  3. 编辑器重新渲染,按钮显示新文字
  4. 保存时,整个组件树序列化成页面 Schema

避坑指南:我曾经在项目中把组件树和属性面板的数据分开存储,结果用户修改属性后,组件树没更新,导致渲染和实际数据不一致。记住,组件树是唯一数据源,属性面板只是它的编辑器。

4.6 实战建议

最后,给你几个实战建议:

  • 先画图,再写代码:用思维导图把数据结构画出来,确认没问题再动手
  • 写单元测试:每个数据结构的增删改查都要测试,特别是边界情况
  • 版本控制:Schema 的每个版本都要存档,方便回滚
  • 性能优化:组件树超过 1000 个节点时,考虑虚拟滚动和增量更新

嗯,核心数据结构就聊这么多。这部分虽然枯燥,但真的重要。你想想看,数据结构设计好了,后面的开发就是一马平川。设计不好,天天改 bug 改到怀疑人生。我当年就是吃了这个亏,现在每次做新项目,第一件事就是先把数据结构定死。

下一章咱们聊聊渲染引擎,怎么把组件树变成真正的页面。到时候见。