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
2
3
4
5
6
7
8
9
10
11
12
13
14
const handleSetFillColor = (color: string) => {
setFillColor(color);
app?.current?.startBatch('batch-fill-node-color');

nodes?.forEach((node) => {
if (node.shape === 'vertical-lane') {
node.attr('vertical-name-rect/fill', color);
} else {
node.attr('body/fill', color);
}
});

app?.current?.stopBatch('batch-fill-node-color');
};

这里没有在 React 中维护另一份节点 JSON,也不靠重新渲染把颜色“同步回画布”。Cell 的 attr 直接修改 Graph 模型;React state 只让颜色选择器显示当前值。

命令式接口下,一次界面操作往往扩展成多个 Cell 写入,History 需要把这些写入重新解释成用户的一次意图。

History 应以用户意图为记录单位

XFlow 在可编辑模式装配 History 插件,并在 beforeAddCommand 中读取写入选项:

1
2
3
4
5
6
7
8
9
10
graph.use(
new History({
stackSize: 20,
beforeAddCommand: (_event, args) => {
if (args?.options.ignoreHistory) {
return false;
}
},
}),
);

这段配置只规定什么命令可以进入历史栈,并不会自动推断哪些多步骤修改属于同一次操作。事务边界仍由调用处通过 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
graph.startBatch('custom-batch-paste');
const cells = graph.paste({ offset: 32 });

cells.forEach((cell, index) => {
if (cell.isNode()) {
cell.setData({
...cell.getData(),
createLegendTime: Date.now(),
nodeId: totalNodes + index + 1,
});
cell.removePropByPath('parentVerticalLane');
cell.removePropByPath('parentHorizontalLane');
} else {
cell.removeData();
}
});

graph.stopBatch('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
2
3
4
5
6
node.removePropByPath('parentVerticalLane', { ignoreHistory: true });
node.setProp(
'parentVerticalLane',
`${currentParent.id}-${columnId}`,
{ 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
2
3
4
5
6
7
8
9
10
11
12
function runUserTransaction(
graph: Graph,
name: string,
action: () => void,
) {
graph.startBatch(name);
try {
action();
} finally {
graph.stopBatch(name);
}
}

这是一项可继续演进的封装,当前项目尚未将其统一作为公共 API。类似地,派生字段也可以收敛到专门的更新入口,降低遗漏 ignoreHistory 的风险。

测试也该围绕用户能感知到的结果组织:粘贴一次后是否只需撤销一次,批量改色后是否能整体回退,拖入或移出泳道后派生归属是否仍然一致。以批量改色为例,测试应先记录两个选中节点原有的填充色;执行一次工具栏改色、再撤销一次后,两个节点都应恢复原值。“第一次撤销只恢复第一个节点”不应被视为可接受结果。相比单独断言某个 attr 是否调用过,这样更贴近图编辑器真正的契约。

撤销承载领域语义

在图编辑器里,一条 History 记录应表达用户完成的一次可理解动作;“某个方法被调用过”的痕迹不足以定义它。系统为维持模型一致性做的附带写入,也不应再为用户多制造一次撤销选择。

1
2
一次用户意图 = 一条 History 事务
一次派生修正 = 不额外占用 History 步骤

新增工具栏动作、快捷键或事件处理器时,先问两个问题:它对应什么用户意图?其中哪些写入只是派生修正?这两个答案决定了 batch 的边界和 ignoreHistory 的使用位置,最终也决定撤销体验是否符合用户直觉。