命令历史(Undo / Redo)
CommandHistory 是一个通用的 命令模式 实现:每个可逆操作封装成一对 redo / undo,压入历史栈后即可双向穿梭。它与 ECS 完全解耦——任何能写出「正向/反向」两个函数的操作都可以进入历史,不论是实体、相机、UI 状态还是纯业务数据。
概述
engine.history 已经是一个现成的 CommandHistory 实例,默认最大 50 条。你也可以独立 createCommandHistory 出一个自己管理的实例(例如工具面板内部的撤销栈)。
execute(cmd)先跑 cmd.redo(),再压入 undo 栈,清空 redo 栈undo()undo 栈弹出 → 跑 cmd.undo() → 压入 redo 栈redo()redo 栈弹出 → 跑 cmd.redo() → 压回 undo 栈transaction(label, exec)收集多条命令 → 合并成一条历史记录
createCommandHistory
ts
import { createCommandHistory } from '@tripo3d/engine';
// 默认保留 50 条
const history = createCommandHistory();
// 或自定义上限
const bigHistory = createCommandHistory(200);超出上限时,最旧的记录会被丢弃(undoStack.shift())——并不会触发命令的 dispose 之类的回调,命令自身需要保证被丢弃后没有副作用。
HistoryCommand
| 名称 | 类型 | 默认值 | 说明 |
|---|---|---|---|
redo必填 | () => void | Promise<void> | — | 正向执行;首次 execute 时会自动调用一次 |
undo必填 | () => void | Promise<void> | — | 逆向回滚,需要完全恢复到 redo 之前的状态 |
label | string | — | 可选的可读标签,便于 UI 显示或调试 |
redo 和 undo 都可以是 async——CommandHistory 的方法全都返回 Promise<void>,调用方应当 await,避免并发触发两次 undo。
CommandHistory
| 字段 / 方法 | 签名 | 说明 |
|---|---|---|
canUndo | readonly boolean | undo 栈是否非空 |
canRedo | readonly boolean | redo 栈是否非空 |
execute | (cmd: HistoryCommand) => Promise<void> | 跑一次 cmd.redo() 并入栈;清空 redo 栈 |
undo | () => Promise<void> | 弹出最近一条命令并执行 undo |
redo | () => Promise<void> | 弹出最近一条被撤销的命令并执行 redo |
transaction | (label: string, exec: (tx: HistoryTransaction) => void | Promise<void>) => Promise<void> | 把多个命令折叠成一条历史 |
clear | () => void | 清空 undo 与 redo 栈 |
事务(transaction)
当一个「语义上的单步」由多个小操作组成(例如同时修改位置、旋转、缩放),希望用户 Ctrl+Z 一次就全部回滚,这时用 transaction:
ts
await engine.history.transaction('align-to-ground', async (tx) => {
await tx.execute(moveCommand);
await tx.execute(rotateCommand);
await tx.execute(snapCommand);
});在 exec 回调里,tx.execute(cmd) 会立即跑 cmd.redo() 并收集命令;回调结束后,transaction 按以下规则折叠:
- 0 条命令:直接丢弃,不入栈;
- 1 条命令:单独入栈(相当于
execute); - 2 条及以上:合成一条
HistoryCommand,redo依序执行,undo逆序回滚,外层只看到一条记录。
TIP
tx.commands 是一个只读快照,可以在回调里动态判断是否已经收集到某些前置命令后决定是否执行下一个。
完整示例:移动实体
假设要把一个实体的 Transform 位置从 A 移到 B,并允许撤销:
ts
import type { HistoryCommand } from '@tripo3d/engine';
function moveEntityCommand(entity: Entity, from: [number, number, number], to: [number, number, number]): HistoryCommand {
return {
label: `Move ${entity.name}`,
redo() {
entity.object.position.set(to[0], to[1], to[2]);
},
undo() {
entity.object.position.set(from[0], from[1], from[2]);
},
};
}
// 触发点:拖拽结束时
const from: [number, number, number] = [prev.x, prev.y, prev.z];
const to: [number, number, number] = [next.x, next.y, next.z];
await engine.history.execute(moveEntityCommand(entity, from, to));
// 之后可以在工具栏上
if (engine.history.canUndo) await engine.history.undo();
if (engine.history.canRedo) await engine.history.redo();TIP
如果 redo 可能抛错(例如资源已被删除),应在命令内部捕获并决定是否回滚 —— 目前实现不会自动回滚 execute 过程中的异常。下一节会详细讨论。
最佳实践
- 保持幂等。
redo和undo应当只依赖参数和当前状态的差值,多次undo → redo结果必须稳定一致。 - 错误边界。
execute里的redo抛错会让命令不入栈,但副作用(如已经修改的那部分状态)不会被自动回滚——请在redo内部包一层 try/catch,失败时主动恢复。 - 避免大对象闭包。命令常年留在 undo 栈,如果
redo/undo闭包捕获了整张场景、图片、网格数据,内存泄漏会在长时间会话里显现。只捕获差值(前/后的数值、id、JSON 化后的小对象),真正用到时再从世界里按 id 取。 - 命令粒度。每帧都变化的值(鼠标拖拽中每个 pointermove)不要逐帧入栈——应在交互结束时用
execute提交一条 A→B 的命令;或用transaction折叠。 - 与 ECS 配合。ECS 的 Component 本质上是状态容器,命令可以只持有「要改哪个实体的哪个组件的哪些字段」,从而天然保持小闭包。
- 清理时机。重置场景(新建项目、切换文件)时显式
engine.history.clear(),防止旧命令引用被 GC 不掉的对象。