写在前面:
在 Web 应用中,编辑器这样的应用往往需要提供 Undo(撤销) 和 Redo(重做) 功能,以便用户可以回滚或重放操作。这可以通过 快照(Snapshot) 和 命令(Command) 两种方式来实现。个人曾作为某2000w用户量的知名笔记软件-思维导图笔记从0到1开发者,对这个功能印象深刻。基于Snapshot和Command的两种模式在前期和中期都曾经开发过,在此总结一下。
为了避免可能的知识产权纠纷和麻烦,文章中涉及到的代码都已经做了通用处理
以大家熟悉的思维导图为例,其余编辑器和同类型的web应用原理相似。思维导图项目当时用的d3.js,这里不作赘述,只关注它的undo/redo功能。
1.基于快照式(Snapshot-based)
1.1 实现原理
-
-
-
数据存储
- 每次用户对思维导图进行修改(例如:新增节点、移动节点、删除节点)时,将整个数据结构(思维导图的 JSON 结构)存入一个栈(如
undoStack)。 - 执行
undo时,从undoStack弹出栈顶的快照,并用它替换当前数据结构。 - 执行
redo时,从redoStack弹出快照,恢复思维导图的状态。
- 每次用户对思维导图进行修改(例如:新增节点、移动节点、删除节点)时,将整个数据结构(思维导图的 JSON 结构)存入一个栈(如
-
栈管理
- 进行修改时,将当前状态的副本压入
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优缺点
✅ 优点
-
高效的内存使用:
- 命令模式不需要存储整个数据的快照,只存储每个操作的命令对象,这些命令对象通常包含执行和撤销操作的逻辑。
- 相较于快照模式,它的内存开销较小,尤其在数据量较大的情况下(例如图形编辑器、复杂文档编辑器等)。
-
操作可逆性:
- 每个操作都被封装为一个独立的命令对象,这意味着每个操作都可以精确控制和撤销。
- 你可以选择性地撤销特定的操作,而不是像快照模式一样要恢复到某个固定的历史快照。
-
支持复杂操作:
- 命令模式能够应对复杂的操作,因为你可以将多个操作封装为一个复合命令或批量命令(例如,多个图形元素的同时变换操作)。
- 对于高频的复杂操作,命令模式比快照模式更高效,因为它避免了保存整个数据结构的开销。
-
灵活性:
- 每个命令对象都可以拥有不同的逻辑,你可以根据需要来定义命令的行为,如延迟执行、命令合并、批量操作等。
- 还可以方便地扩展操作类型(例如添加自定义命令)。
-
支持操作合并:
- 可以在执行某些操作时将多个小操作合并为一个大操作,从而减少内存消耗和优化性能。
❌ 缺点
-
实现复杂度较高:
- 相比快照模式,命令模式的实现更加复杂,需要你定义和维护大量的命令对象,以及每个命令的
execute和undo方法。 - 需要将每个操作都封装为一个独立的命令类,这在某些简单的应用场景下可能显得过于复杂。
- 相比快照模式,命令模式的实现更加复杂,需要你定义和维护大量的命令对象,以及每个命令的
-
命令栈管理复杂:
- 需要精细管理undo/redo栈,如果操作历史很长或操作非常频繁,栈的大小和命令的管理会成为一个问题。
- 在栈管理时需要注意避免内存泄漏(如未及时清理无效命令)。
-
执行效率问题:
- 对于某些操作来说,执行
undo和redo操作时并不总是非常高效,特别是在处理大量命令对象时。 - 如果操作过于频繁或命令结构过于复杂,可能导致性能下降。
- 对于某些操作来说,执行
-
不适合简单操作:
- 对于一些简单的应用,命令模式可能导致过度设计。如果你的应用只是做一些简单的状态变更(比如文本编辑),快照模式会更直接和高效。
-
状态管理:
- 命令模式需要精确管理操作的状态,在复杂场景下,如何处理命令的依赖关系和顺序可能变得非常复杂。
- 如果命令之间存在顺序依赖或嵌套关系,可能需要额外的代码来协调这些操作
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应用(如本例中的思维导图) |