详述web应用中的撤销/重做、undo/redo

337 阅读7分钟

写在前面:

在 Web 应用中,编辑器这样的应用往往需要提供 Undo(撤销) 和 Redo(重做) 功能,以便用户可以回滚或重放操作。这可以通过 快照(Snapshot) 和 命令(Command) 两种方式来实现。个人曾作为某2000w用户量的知名笔记软件-思维导图笔记从0到1开发者,对这个功能印象深刻。基于Snapshot和Command的两种模式在前期和中期都曾经开发过,在此总结一下。

为了避免可能的知识产权纠纷和麻烦,文章中涉及到的代码都已经做了通用处理

以大家熟悉的思维导图为例,其余编辑器和同类型的web应用原理相似。思维导图项目当时用的d3.js,这里不作赘述,只关注它的undo/redo功能。

1.基于快照式(Snapshot-based)

1.1 实现原理

      1. 数据存储

        • 每次用户对思维导图进行修改(例如:新增节点、移动节点、删除节点)时,将整个数据结构(思维导图的 JSON 结构)存入一个栈(如 undoStack)。
        • 执行 undo 时,从 undoStack 弹出栈顶的快照,并用它替换当前数据结构。
        • 执行 redo 时,从 redoStack 弹出快照,恢复思维导图的状态。
      2. 栈管理

        • 进行修改时,将当前状态的副本压入 undoStack,清空 redoStack(因为 redo 只适用于未被新修改覆盖的状态)。
        • 撤销(undo)时,将当前状态推入 redoStack,然后恢复 undoStack 顶部的状态。
        • 重做(redo)时,将当前状态推入 undoStack,然后恢复 redoStack 顶部的状态。

1.2优缺点

优点

      • 实现简单,直接存储完整的思维导图数据。
      • 适用于小型数据集,比如简单的 JSON 数据结构。
      • 适用于状态独立的场景,不会有复杂的回放逻辑。

缺点

      • 占用大量内存:每次存储完整快照,可能导致内存开销大,尤其是数据量大的时候。
      • 性能开销大:切换状态时,可能需要整体替换数据,导致 DOM 重新渲染,影响性能

1.3 代码示例

 

我们定义一个 MindMap 类,用于管理节点数据。

class MindMap {
  constructor() {
    this.nodes = {}; // 存储思维导图的节点
  }

  // 复制当前数据的快照
  getSnapshot() {
    return JSON.parse(JSON.stringify(this.nodes)); // 深拷贝
  }

  // 载入快照
  loadSnapshot(snapshot) {
    this.nodes = JSON.parse(JSON.stringify(snapshot));
    console.log("Snapshot Loaded:", this.nodes);
  }

  // 添加节点
  addNode(node) {
    this.nodes[node.id] = node;
    console.log("Added Node:", node);
  }

  // 删除节点
  removeNode(nodeId) {
    if (this.nodes[nodeId]) {
      console.log("Removed Node:", this.nodes[nodeId]);
      delete this.nodes[nodeId];
    }
  }

  // 移动节点
  moveNode(nodeId, newPosition) {
    if (this.nodes[nodeId]) {
      console.log("Moved Node:", nodeId, "to", newPosition);
      this.nodes[nodeId].position = newPosition;
    }
  }
}

我们定义 SnapshotManager 来管理撤销(undo)和重做(redo)。

class SnapshotManager {
  constructor(mindMap) {
    this.mindMap = mindMap;
    this.undoStack = []; // 撤销栈
    this.redoStack = []; // 重做栈
  }

  // 记录当前快照(执行新操作时调用)
  saveSnapshot() {
    this.undoStack.push(this.mindMap.getSnapshot());
    this.redoStack = []; // 新的操作会清空 redo 栈
    console.log("Snapshot saved. Undo stack size:", this.undoStack.length);
  }

  // 撤销操作
  undo() {
    if (this.undoStack.length === 0) {
      console.log("Nothing to undo.");
      return;
    }
    this.redoStack.push(this.mindMap.getSnapshot()); // 当前快照存入 redo 栈
    const previousSnapshot = this.undoStack.pop(); // 取出上一个快照
    this.mindMap.loadSnapshot(previousSnapshot);
    console.log("Undo executed.");
  }

  // 重做操作
  redo() {
    if (this.redoStack.length === 0) {
      console.log("Nothing to redo.");
      return;
    }
    this.undoStack.push(this.mindMap.getSnapshot()); // 先保存当前快照
    const redoSnapshot = this.redoStack.pop(); // 取出 redo 栈顶快照
    this.mindMap.loadSnapshot(redoSnapshot);
    console.log("Redo executed.");
  }
}

2.基于命令式(Command-based)

2.1实现原理

    • 这种方式的核心思想是 记录操作步骤,而不是存储整个数据快照。

    • 每次用户执行操作时,记录一条 命令(Command) ,命令包括:

      • execute() 方法:执行该操作。
      • undo() 方法:撤销该操作。
    • undo 时执行 undo() 方法,redo 时重新执行 execute() 方法。

2.2优缺点

