React 控件如何安全修改 X6:事务边界、History 与派生状态
工具栏上的一次“改成蓝色”会在 Graph 内触发多次修改:它可能依次写入多个节点的填充属性,还会根据节点 shape 处理不同的 SVG selector。粘贴和删除更复杂,除了创建或移除 Cell,还可能重置编号,清理旧归属并删除关联边。
如果 History 按每次底层 API 调用分别记账,用户按一次撤销,看到的很可能只是半完成状态。图编辑器应把用户能识别的一次操作收敛成一条事务,History 无需机械记录每个发生过的命令。
在 XFlow 中,React 控件通过 Graph 实例发出命令,startBatch / stopBatch 定义用户操作的边界,ignoreHistory 则把系统派生的修正排除在独立撤销步骤之外。
React 管控件,Graph 执行模型修改
第 02 篇中,Graph 被放在 ref 与 Context 中,没有放入 React state。这个边界也决定了工具栏的工作方式:React 管理颜色选择器、菜单开关和可访问性;真正修改节点、边和选择集的命令交给 Graph 与 Cell。
以节点填充色为例,工具栏从 Context 取得 app.current,先更新本地控件状态,再对选中节点逐个写入属性:
1 | const handleSetFillColor = (color: string) => { |
这里没有在 React 中维护另一份节点 JSON,也不靠重新渲染把颜色“同步回画布”。Cell 的 attr 直接修改 Graph 模型;React state 只让颜色选择器显示当前值。
命令式接口下,一次界面操作往往扩展成多个 Cell 写入,History 需要把这些写入重新解释成用户的一次意图。
History 应以用户意图为记录单位
XFlow 在可编辑模式装配 History 插件,并在 beforeAddCommand 中读取写入选项:
1 | graph.use( |
这段配置只规定什么命令可以进入历史栈,并不会自动推断哪些多步骤修改属于同一次操作。事务边界仍由调用处通过 batch 明确给出。
sequenceDiagram
participant U as 用户
participant R as React 工具栏
participant G as X6 Graph
participant H as History
U->>R: 点击“批量修改颜色”
R->>G: startBatch
R->>G: 逐个更新选中 Cell
R->>G: stopBatch
G->>H: 记录一条用户事务
判断标准可以很直接:用户在界面上完成一次什么动作,就应该用一次撤销回到动作前。要是一次“批量填色”得连续按五次撤销才能完全回退,事务边界显然切得太细。
batch 适合包住一个完整的编辑意图
startBatch / stopBatch 应包住共同完成一个用户意图的所有模型写入,无需包住“所有循环”。XFlow 中有几类典型场景。
粘贴包含复制 Cell 及后续处理
粘贴快捷键会先调用 graph.paste,再逐个处理新生成的 Cell:节点需要刷新 createLegendTime、nodeId,移除展示工具与旧的泳道归属;边则清理原有 data。最后清空旧选择并选中新 Cell。
1 | graph.startBatch('custom-batch-paste'); |
这些动作虽然散落在不同的写入里,但合起来才是“粘贴一组新图元”。若拆成多条历史命令,撤销过程会落在粘贴前后之间的中间状态。
删除往往带着关联清理
删除有时会包含 removeCell 之外的关联处理。这里的条件边受业务规则约束,不能一律按普通边处理;快捷键删除会区分节点与边,并保留其中一部分。label-able-polyline 则是项目定义的一类特殊折线节点,删除它时还要一并处理关联边。右键菜单中的删除也会将关联清理与目标 Cell 的深度删除(连同嵌套内容一并移除)放进同一个 batch。
是否共用事务取决于这些写入是否共同表达“删除这个对象”这一用户意图,代码是否位于同一个函数不影响这个判断。
批量样式与排版也属于一条事务
NodeToolbar 使用 batch 处理节点样式,包括填充色、描边色、线型、线宽和锁定宽高比。对齐和等距分布也各自使用 batch。一次水平居中可能修改多个节点的位置;一次线型选择可能按节点 shape 同时改写不同 selector。它们都应在 History 中收敛为一次撤销。
这正是命令式 Graph API 与 React UI 的分工:React 将一次点击翻译为一组明确的模型命令,Graph 再把它们作为一个事务纳入 History。
派生状态不应制造额外的撤销步骤
有些写入来自用户直接意图。节点嵌入泳道后,系统会根据节点位置、泳道边界和分栏的 markup 计算节点属于哪个分栏,并写入 parentVerticalLane 或 parentHorizontalLane。
用户直接做的是“拖动节点”。分栏归属是系统为了让模型保持可查询、一致而计算出的派生属性。XFlow 在更新这类属性时传入 ignoreHistory:
1 | node.removePropByPath('parentVerticalLane', { ignoreHistory: true }); |
ignoreHistory 在这里的含义很窄:这次属性写入不会单独进入 History。它只影响这次写入是否入栈;开发者仍需定义撤销后所有派生数据如何恢复。
以拖入泳道再撤销为例,读者可以把两条路径分开看:拖动节点的位置变化是用户操作,应进入 History;位置变化触发的泳道归属计算是派生修正,不应单独入栈。执行撤销或重做时,Graph 命令先恢复或重放主操作,再由对应的事件路径重新计算归属。后半段不是 ignoreHistory 自动完成的,必须由实现明确保证。
flowchart LR
A[用户拖动节点] --> B[位置变化进入 History]
B --> C[事件路径计算泳道归属]
C --> D[写入归属属性 ignoreHistory]
B --> E[撤销或重做位置变化]
E --> F[事件路径重新计算归属]
F --> D
这样区分用户意图与派生状态,可以避免 History 被“更新泳道归属”“删除旧分栏字段”这类用户从未主动执行过的步骤填满。用户看到的撤销单位,应当是自己能识别的操作。
事务边界需要由约定明确
当前代码中的 batch 分布在快捷键、右键菜单、工具栏与事件处理器中。History 插件不会在开发者漏掉 startBatch 时自动判断哪些命令应合并;同样地,忘记传递 ignoreHistory 的派生写入也会进入历史栈。
还有一个工程细节:现有调用通常直接成对书写 startBatch 与 stopBatch。如果中间逻辑未来可能抛错,更稳妥的封装应保证结束动作总会执行:
1 | function runUserTransaction( |
这是一项可继续演进的封装,当前项目尚未将其统一作为公共 API。类似地,派生字段也可以收敛到专门的更新入口,降低遗漏 ignoreHistory 的风险。
测试也该围绕用户能感知到的结果组织:粘贴一次后是否只需撤销一次,批量改色后是否能整体回退,拖入或移出泳道后派生归属是否仍然一致。以批量改色为例,测试应先记录两个选中节点原有的填充色;执行一次工具栏改色、再撤销一次后,两个节点都应恢复原值。“第一次撤销只恢复第一个节点”不应被视为可接受结果。相比单独断言某个 attr 是否调用过,这样更贴近图编辑器真正的契约。
撤销承载领域语义
在图编辑器里,一条 History 记录应表达用户完成的一次可理解动作;“某个方法被调用过”的痕迹不足以定义它。系统为维持模型一致性做的附带写入,也不应再为用户多制造一次撤销选择。
1 | 一次用户意图 = 一条 History 事务 |
新增工具栏动作、快捷键或事件处理器时,先问两个问题:它对应什么用户意图?其中哪些写入只是派生修正?这两个答案决定了 batch 的边界和 ignoreHistory 的使用位置,最终也决定撤销体验是否符合用户直觉。