2025-11-23-Undo/Redo机制具体实现

3194 个字
16 分钟
2025-11-23-Undo/Redo机制具体实现

1. 模块摘要 (Executive Summary)#

Undo/Redo 机制是画布应用中实现操作撤销和重做的核心功能模块。它基于命令模式(Command Pattern)实现来,管理操作历史、执行撤销/重做操作和防止操作冲突,通过维护撤销栈和重做栈来管理用户的操作历史。

  • 项目结构树

    Terminal window
    src/
    ├── lib/
    ├── UndoRedoManager.ts # 撤销重做管理器核心实现
    └── UpdateElementCommand.ts # 元素更新命令实现
    └── stores/
    └── canvasStore.ts # 状态存储,命令操作的目标
    • Command Pattern:设计模式,用于封装操作命令
    • Zustand:状态管理库,作为命令操作的目标
    • TypeScript:提供类型安全和代码可维护性

2. Props 和相关类型定义#

2.1 UndoRedoManager 核心方法#

撤销重做管理器提供了一系列核心方法用于管理操作命令。

方法名参数返回值描述
executeCommandcommand: Commandvoid执行并记录命令
undovoid执行撤销操作
redovoid执行重做操作
lockvoid锁定管理器,防止记录新命令
unlockvoid解锁管理器
isLockedboolean检查管理器是否被锁定
canUndoboolean检查是否可以撤销
canRedoboolean检查是否可以重做

2.2 核心类型定义#

Command 接口: 定义了命令对象必须实现的方法。

export interface Command {
execute(): void; // 执行命令
undo(): void; // 撤销命令
redo(): void; // 重做命令
}

UpdateOperation 接口: 定义了元素更新操作的数据结构。

interface UpdateOperation {
id: string; // 元素ID
initialAttrs: Partial<CanvasElement>; // 修改前的属性
finalAttrs: Partial<CanvasElement>; // 修改后的属性
}

3. 核心状态管理 (State Architecture)#

⚠️ 为防止在执行撤销/重做操作时记录新的命令,系统实现了锁定机制。在执行命令时会先锁定管理器,执行完成后再解锁,确保操作的原子性。

3.1 内部状态 (Local State)#

UndoRedoManager 维护以下内部状态用于管理操作历史:

状态名类型描述
undoStackCommand[]撤销命令栈,存储可以撤销的命令
redoStackCommand[]重做命令栈,存储可以重做的命令
lockedboolean锁定状态,防止在执行命令时记录新命令

3.2 外部状态 (Global/Server State)#

Undo/Redo 机制通过 Zustand 状态管理库操作外部状态:

状态名类型描述
elementsRecord<string, CanvasElement>所有画布元素数据,命令操作的目标

3.3 状态同步机制#

graph TD A[用户操作] --> B{StageManager} B --> C[创建命令对象] C --> D[UndoRedoManager.executeCommand] D --> E{管理器锁定?} E -->|是| F[忽略命令] E -->|否| G[执行命令] G --> H[命令入撤销栈] H --> I[清空重做栈] I --> J[Zustand 状态更新] subgraph 撤销操作 K[UndoRedoManager.undo] K --> L{撤销栈空?} L -->|是| M[无法撤销] L -->|否| N[弹出命令] N --> O[执行命令.undo] O --> P[命令入重做栈] P --> Q[Zustand 状态更新] end subgraph 重做操作 R[UndoRedoManager.redo] R --> S{重做栈空?} S -->|是| T[无法重做] S -->|否| U[弹出命令] U --> V[执行命令.redo] V --> W[命令入撤销栈] W --> X[Zustand 状态更新] end style A fill:#e1f5fe style J fill:#e8f5e8 style Q fill:#e8f5e8 style X fill:#e8f5e8 style F fill:#ffebee

4. 命令管理机制#

Undo/Redo 机制采用命令模式(Command Pattern)来管理操作命令,通过定义统一的接口和不同的实现类来处理各种操作。

4.1 命令类型#

系统中主要有两种命令类型:

  1. 快照命令(SnapshotCommand):用于记录整个画布状态的变化,通常用于添加元素、删除元素等较大范围的操作,保存完整的状态快照

  2. 更新元素命令(UpdateElementCommand):用于记录特定元素的属性变化,主要用于拖拽移动和调整大小操作,只保存相关元素的特定属性变化

4.2 命令接口定义#

所有命令都实现统一的 Command 接口:

export interface Command {
execute(): void; // 执行命令
undo(): void; // 撤销命令
redo(): void; // 重做命令
}

4.3 快照命令(SnapshotCommand)#

快照命令用于记录整个画布状态的变化,适用于影响范围较大的操作。

核心实现

export class SnapshotCommand implements Command {
private prevState: any;
private nextState: any;
private commandId: number;
private type: string;
constructor(prevState: any, nextState: any, type: any) {
// 使用 structuredClone 进行深拷贝,确保状态隔离
this.prevState = structuredClone(prevState);
this.nextState = structuredClone(nextState);
this.type = type;
// 生成唯一的命令ID用于调试
this.commandId = Date.now() % 1000000;
}
execute(): void {
// execute在添加到命令栈之前已经执行了
}
undo(): void {
// 恢复到之前的状态
useStore.setState(this.prevState);
}
redo(): void {
// 恢复到之后的状态
useStore.setState(this.nextState);
}
}

4.4 更新元素命令(UpdateElementCommand)#

更新元素命令用于记录特定元素的属性变化,适用于影响范围较小的精细操作。

核心实现

interface UpdateOperation {
id: string;
initialAttrs: Partial<CanvasElement>; // 修改前的属性
finalAttrs: Partial<CanvasElement>; // 修改后的属性
}
export class UpdateElementCommand implements Command {
private commandId: string;
constructor(
private operations: UpdateOperation[],
private operationType: string = "更新元素"
) {
// 生成唯一命令ID
this.commandId = `UpdateElementCommand-${Math.random()
.toString(36)
.slice(2, 11)}`;
}
execute(): void {
// 应用最终状态
const updates: Record<string, Partial<CanvasElement>> = {};
this.operations.forEach((op) => {
updates[op.id] = op.finalAttrs;
});
useStore.setState((state) => {
const newElements = { ...state.elements };
Object.entries(updates).forEach(([id, attrs]) => {
if (newElements[id]) newElements[id] = { ...newElements[id], ...attrs };
});
return { elements: newElements };
});
}
undo(): void {
// 撤销:恢复到 initialAttrs
const updates: Record<string, Partial<CanvasElement>> = {};
this.operations.forEach((op) => {
updates[op.id] = op.initialAttrs;
});
useStore.setState((state) => {
const newElements = { ...state.elements };
Object.entries(updates).forEach(([id, attrs]) => {
if (newElements[id]) newElements[id] = { ...newElements[id], ...attrs };
});
return { elements: newElements };
});
}
redo(): void {
// 重做:恢复到 finalAttrs
const updates: Record<string, Partial<CanvasElement>> = {};
this.operations.forEach((op) => {
updates[op.id] = op.finalAttrs;
});
useStore.setState((state) => {
const newElements = { ...state.elements };
Object.entries(updates).forEach(([id, attrs]) => {
if (newElements[id]) newElements[id] = { ...newElements[id], ...attrs };
});
return { elements: newElements };
});
}
}

4.5 命令生命周期#

命令的生命周期包括创建、执行、撤销和重做四个阶段:

graph TD A[命令创建] --> B[命令执行] B --> C{用户操作} C -->|撤销| D[执行undo方法] C -->|重做| E[执行redo方法] D --> F[命令状态切换] E --> F F --> G[状态更新完成] style A fill:#e1f5fe style B fill:#f3e5f5 style D fill:#fff3e0 style E fill:#fff3e0 style G fill:#e8f5e8

5. 命令栈管理机制#

撤销/重做机制使用两个栈来管理命令历史:

  1. 撤销栈(Undo Stack)

存储用户可以撤销的操作命令,栈顶是最近执行的命令,执行新命令时,命令被推入此栈,执行撤销操作时,命令从此栈弹出并推入重做栈

  1. 重做栈(Redo Stack)

存储用户可以重做的操作命令,在执行撤销操作时,被撤销的命令被推入此栈,执行重做操作时,命令从此栈弹出并推入撤销栈,执行新命令时,此栈被清空

5.1 命令栈操作流程#