优点

  • 高效的内存使用

    • 命令模式不需要存储整个数据的快照,只存储每个操作的命令对象,这些命令对象通常包含执行和撤销操作的逻辑。
    • 相较于快照模式,它的内存开销较小,尤其在数据量较大的情况下(例如图形编辑器、复杂文档编辑器等)。
  • 操作可逆性

    • 每个操作都被封装为一个独立的命令对象,这意味着每个操作都可以精确控制和撤销
    • 你可以选择性地撤销特定的操作,而不是像快照模式一样要恢复到某个固定的历史快照。
  • 支持复杂操作

    • 命令模式能够应对复杂的操作,因为你可以将多个操作封装为一个复合命令或批量命令(例如,多个图形元素的同时变换操作)。
    • 对于高频的复杂操作,命令模式比快照模式更高效,因为它避免了保存整个数据结构的开销。
  • 灵活性

    • 每个命令对象都可以拥有不同的逻辑,你可以根据需要来定义命令的行为,如延迟执行、命令合并、批量操作等。
    • 还可以方便地扩展操作类型(例如添加自定义命令)。
  • 支持操作合并

    • 可以在执行某些操作时将多个小操作合并为一个大操作,从而减少内存消耗和优化性能。

缺点

  • 实现复杂度较高

    • 相比快照模式,命令模式的实现更加复杂,需要你定义和维护大量的命令对象,以及每个命令的 executeundo 方法。
    • 需要将每个操作都封装为一个独立的命令类,这在某些简单的应用场景下可能显得过于复杂。
  • 命令栈管理复杂

    • 需要精细管理undo/redo栈,如果操作历史很长或操作非常频繁,栈的大小和命令的管理会成为一个问题。
    • 在栈管理时需要注意避免内存泄漏(如未及时清理无效命令)。
  • 执行效率问题

    • 对于某些操作来说,执行 undoredo 操作时并不总是非常高效,特别是在处理大量命令对象时。
    • 如果操作过于频繁或命令结构过于复杂,可能导致性能下降。
  • 不适合简单操作

    • 对于一些简单的应用,命令模式可能导致过度设计。如果你的应用只是做一些简单的状态变更(比如文本编辑),快照模式会更直接和高效。
  • 状态管理

    • 命令模式需要精确管理操作的状态,在复杂场景下,如何处理命令的依赖关系和顺序可能变得非常复杂。
    • 如果命令之间存在顺序依赖或嵌套关系,可能需要额外的代码来协调这些操作

 

2.3代码示例

下面是一个 简单的思维导图操作管理 示例,支持 添加节点、删除节点、移动节点

 

// 定义一个命令基类
class Command {
  execute() {}
  undo() {}
}
// 添加节点
class AddNodeCommand extends Command {
  constructor(mindMap, node) {
    super();
    this.mindMap = mindMap;
    this.node = node;
  }

  execute() {
    this.mindMap.addNode(this.node);
  }

  undo() {
    this.mindMap.removeNode(this.node.id);
  }
}
// 删除节点
class RemoveNodeCommand extends Command {
  constructor(mindMap, node) {
    super();
    this.mindMap = mindMap;
    this.node = node;
  }

  execute() {
    this.mindMap.removeNode(this.node.id);
  }

  undo() {
    this.mindMap.addNode(this.node);
  }
}
// 移动节点
class MoveNodeCommand extends Command {
  constructor(mindMap, nodeId, oldPosition, newPosition) {
    super();
    this.mindMap = mindMap;
    this.nodeId = nodeId;
    this.oldPosition = oldPosition;
    this.newPosition = newPosition;
  }

  execute() {
    this.mindMap.moveNode(this.nodeId, this.newPosition);
  }

  undo() {
    this.mindMap.moveNode(this.nodeId, this.oldPosition);
  }
}

 

定义一个CommandManager 来管理这些命令的执行、撤销和重做

class CommandManager {
  constructor() {
    this.undoStack = [];
    this.redoStack = [];
  }

  executeCommand(command) {
    command.execute();
    this.undoStack.push(command);
    this.redoStack = []; // 新的操作会清空 redo 栈
  }

  undo() {
    if (this.undoStack.length === 0) return;
    const command = this.undoStack.pop();
    command.undo();
    this.redoStack.push(command);
  }

  redo() {
    if (this.redoStack.length === 0) return;
    const command = this.redoStack.pop();
    command.execute();
    this.undoStack.push(command);
  }
}

下面是一个完整的 思维导图类

class MindMap {
  constructor() {
    this.nodes = {};
  }

  addNode(node) {
    this.nodes[node.id] = node;
    console.log("Added Node:", node);
  }

  removeNode(nodeId) {
    if (this.nodes[nodeId]) {
      console.log("Removed Node:", this.nodes[nodeId]);
      delete this.nodes[nodeId];
    }
  }

  moveNode(nodeId, newPosition) {
    if (this.nodes[nodeId]) {
      console.log("Moved Node:", nodeId, "to", newPosition);
      this.nodes[nodeId].position = newPosition;
    }
  }
}

 

 

 

 

MindMap和CommandManager初始化的使用:

const mindMap = new MindMap();
const commandManager = new CommandManager();

// 添加节点
const node1 = { id: 1, name: "Root", position: { x: 0, y: 0 } };
const addNodeCmd = new AddNodeCommand(mindMap, node1);
commandManager.executeCommand(addNodeCmd);

// 移动节点
const moveCmd = new MoveNodeCommand(mindMap, 1, { x: 0, y: 0 }, { x: 100, y: 100 });
commandManager.executeCommand(moveCmd);

// 执行 Undo(撤销移动)
commandManager.undo(); // 节点回到原来的位置

// 执行 Redo(重新移动)
commandManager.redo(); // 节点移动到新的位置

 

3.总结

对比项快照模式命令模式
存储内容整个数据快照(JSON)仅存储操作
存储大小较大,影响性能较大,影响性能
撤销粒度只能恢复某个快照的全部内容可精确撤销单个操作
性能适用于小型数据,性能低适用于小型数据,性能低
适用场景适用于小型数据、简单的文本适用于复杂的操作系统(如 Photoshop)、复杂app应用(如本例中的思维导图)