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 | useEffect(() => { |
空依赖数组把这段初始化固定在挂载和卸载两个时点,不会跟随后续 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 |
树模型渲染 | 可使用专用适配流程 |
矩阵分散在两处,职责不同。
getGraphOptions(type, isReadOnly)决定 X6 原生能力,例如连线、嵌入、路由、高亮和 Cell 交互;registerPlugin(graph, type, isReadOnly)决定哪些插件实际进入这个 Graph 实例。
例如,决策树把路由改为 normal;状态图允许自环;组织图不允许连线落在空白处;特性树则显式关闭边移动和顶点新增。它们不必复制 XFlow 主组件,只需在能力入口处声明差异。
新增图类型时,先列出要开放、收紧或重载的能力,再把差异落到这两个策略入口。相较于复制组件后逐行删除功能,这种做法的检查面更清楚,也能避免公共能力升级时遗漏同步。
只读模式必须关闭所有编辑通道
只读模式还要关闭拖拽、快捷键、剪贴板、节点变形和右键菜单这些入口;漏掉任意一条,页面看似只读,模型仍可能被改动。
XFlow 的只读能力至少覆盖 Graph options、插件注册和 UI 渲染三个层次。
1 | Graph options:nodeMovable、edgeMovable 等底层交互是否允许 |
在 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 | if (type === 'FEATURE_TREE') { |
因此,特性树不是“普通图 + 禁用边工具栏”。它改变的不只是能力组合,数据加载及用户意图写入模型的路径也随之改变。判断新类型该留在通用矩阵还是拆成独立模式时,我会先看它是否仍共享同一份数据模型和交互事件路径;如果不共享,继续叠加 if (type) 通常只是在掩盖架构边界。
运行中切换类型或读写模式,要显式重建运行时
type 和 isReadOnly 在首次执行 useEffect 时已参与 Graph options 与插件组合。之后即使 React 收到新 props,也不会自动完成以下工作:卸载旧插件、改写已有交互配置、重新绑定事件,再让浮层和工具栏与新策略保持一致。
当前实现下,可靠的切换方式是由外层改变 XFlow 的 key,让旧组件卸载,再按新参数重新挂载:
1 | <XFlow |
这样做让旧 Graph 先完整执行 dispose(),再创建能力集合正确的新实例。若切换前已有图数据,外层还要明确保存和恢复的时机,避免重挂后丢失模型。
也可以设计真正的动态重配协议,不过每一项都要明确:交互如何启停、插件如何注册或卸载、事件怎样重绑、UI 如何同步,以及历史栈与选中状态怎样迁移。只把 type 或 isReadOnly 加进 useEffect 依赖数组,无法替你处理这些运行时资源的清理与重建顺序。
Graph 的能力是一次装配后的运行时契约
第 01 篇里的 Portal 之所以能通过订阅稳定的 Graph 同步坐标,是因为 Graph 生命周期与 React props 之间存在这条边界。
1 | Graph 生命周期 |
React props 可以变化,已装配的 Graph 能力却不会自动重配。这个边界明确后,新增图类型时便知道要检查 getGraphOptions、registerPlugin、事件层和 UI 层;切换读写模式时,也应明确选择重建实例,或投入成本设计动态协议。