graph TD A[执行新命令] --> B[命令入撤销栈] B --> C[清空重做栈] D[执行撤销] --> E{撤销栈空?} E -->|是| F[无操作] E -->|否| G[弹出命令] G --> H[执行命令.undo] H --> I[命令入重做栈] J[执行重做] --> K{重做栈空?} K -->|是| L[无操作] K -->|否| M[弹出命令] M --> N[执行命令.redo] N --> O[命令入撤销栈] style A fill:#e1f5fe style B fill:#f3e5f5 style C fill:#f3e5f5 style D fill:#e1f5fe style H fill:#fff3e0 style I fill:#fff3e0 style J fill:#e1f5fe style N fill:#e8f5e8 style O fill:#e8f5e8

5.2 不同类型的命令#

撤销栈中并不全是快照命令。系统中至少有两种不同类型的命令:

  1. 快照命令(SnapshotCommand)

    • 用于记录整个画布状态的变化
    • 通常用于添加元素、删除元素等较大范围的操作
    • 保存完整的状态快照
  2. 更新元素命令(UpdateElementCommand)

    • 用于记录特定元素的属性变化
    • 主要用于拖拽移动和调整大小操作
    • 只保存相关元素的特定属性变化

5.3 操作序列和撤销栈状态变化示例#

初始状态#

撤销栈:空
重做栈:空

1. 创建元素 A#

当创建元素 A 时,系统会生成一个快照命令,记录整个画布状态的变化。
撤销栈:[SnapshotCommand_A] (大小: 1)
重做栈:空

2. 移动 A 到一个位置#

当移动元素 A 时,系统会生成一个更新元素命令(UpdateElementCommand),只记录 A 元素位置的变化。
撤销栈:[SnapshotCommand_A, UpdateElementCommand_MoveA] (大小: 2)
重做栈:空

3. 创建元素 B#

当创建元素 B 时,系统会生成另一个快照命令,记录添加 B 元素后的状态。
撤销栈:[SnapshotCommand_A, UpdateElementCommand_MoveA, SnapshotCommand_B] (大小: 3)
重做栈:空

4. 缩放 B 到一个位置#

当缩放元素 B 时,系统会生成一个更新元素命令,记录 B 元素尺寸和位置的变化。
撤销栈:[SnapshotCommand_A, UpdateElementCommand_MoveA, SnapshotCommand_B, UpdateElementCommand_ResizeB] (大小: 4)
重做栈:空

5. 移动 B 到一个位置#

当再次移动元素 B 时,系统会生成另一个更新元素命令,记录 B 元素位置的新变化。
撤销栈:[SnapshotCommand_A, UpdateElementCommand_MoveA, SnapshotCommand_B, UpdateElementCommand_ResizeB, UpdateElementCommand_MoveB] (大小: 5)
重做栈:空

5.4 执行撤销操作时的状态变化#

第一次撤销(移动 B 操作)#
  1. 从撤销栈弹出最后一个命令:UpdateElementCommand_MoveB
  2. 执行该命令的 undo()方法,将 B 元素恢复到缩放后的位置
  3. 将该命令推入重做栈

撤销栈:[SnapshotCommand_A, UpdateElementCommand_MoveA, SnapshotCommand_B, UpdateElementCommand_ResizeB] (大小: 4)
重做栈:[UpdateElementCommand_MoveB] (大小: 1)

第二次撤销(缩放 B 操作)#
  1. 从撤销栈弹出最后一个命令:UpdateElementCommand_ResizeB
  2. 执行该命令的 undo()方法,将 B 元素恢复到刚创建时的尺寸和位置
  3. 将该命令推入重做栈

撤销栈:[SnapshotCommand_A, UpdateElementCommand_MoveA, SnapshotCommand_B] (大小: 3)
重做栈:[UpdateElementCommand_MoveB, UpdateElementCommand_ResizeB] (大小: 2)

第三次撤销(创建 B 操作)#
  1. 从撤销栈弹出最后一个命令:SnapshotCommand_B
  2. 执行该命令的 undo()方法,将整个画布状态恢复到创建 B 之前的状态(即只包含 A 元素的状态)
  3. 将该命令推入重做栈

撤销栈:[SnapshotCommand_A, UpdateElementCommand_MoveA] (大小: 2)
重做栈:[UpdateElementCommand_MoveB, UpdateElementCommand_ResizeB, SnapshotCommand_B] (大小: 3)

第四次撤销(移动 A 操作)#
  1. 从撤销栈弹出最后一个命令:UpdateElementCommand_MoveA
  2. 执行该命令的 undo()方法,将 A 元素恢复到初始位置
  3. 将该命令推入重做栈

