XFlow 的 Graph 为什么不会随 props 自动切换:生命周期与能力矩阵

第 01 篇讨论了浮层怎样跟随缩放、平移中的画布。那套坐标同步有一个隐含前提:被订阅的 Graph 实例足够稳定。

稳定的 Graph 实例仍不能在运行时随意重配。给 XFlow 传入新的 type 或 isReadOnly 后,React 会重新渲染组件;已经创建的 Graph 却不会自行撤销旧插件、替换交互规则,再按新配置装配一遍。

在当前实现里,图类型和只读状态不是普通的视觉 props。它们会在 Graph 初始化时共同决定一组能力。多图类型编辑器中“明明切了模式,为什么还能编辑”的问题,往往正是没有区分 React 的渲染周期与 Graph 生命周期。

Graph 的生命周期独立于 React 的每次渲染

X6 的 Graph 承担 Cell、选择、缩放和插件等高频运行时状态;React 则管理容器、工具栏、抽屉和浮层等界面状态。若把整张图的 JSON 镜像进 React state,再在每次拖动时驱动组件树重渲染,不仅没有必要,也会混淆这两层的职责。

XFlow 在挂载后的 useEffect 中只创建一次 Graph。初始化过程会根据当时的 type 与 isReadOnly 计算 Graph options,随后创建 Dnd、挂载 MiniMap、注册插件、快捷键和业务事件,最后把实例保存进 ref:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
useEffect(() => {
const graph = new Graph({
container: containerRef.current!,
...getGraphOptions(type, isReadOnly),
});

graphRef.current = graph;
registerPlugin(graph, type, isReadOnly);
registerShortcutKeys(graph);
registerEvent(graph, containerRef.current!, type, setSelectedData);

return () => {
dnd.dispose();
graph.dispose();
};
}, []);

空依赖数组把这段初始化固定在挂载和卸载两个时点,不会跟随后续 props 更新。React 可以继续重渲染外壳,graphRef.current 指向的仍是最初那个实例。

flowchart LR
  M[React 挂载] --> C[new Graph(type, isReadOnly)]
  C --> P[注册插件与事件]
  P --> R[Graph ref / Context]
  R --> U[工具栏、浮层、Ref API]
  U --> G[命令式操作 Graph]
  M --> X[React 卸载]
  X --> D[dnd.dispose 与 graph.dispose]

这里的 ref 和 Context 传递的是实例引用,不是图数据副本。工具栏可以直接调用 Cell 的 attr、setData 或 setRouter;React state 只保留浮层显隐、面板展开等低频 UI 状态。第 01 篇的 DynamicPortal 能持续订阅同一个 Graph 并重算位置,依赖的正是这个前提。

能力矩阵让图类型差异停留在策略层

XFlow 支持多种 DiagramType,却不必为每种类型复制一份画布组件。类型差异和只读状态最终都落在同一个问题上:当前 Graph 应装配并暴露哪些能力?

能力层 通用可编辑图 只读图 FEATURE_TREE 类型特例
Graph options 默认连接、嵌入和曼哈顿路由 节点、边不可移动 边不可移动、不可加顶点 可改路由、连线限制、自环规则
基础插件 框选、滚动、导出 框选、滚动、导出 选择框与边选择收紧 按具体类型调整
编辑插件 Transform、Snapline、Clipboard、Keyboard、History 不注册 部分能力天然关闭 按策略覆写
UI 与浮层 工具栏、右键菜单、编辑浮层 隐藏编辑 UI,保留浏览能力 走专用树交互 可追加专用面板
数据加载 fromJSON fromJSON 树模型渲染 可使用专用适配流程

矩阵分散在两处,职责不同。

  1. getGraphOptions(type, isReadOnly) 决定 X6 原生能力,例如连线、嵌入、路由、高亮和 Cell 交互;
  2. registerPlugin(graph, type, isReadOnly) 决定哪些插件实际进入这个 Graph 实例。

例如,决策树把路由改为 normal;状态图允许自环;组织图不允许连线落在空白处;特性树则显式关闭边移动和顶点新增。它们不必复制 XFlow 主组件,只需在能力入口处声明差异。

新增图类型时,先列出要开放、收紧或重载的能力,再把差异落到这两个策略入口。相较于复制组件后逐行删除功能,这种做法的检查面更清楚,也能避免公共能力升级时遗漏同步。

只读模式必须关闭所有编辑通道

