10 操作历史记录 - Undo&Redo 实现方案

735 阅读8分钟

在可视化与流程图编辑场景中,“撤销(Undo)/重做(Redo)”功能可以显著提升用户的操作体验与容错性。无论是节点位置变动、连线新增或删除,用户都希望能够随时“回到上一步”或“继续下一步”。本篇文章将探讨 X6LogicFlowReactFlow 在撤销/重做功能上的设计思路与实现方式,帮助你在项目中构建强大的操作历史管理。

一、为何需要撤销/重做

  1. 人性化体验

用户在编辑流程图或可视化页面时难免会有误操作的情况。如果没有撤销/重做功能,一旦误删节点或改错属性,就只能重新来过,浪费时间。撤销/重做可以让用户在尝试各种操作时更具信心,降低试错成本。

  1. 多步骤操作

大多数可视化编辑涉及多个步骤:创建节点→连接连线→设置属性→拖拽对齐……一旦要回到某个状态,需要系统记录这些步骤之间的差异或数据。

  1. 团队协作与审计

在多人协作场景下,撤销/重做可以与版本记录或审计功能配合,清晰展示谁在何时做了哪些改动。即使不是多人协作,保留可回溯的历史记录也有助于错误排查与调试。

二、撤销/重做的通用思路

无论是在X6、LogicFlow还是ReactFlow中,实现Undo/Redo大体上有两种思路:

  1. 记录操作(Operation Stack)
  • 每当用户执行一个操作(如添加节点、移动节点、删除连线)时,将这一“操作”记录为“命令对象(Command Pattern)”或“操作日志”。

  • “撤销”即执行与此操作相反的步骤,“重做”则再次执行原操作。

  • 优点:相对节省内存,仅记录操作本身以及恢复/逆操作所需的信息。

  • 缺点:对每个操作都要定义正向和逆向逻辑,开发和维护成本较高;操作间可能存在依赖,需要小心管理复杂场景。

  1. 记录状态快照(State Snapshot Stack)
  • 每当用户完成一次操作,就将当前流程图的完整数据(或增量)存储到一个“状态堆栈”中。

  • “撤销”即将画布恢复到上一个状态;“重做”则恢复到下一个状态。

  • 优点:实现相对简单,直接在撤销/重做时用历史数据覆盖当前画布。

  • 缺点:若状态体积过大(许多节点和连线)会消耗较多内存;状态应用的过程也可能较慢,需要一些增量或差异更新策略。

三、X6 的实现方式

  1. 记录操作

X6提供Command系统:开发者可以定义各种命令(如AddNodeCommand, RemoveEdgeCommand),在执行时把当前操作及其逆向逻辑存储进一个操作栈。

• 当用户按下“撤销”时,框架会调用命令对象的undo()方法,按事先定义好的逻辑恢复到前一状态;“重做”则调用redo()方法。

  1. 示例
// 定义一个添加节点的命令
class AddNodeCommand {
  constructor(graph, nodeConfig) {
    this.graph = graph;
    this.nodeConfig = nodeConfig;
    this.createdNode = null; // 用来记录执行时创建的node
  }
  execute() {
    this.createdNode = this.graph.addNode(this.nodeConfig);
  }
  undo() {
    if (this.createdNode) {
      this.graph.removeNode(this.createdNode.id);
    }
  }
  redo() {
    if (this.createdNode) {
      this.createdNode = this.graph.addNode(this.nodeConfig);
    }
  }
}

// 操作历史管理
class CommandManager {
  constructor() {
    this.undoStack = [];
    this.redoStack = [];
  }
  execute(command) {
    command.execute();
    this.undoStack.push(command);
    this.redoStack = [];
  }
  undo() {
    const cmd = this.undoStack.pop();
    if (cmd) {
      cmd.undo();
      this.redoStack.push(cmd);
    }
  }
  redo() {
    const cmd = this.redoStack.pop();
    if (cmd) {
      cmd.redo();
      this.undoStack.push(cmd);
    }
  }
}

在X6 Graph初始化时,注入CommandManager并在相关操作时触发execute

  1. 优点与局限
  • 优点:对每个操作都能有精准的undo/redo逻辑,节省内存,因为只存了操作而非全量状态。

  • 局限:开发者需要写多种Command,对操作进行拆分、维护;当操作复杂时,逻辑较繁琐。

四、LogicFlow / ReactFlow 的状态快照实现

LogicFlow

  1. 基于图数据的快照
  • LogicFlow中,节点和连线都在内存中有一份图数据(lf.getGraphData())。

  • 当用户完成某一步操作(例如拖拽节点后松手),可以通过lf.getGraphData()获取当前全部流程图数据,压入一个状态栈。

  • 撤销时,从栈中取出上一个数据,用lf.render(那份数据)覆盖当前画布。

  1. 示例
class HistoryManager {
  constructor(lf) {
    this.lf = lf;
    this.undoStack = [];
    this.redoStack = [];

    // 监听节点、边变化
    this.lf.on('node:dragend', () => this.record());
    this.lf.on('edge:add', () => this.record());
    this.lf.on('edge:remove', () => this.record());
    // ...更多事件监听
  }
  
  record() {
    const data = this.lf.getGraphData();
    // 注意:可做深拷贝
    this.undoStack.push(JSON.parse(JSON.stringify(data)));
    this.redoStack = [];
  }
  
  undo() {
    if (this.undoStack.length > 1) {
      const last = this.undoStack.pop();
      this.redoStack.push(last);
      const prev = this.undoStack[this.undoStack.length - 1];
      this.lf.render(JSON.parse(JSON.stringify(prev)));
    }
  }
  