撤销栈:[SnapshotCommand_A] (大小: 1)
重做栈:[UpdateElementCommand_MoveB, UpdateElementCommand_ResizeB, SnapshotCommand_B, UpdateElementCommand_MoveA] (大小: 4)

第五次撤销(创建 A 操作)#
  1. 从撤销栈弹出最后一个命令:SnapshotCommand_A
  2. 执行该命令的 undo()方法,将整个画布状态恢复到初始状态(空画布)
  3. 将该命令推入重做栈

撤销栈:空
重做栈:[UpdateElementCommand_MoveB, UpdateElementCommand_ResizeB, SnapshotCommand_B, UpdateElementCommand_MoveA, SnapshotCommand_A] (大小: 5)

6. 逻辑流程 (Logic Flow)#

6.1 交互时序图 (Mermaid)#

sequenceDiagram participant U as 用户 participant SM as StageManager participant URM as UndoRedoManager participant C as Command participant ZS as Zustand Store U->>SM: 执行操作(如拖拽元素) SM->>SM: 记录操作初始状态 SM->>ZS: 更新元素状态 SM->>C: 创建 UpdateElementCommand SM->>URM: executeCommand(command) URM->>C: command.execute() C->>URM: 命令入撤销栈 URM->>URM: 清空重做栈 U->>URM: 执行撤销 (Ctrl+Z) URM->>URM: 锁定管理器 URM->>C: command.undo() C->>ZS: 恢复初始状态 C->>URM: 命令入重做栈 URM->>URM: 解锁管理器 U->>URM: 执行重做 (Ctrl+Y) URM->>URM: 锁定管理器 URM->>C: command.redo() C->>ZS: 恢复最终状态 C->>URM: 命令入撤销栈 URM->>URM: 解锁管理器

6.2 核心函数解析#

executeCommand 函数:当用户完成一个操作(如创建、更新、删除元素)时触发,执行命令并将命令添加到撤销栈,同时清空重做栈

executeCommand(command: Command) {
if (this.locked) {
// 如果管理器被锁定,忽略命令
return
}
// 执行命令
command.execute()
// 将命令添加到撤销栈
this.undoStack.push(command)
// 清空重做栈
this.redoStack = []
}

undo 函数:当用户执行撤销操作(如按 Ctrl+Z)时触发,从撤销栈弹出命令,执行命令的 undo 方法,并将命令放入重做栈

undo() {
if (this.undoStack.length === 0) {
// 撤销栈为空,无法撤销
return
}
this.lock() // 锁定管理器
const command = this.undoStack.pop()! // 弹出命令
command.undo() // 执行撤销
this.redoStack.push(command) // 命令入重做栈
this.unlock() // 解锁管理器
}

redo 函数:当用户执行重做操作(如按 Ctrl+Y)时触发,从重做栈弹出命令,执行命令的 redo 方法,并将命令放入撤销栈

redo() {
if (this.redoStack.length === 0) {
// 重做栈为空,无法重做
return
}
this.lock() // 锁定管理器
const command = this.redoStack.pop()! // 弹出命令
command.redo() // 执行重做
this.undoStack.push(command) // 命令入撤销栈
this.unlock() // 解锁管理器
}

7. UI 与样式实现 (UI Implementation)#

Undo/Redo 机制通过快捷键和控制台界面与用户交互:

graph TD A[用户交互] --> B{交互方式} B --> C[键盘快捷键] B --> D[控制台界面] C --> E[Ctrl+Z 撤销] C --> F[Ctrl+Y 重做] D --> G[命令栈控制台] G --> H[撤销按钮] G --> I[重做按钮] G --> J[清空按钮] style A fill:#e1f5fe style C fill:#f3e5f5 style D fill:#f3e5f5 style E fill:#e8f5e8 style F fill:#e8f5e8 style G fill:#fff3e0

分享到社交平台

将本文分享给你的朋友们

2025-11-23-Undo/Redo机制具体实现
https://firefly.cuteleaf.cn/posts/2025-11-23-undo-redo-机制具体实现/
作者
Zhongye
发布于
2025-11-23
版权声明
CC BY-NC-SA 4.0

评论

Profile Image of the Author
Zhongye
南漂中
公告
新的博客站!旧站点传送门 zhongye1.github.io/Arknight-notes
音乐
专辑封面

音乐

暂无播放

0:00 0:00
暂无歌词
分类
标签
站点统计
文章数
142
分类数
14
标签数
214
总字数
339,690
运行天数
0
最后更新
0 天前

目录