FEATURE_TREE:何时不应强求统一抽象
「统一抽象」很容易显得正确:既然树也是图,何不和流程图共用一套 JSON、事件和工具栏?
但数学上的包含关系,并不意味着产品交互可以按同一种方式实现。用户新增子主题、折叠一整条分支、重新计算层级布局时,面对的是子树,而不是任意图的连线编辑。继续复用,通常只是把复杂度从两条清晰的路径挪进越来越多的条件分支。
先区分两种模型
普通流程图通常可以表示为一组扁平 cells:
1 | type GraphData = { |
节点和边由 ID 引用连接,允许多入边、多出边、回路和跨层关系。数据加载通常就是 fromJSON。
特性树或思维导图更接近递归结构:
1 | type TreeNode = { |
它有一个根、明确的父子关系和顺序;展开、折叠、添加子节点、重新布局也都是对子树操作。它最终当然能画成 nodes + edges,但这份扁平结果更接近渲染产物,不是编辑时最自然的领域模型。
独立路径承认差异
Graph 容器和基础框架仍可共享;从数据加载到渲染、事件处理,树图则走自己的路径:
1 | 普通图:Graph JSON → fromJSON → 通用事件 |
加载树图时,先把领域树交给布局器计算节点位置,再递归创建 X6 Node 和 Edge;节点的 visible、折叠标记等状态也在这条路径中处理。
实际加载的数据类型是 MindMapData。id、type、label、width、height 与 zIndex 是必填字段,children 承载递归关系。x、y、attrs 用来覆盖布局结果或保存样式,visible、eventTarget 与 numberOfCollapsed 则记录折叠状态。
1 | type MindMapData = { |
不能只保留 id、label 和 children。renderIndented 会把尺寸交给 Hierarchy.mindmap,并把层级交给 X6;缺少这些字段,就无法按当前实现完成渲染。
这样,树语义集中在 children 上,不会散落成「某条边是否为父子边」「这个节点能否连接另一个节点」之类的通用图判断。
ID 规则服务于新增,不是树关系来源
当前 FEATURE_TREE 编辑路径不从平面 cells 反推父子关系。findItem 沿 children 递归查找节点及其父节点,removeNode 也从该父节点的 children 中删除目标;树的关系来源始终是递归数据。
新建子节点时,ID 以父节点 ID 加上连字符和当前子节点数加一生成:
1 | const length = dataItem.children ? dataItem.children.length : 0; |
root-2-3 可以读作 root-2 的第三个新增孩子,数字后缀隐含同级创建顺序;但运行时的父子关系并不靠它确定。新增逻辑只取 children.length + 1,删除中间孩子后再新增,可能复用仍存在的后缀。持久化或导入数据若依赖 ID 唯一性,不能把这段编号逻辑当作无冲突的通用 ID 生成器。
若持久化格式只保存扁平 cells,才需要另行定义从 ID 或显式 parentId 恢复树的契约。当前 MindMapData 并没有 parentId 或 order 字段,不能把这类扩展模型误写成现有实现。
布局能力与项目能力要分清
树布局可交给专门库,例如 mindmap layout:它根据树结构、节点尺寸、方向和间距计算坐标。项目侧仍要负责:
- 传入领域树和布局约束;
- 遍历布局结果,创建 Node 与 Edge;
- 将领域属性映射为样式、工具和可见性;
- 批量替换当前 cells;
- 应用折叠/展开状态。
布局算法由第三方布局器提供;领域模型如何转成渲染产物,以及何时折叠、编辑和重渲染,仍是项目侧的职责。
折叠与删除节点
当前实现不会从领域树中删除后代。collapse:topic 递归把目标节点的全部后代设为 visible = false,再在父节点写入 eventTarget = 'collapse' 和 numberOfCollapsed。expand:topic 则递归恢复后代的 visible = true,并将父节点标记为 expand。
随后 renderIndented 会重新遍历整棵树,重新创建 Node 与 Edge,并以一次 graph.resetCells(cells) 替换画布中的 cells:
1 | 遍历树 |
cell-collapse 加在 Node 和 Edge 的 View 上,因此被折叠孩子及其入边都会隐藏,父节点则留在画布中显示折叠数量。领域节点的 id 保持不变,但 resetCells 会重建 X6 Cell;这里保留的不是原 Cell 实例。
选中态也没有专门的恢复或清除步骤:折叠、展开处理器没有调用 clearSelection,渲染函数也不保存 getSelectedCells() 的结果。resetCells 后选择插件对旧 Cell 的处理属于 X6 运行时行为,源码没有给出“选中态必然保留”或“必然清除”的契约。
折叠的撤销语义也没有单独收口。非只读图会注册 History,只有变更选项带 ignoreHistory 才会跳过入栈。collapse:topic 与 expand:topic 没有传入该选项,也没有用 startBatch / stopBatch 把重渲染包成一次事务;因此源码并不承诺一次折叠或展开对应一条可撤销记录,实际入栈粒度仍取决于 X6 History 对 resetCells 的运行时处理。
这两个事件也没有 readOnly 提前返回。与新增、删除、编辑节点不同,当前实现允许只读树响应折叠和展开。遍历、Cell 创建和可见性收集均为 O(n);布局器内部的复杂度属于依赖库能力,不能从适配层代码直接推断。
双路径的成本是真实的
独立树路径有明确成本:每次改动都要确认,它是否同样影响树图。
普通图的边工具栏、右键菜单和连线规则,就未必适用于树图。树图误用了这些能力,用户就会看到不符合树语义的编辑入口;运行时因此需要在浮层和插件层明确收紧树图能力。
同时,测试也要分开覆盖:
- 普通图:连线、嵌入、复制粘贴、边编辑;
- 树图:新增子节点、删除子树、展开折叠、属性反写、重复加载。
共用容器不代表共用行为。
什么时候该统一,什么时候该分叉
只有当两类模型的核心用户操作共享同一组不变量时,统一才有意义。核心操作、数据真相和布局方式都已经不同,继续统一的维护成本往往高过拆分。
| 维度 | 更适合统一 | 更适合分叉 |
|---|---|---|
| 数据模型 | 都是任意 nodes + edges | 一方是递归树 |
| 编辑操作 | 都是连线、移动、删除 | 一方是子树增删、折叠展开 |
| 布局 | 手工定位为主 | 一方必须持续自动布局 |
| UI 工具 | 工具语义相同 | 一方禁止边编辑或右键动作 |
为树图保留独立路径,是为了不把它的语义埋进普通图不断累积的偶然复杂度中。