  redo() {
    if (this.redoStack.length > 0) {
      const next = this.redoStack.pop();
      this.undoStack.push(next);
      this.lf.render(JSON.parse(JSON.stringify(next)));
    }
  }
}
  1. 优点与局限
  • 优点:实现相对直观,不用对每个操作定义命令;任何变化后只需存储一份画布数据即可。

  • 局限:当画布包含大量节点/连线,或经常操作,会产生较大的状态栈,导致内存占用上升。同时,每次撤销或重做都要做整图重渲染,可能影响性能。

ReactFlow

  1. React状态驱动
  • ReactFlow中,节点、连线数据通常存放在React的state或Redux等全局状态管理中,一旦数据改变就自动触发UI刷新。

  • 记录Undo/Redo只需在数据层面存储快照:每次变更后将最新nodes、edges放入undoStack;撤销就把上一个版本pop出来赋给当前状态即可。

  1. 示例
const [nodes, setNodes] = useState<NodeData[]>([]);
const [edges, setEdges] = useState<EdgeData[]>([]);
const [undoStack, setUndoStack] = useState<StateSnapshot[]>([]);
const [redoStack, setRedoStack] = useState<StateSnapshot[]>([]);

// 每次有操作更新节点/连线时
const recordSnapshot = (newNodes: NodeData[], newEdges: EdgeData[]) => {
  setUndoStack(prev => [...prev, { nodes: newNodes, edges: newEdges }]);
  setRedoStack([]); // 重置redo栈
};

// 撤销
const undo = () => {
  setUndoStack(prev => {
    if (prev.length > 1) {
      const last = prev.pop()!;
      setRedoStack(r => [...r, last]);
      const prevSnap = prev[prev.length - 1];
      setNodes(prevSnap.nodes);
      setEdges(prevSnap.edges);
    }
    return [...prev];
  });
};

// 重做
const redo = () => {
  setRedoStack(prev => {
    if (prev.length > 0) {
      const next = prev.pop()!;
      setUndoStack(u => [...u, next]);
      setNodes(next.nodes);
      setEdges(next.edges);
    }
    return [...prev];
  });
};
  1. 优点与局限
  • 优点:操作只需维护 React state 的快照,撤销/重做时直接替换节点、连线数组;逻辑简单统一。

  • 局限:若流程图数据量很大,每次都存储全量数组会有内存和性能压力;可以考虑差量存储或使用不可变数据结构来提高效率。

五、如何选择合适的方式

  1. 命令模式(Operation Stack)
  • 适用于对内存或数据体量较为敏感、对操作过程有精细化管理需求的场景。

  • 优点:存储量小,撤销/重做精细;缺点:需要手动实现各种操作的 undo() 和 redo()。

  1. 状态快照(State Snapshot)
  • 适用于流程图数据结构相对可控、或已有完整的数据序列化/反序列化能力的场景。

  • 优点:实现简单、直观,只要获取当前图数据就能备份;缺点:大规模数据时,快照会占用较大内存,每次还原都要全量重绘或做diff。

  1. 混合策略
  • 也可以将命令模式与状态快照结合起来:对大部分简单操作用命令模式(节省存储),在关键时刻(如初次加载、定期)存储全量快照以防止操作过多导致混乱。

  • 根据项目需求决定是以命令模式为主,还是以快照方式为主。

六、性能与优化

  1. 内存管理
  • 如果使用状态快照,注意限制Undo/Redo栈大小(如最多20或50步),防止无限制地存储导致内存爆炸。

  • 也可在超过一定步数后合并历史,或只保留关键节点。

  1. 增量存储
  • 如果每次变化量很小,可以只存储差量(diff),在撤销时合并差量来恢复。

  • 但增量计算需要额外的逻辑,通常只有在有大量操作或大规模数据时才值得付出额外开发成本。

  1. 懒加载
  • 如果撤销/重做并不频繁,而节点量又特别大,可以在操作完成后延迟保存快照;或者只在关键操作(如节点新增/删除)时才进行存储,而不记录每一次拖动事件。

七、综合建议

  1. 明确需求场景
  • 如果你使用 X6 并想要精细化的撤销/重做(每个操作有清晰逆向逻辑),可直接使用或扩展X6自带的Command系统。

  • 如果你在 LogicFlowReactFlow 中,项目规模不算超大,且图数据结构可轻松导入导出,则用“状态快照”方式更直观简单。

  1. 限制步数 & 提示
  • 无论哪种方案,都建议限制最大撤销步数,并给用户提供可视化的历史记录列表或状态提示(如“已撤销到第3步”)等,提升操作体验。
  1. 与其他功能结合
  • 撤销/重做往往与保存/加载多用户协作版本管理等功能相关联;在团队协作场景下,还要考虑如何同步撤销/重做信息给其他用户,或者只对本地操作生效。

八、总结

X6 倾向于 命令模式:更精确地控制每一次操作,只需记录操作内容和逆向逻辑,适合对内存敏感或需要严格管理操作依赖的情况。 LogicFlow / ReactFlow 倾向于 状态快照:基于节点/连线的全局数据,撤销/重做就是切换到历史版本,非常易于实现;但需要注意规模和性能问题。

在真实项目中,可根据需求将命令模式与状态快照结合,也可引入不可变数据结构、diff算法、版本管理等高级技术。 

在选择具体实现时,务必结合自身需求(节点数量、操作频度、团队技术栈)与用户体验期望,来设计合适的Undo/Redo管理策略。只有将性能与可维护性平衡好,才能为用户提供流畅、可靠的操作回溯能力,让流程图或可视化应用更加“所见即所得”,更具专业水准。 

希望这篇文章能帮助你理解三种主流流程图库在撤销/重做功能上的思路与差异,并为实际项目的历史管理功能设计提供一些参考。