只读模式还要关闭拖拽、快捷键、剪贴板、节点变形和右键菜单这些入口;漏掉任意一条,页面看似只读,模型仍可能被改动。

XFlow 的只读能力至少覆盖 Graph options、插件注册和 UI 渲染三个层次。

1
2
3
Graph options:nodeMovable、edgeMovable 等底层交互是否允许
插件注册:Transform、Snapline、Clipboard、Keyboard、History 是否进入实例
UI 渲染:GraphToolbar、DynamicPortal 等编辑界面是否挂载

在 Graph options 中,CommonGraphOptions(true) 将节点和边的移动能力设为 false。在插件层,registerPlugin 仍装配 Selection、Scroller 和 Export,以保留浏览、框选、滚动与导出;但 Transform、对齐线、剪贴板、键盘和 History 只在非只读模式注册。UI 层则不渲染 GraphToolbar 与 DynamicPortal,避免出现可点击的编辑入口。

事件层还需补上这层约束。通用事件在只读时保留 Cell 选中状态,供侧栏展示详情,同时不显示连接桩;特性树的新增主题、节点内容同步、删除和 Tab 新增也都会先判断 readOnly。所以只读不是一个样式开关,而是一组共同阻断编辑的规则。

这里有一个维护约束:通用事件当前从 DOM 上的 data-isreadonly 读取状态,而 Graph options 与插件注册直接接收 isReadOnly 参数。修改只读规则时,不能只检查一个 if,而要沿着 options、插件、事件、UI 四条路径一起核对。

FEATURE_TREE 说明有些类型已是独立建模模式

能力矩阵不意味着每种图都只是从通用图上关掉几个开关。FEATURE_TREE 就是清晰的边界案例:它拥有自己的 MindMapData 树模型、渲染函数和事件路径。

普通类型调用 setData 时,会把图数据交给 graph.fromJSON;状态图会先拆分特定边再加载;特性树则调用 renderIndented,再注册 add:topic、collapse:topic、expand:topic、node:changed 等树事件。删除和 Tab 新增子主题也在这条专用事件链里处理。

1
2
3
4
5
6
if (type === 'FEATURE_TREE') {
renderIndented(data, graphRef.current!, isReadOnly, true);
registerIndentedTreeEvent(graphRef.current!, data, isReadOnly);
} else {
graphRef.current?.fromJSON(data);
}

因此,特性树不是“普通图 + 禁用边工具栏”。它改变的不只是能力组合,数据加载及用户意图写入模型的路径也随之改变。判断新类型该留在通用矩阵还是拆成独立模式时,我会先看它是否仍共享同一份数据模型和交互事件路径;如果不共享,继续叠加 if (type) 通常只是在掩盖架构边界。

运行中切换类型或读写模式,要显式重建运行时

type 和 isReadOnly 在首次执行 useEffect 时已参与 Graph options 与插件组合。之后即使 React 收到新 props,也不会自动完成以下工作:卸载旧插件、改写已有交互配置、重新绑定事件,再让浮层和工具栏与新策略保持一致。

当前实现下,可靠的切换方式是由外层改变 XFlow 的 key,让旧组件卸载,再按新参数重新挂载:

1
2
3
4
5
6
<XFlow
key={`${type}-${isReadOnly}`}
type={type}
isReadOnly={isReadOnly}
ref={xflowRef}
/>

这样做让旧 Graph 先完整执行 dispose(),再创建能力集合正确的新实例。若切换前已有图数据,外层还要明确保存和恢复的时机,避免重挂后丢失模型。

也可以设计真正的动态重配协议,不过每一项都要明确:交互如何启停、插件如何注册或卸载、事件怎样重绑、UI 如何同步,以及历史栈与选中状态怎样迁移。只把 type 或 isReadOnly 加进 useEffect 依赖数组,无法替你处理这些运行时资源的清理与重建顺序。

Graph 的能力是一次装配后的运行时契约

第 01 篇里的 Portal 之所以能通过订阅稳定的 Graph 同步坐标,是因为 Graph 生命周期与 React props 之间存在这条边界。

1
2
3
4
Graph 生命周期
× 图类型策略
× 只读约束
= 当前实例实际拥有的能力

React props 可以变化,已装配的 Graph 能力却不会自动重配。这个边界明确后,新增图类型时便知道要检查 getGraphOptions、registerPlugin、事件层和 UI 层;切换读写模式时,也应明确选择重建实例,或投入成本设计动态协议。