9.1 命令模式与可撤销操作
第 8 章把编辑器的数据模型立了起来:一份场景文档、一套脏标记、一条通知通路。可文档本身不会「记住」你做过什么——它只有当前状态。用户真正依赖编辑器的地方,恰恰是能反悔:改错了按 Ctrl+Z,改多了按 Ctrl+Y。这一章就来补上这份「记忆」,而它的载体是命令栈。
命令栈不是把每一步操作都存成快照,而是把「一次编辑」封成一个可逆的对象。这一节先定死这个对象的契约(Command),再实现承载它的栈(CommandStack),最后用单测把最容易写错的语义钉死。9.2 在这层之上加批量事务、连续编辑合并与内存上限;9.3 把命令真正接到第 8 章的文档模型上。
本章与站内既有内容的分工
站内已有内容从引擎子系统与数据组织的角度讲过编辑器与存档,本卷只做「从零实现 + 每节真跑」:
| 主题 | 站内既有内容 | 本章这一节做什么 |
|---|---|---|
| 编辑器与扩展 | Defold 子系统专题 (level-editor / editor-extensions 等) | 不重推引擎用法,从零实现命令栈与撤销重做 |
| 存档与序列化 | Defold 存档与序列化 、游戏存档系统设计 | 那些讲「落盘」,本节讲「编辑历史」的撤销 / 重做 |
| ECS 与数据模型 | Defold ECS 架构 、游戏引擎架构与 ECS | 命令作用的对象是 8 章的文档模型,本节只做命令层 |
| 类型系统 | TypeScript 专题 | 用 TS 接口表达命令契约,并真跑单测 |
一句话:原理与引擎视角去站内既有文章,本节只交付一份能跑的 src/editor/command/ 代码。
把一次编辑封成对象
最朴素的做法是整份快照:每次编辑前把整个文档深拷贝一份压栈,撤销就是弹回上一份。它在小场景里能用,但一按 Ctrl+Z 就是「整棵树换掉」——内存是 O(文档大小 × 步数),而且撤销后所有对象引用都变了,选中态、缓存全得重建。编辑器要的是「只反悔那一步」。
命令模式换了个角度:不记录状态,记录意图。一次「把 Cube 的 x 从 1 改成 2」被封装成一个对象,它知道怎么正向做(do),也知道怎么反向做(undo)。只要 undo 严格是 do 的逆运算,栈就能沿着历史逐步回退。
契约里有两个容易踩的坑。第一,构造时不能有副作用:命令对象在构造阶段只应「捕获旧值」,真正的改动一律留到 do 里发生,否则压栈时会改两遍。第二,do 与 undo 必须严格互逆且幂等可重入——因为栈会反复调用它们(undo 后再 redo,就是再 do 一次)。
下面 src/editor/command/command.ts 层级 L1,完整可编译。它定义了 Command 接口与 CommandStack:栈负责 do / undo / redo,并在压入时按 mergeWith 判定是否与栈顶合并。
// file: src/editor/command/command.ts
// tier: L1
// verify: npx vitest run src/editor/command/command.test.ts
//
// helio 编辑器:命令栈的核心。命令把「一次可撤销的编辑」封成对象,
// 栈负责 do / undo / redo,并在压入时用 mergeWith 判定是否与栈顶合并。
// 编辑器 UI(10 / 11 章)只调 push / undo / redo,不直接碰文档。
/** 一条可撤销的编辑。do 与 undo 必须严格互逆。 */
export interface Command {
/** 显示在「撤销 / 重做」菜单上的名字。 */
readonly label: string;
/** 正向应用。构造时不应有副作用,副作用只在 do 里发生。 */
do(): void;
/** 逆向回滚到 do 之前的状态。 */
undo(): void;
/** 可选:能否与「刚压入的上一条」合并;返回合并后的命令,或 null 表示不合并。 */
mergeWith?(prev: Command): Command | null;
/** 可选:估算本条命令占用的字节数,供栈的内存预算使用。 */
byteSize?(): number;
}
function sizeOf(cmd: Command): number {
return cmd.byteSize ? cmd.byteSize() : 0;
}
/**
* 撤销栈。push 时先 do,再尝试与栈顶合并,最后按条数上限与字节预算裁剪最旧的命令。
* 任何新编辑都会清空 redo 栈——这是「撤销后又编辑,重做链断裂」的标准语义。
*/
export class CommandStack {
#undo: Command[] = [];
#redo: Command[] = [];
#limit = 256;
#byteBudget = 8 * 1024 * 1024;
#bytes = 0;
get canUndo(): boolean { return this.#undo.length > 0; }
get canRedo(): boolean { return this.#redo.length > 0; }
get depth(): number { return this.#undo.length; }
get bytes(): number { return this.#bytes; }
get limit(): number { return this.#limit; }
set limit(n: number) {
this.#limit = Math.max(1, Math.floor(n));
this.#trim();
}
set byteBudget(n: number) {
this.#byteBudget = Math.max(0, n);
this.#trim();
}
/** 下一次 undo 会撤销哪条命令。 */
undoLabel(): string | null { return this.#undo.at(-1)?.label ?? null; }
/** 下一次 redo 会重做哪条命令。 */
redoLabel(): string | null { return this.#redo.at(-1)?.label ?? null; }
push(cmd: Command): void {
cmd.do();
this.#redo.length = 0;
const top = this.#undo.at(-1);
const merged = top && cmd.mergeWith ? cmd.mergeWith(top) : null;
if (merged) {
this.#bytes -= sizeOf(top!);
this.#undo[this.#undo.length - 1] = merged;
this.#bytes += sizeOf(merged);
} else {
this.#undo.push(cmd);
this.#bytes += sizeOf(cmd);
}
this.#trim();
}
undo(): boolean {
const cmd = this.#undo.pop();
if (!cmd) return false;
cmd.undo();
this.#redo.push(cmd);
return true;
}
redo(): boolean {
const cmd = this.#redo.pop();
if (!cmd) return false;
cmd.do();
this.#undo.push(cmd);
return true;
}
clear(): void {
this.#undo.length = 0;
this.#redo.length = 0;
this.#bytes = 0;
}
/** 从最旧的(数组头部)开始丢弃,先满足条数上限,再满足字节预算。 */
#trim(): void {
while (this.#undo.length > this.#limit) {
this.#bytes -= sizeOf(this.#undo.shift()!);
}
while (this.#bytes > this.#byteBudget && this.#undo.length > 1) {
this.#bytes -= sizeOf(this.#undo.shift()!);
}
}
}
栈用两个数组分别当 undo 与 redo 链:push 后命令进 #undo;undo 时从 #undo 弹出、undo() 后推进 #redo;redo 反过来。这样「撤销 / 重做」就是两个栈顶之间的搬运,逻辑一目了然。
撤销栈:do / undo / redo 与合并判定
三处语义值得单独点出。
redo 链的断裂。push 的第一件事是 this.#redo.length = 0——只要产生了一条新编辑,之前被撤销的操作就再也回不去了。这是所有编辑器的共识:历史是一条线,不是一棵树(若要做成树状历史,那是另一套设计,本节不做)。
合并判定。属性面板里拖一次滑条会刷出几十条「把 x 从 3.01 改成 3.02」的命令。若每条都进栈,用户按一次 Ctrl+Z 只退 0.01,体验很差。于是 Command 提供可选的 mergeWith(prev):栈在 push 时若发现新命令能与栈顶合并,就用合并结果替换栈顶,而不是新增一条。合并的语义由命令自己定义——通常是「同一条命令、同一目标」,旧值取前一条的、新值取本条的。9.2 会在此基础上加「时间窗」判据,避免两次相隔很久的同名编辑被错误合并。
上限与内存。历史不能无限长。#trim 在每次压栈后从最旧的命令开始丢弃,先满足条数上限(#limit,默认 256),再满足字节预算(#byteBudget,默认 8 MiB,由命令的 byteSize() 累加)。这里有个刻意的选择:字节预算的循环带 this.#undo.length > 1 条件,永远至少保留一条——否则预算设得过小会把栈清空,用户连一步都撤不了。裁剪只动 #undo,#redo 不受影响。
可运行产物:do / undo / redo 与合并的真跑
上面全是纯逻辑,可以在 Node 里真跑。下面 src/editor/command/command.test.ts 层级 L1,用一个最小命令 SetValue(把 Box.v 从 from 设为 to,可合并同一目标上的连续赋值)覆盖三条主线。命令 npx vitest run src/editor/command/command.test.ts。
// file: src/editor/command/command.test.ts
// tier: L1
// verify: npx vitest run src/editor/command/command.test.ts
import { describe, expect, it } from 'vitest';
import { CommandStack, type Command } from './command.ts';
interface Box { v: number }
/** 最小命令:把 Box.v 从 from 设为 to,可合并同一目标上的连续赋值。 */
class SetValue implements Command {
readonly label: string;
#target: Box;
#from: number;
#to: number;
constructor(target: Box, from: number, to: number, label = 'set value') {
this.#target = target;
this.#from = from;
this.#to = to;
this.label = label;
}
do(): void { this.#target.v = this.#to; }
undo(): void { this.#target.v = this.#from; }
byteSize(): number { return 32; }
mergeWith(prev: Command): Command | null {
if (!(prev instanceof SetValue) || prev.#target !== this.#target) return null;
return new SetValue(this.#target, prev.#from, this.#to, prev.label);
}
}
describe('命令栈的 do / undo / redo', () => {
it('push 立即生效,undo 回滚,redo 重放', () => {
const box: Box = { v: 0 };
const stack = new CommandStack();
stack.push(new SetValue(box, box.v, 5));
expect(box.v).toBe(5);
expect(stack.canUndo).toBe(true);
stack.undo();
expect(box.v).toBe(0);
expect(stack.canRedo).toBe(true);
stack.redo();
expect(box.v).toBe(5);
});
it('空栈上 undo / redo 返回 false 且不抛错', () => {
const stack = new CommandStack();
expect(stack.undo()).toBe(false);
expect(stack.redo()).toBe(false);
});
it('新编辑清空 redo 栈', () => {
const box: Box = { v: 0 };
const stack = new CommandStack();
stack.push(new SetValue(box, 0, 1));
stack.push(new SetValue(box, 1, 2, 'second'));
stack.undo();
expect(stack.canRedo).toBe(true);
stack.push(new SetValue(box, box.v, 9, 'fresh'));
expect(stack.canRedo).toBe(false);
expect(stack.redoLabel()).toBeNull();
});
});
describe('合并判定', () => {
it('同一目标的连续赋值合并成一条栈记录', () => {
const box: Box = { v: 0 };
const stack = new CommandStack();
stack.push(new SetValue(box, 0, 1));
stack.push(new SetValue(box, 1, 2));
stack.push(new SetValue(box, 2, 3));
expect(stack.depth).toBe(1);
stack.undo();
expect(box.v).toBe(0); // 一步撤销回到最初
expect(stack.canUndo).toBe(false);
});
it('不同目标不合并', () => {
const a: Box = { v: 0 };
const b: Box = { v: 0 };
const stack = new CommandStack();
stack.push(new SetValue(a, 0, 1));
stack.push(new SetValue(b, 0, 1));
expect(stack.depth).toBe(2);
});
});
describe('栈上限与内存预算', () => {
it('超出条数上限时丢弃最旧的命令', () => {
const stack = new CommandStack();
stack.limit = 3;
const boxes = [0, 1, 2, 3, 4].map(() => ({ v: 0 }));
boxes.forEach((box) => stack.push(new SetValue(box, 0, 1, 'no-merge-' + boxes.indexOf(box))));
expect(stack.depth).toBe(3);
// 最旧两条被丢弃,撤销只能回滚最后三条
stack.undo(); stack.undo(); stack.undo();
expect(stack.canUndo).toBe(false);
expect(boxes[0]!.v).toBe(1); // 第 0 条已不可撤销
expect(boxes[2]!.v).toBe(0); // 第 2 条仍在栈内,已回滚
});
it('字节预算触发裁剪,且至少保留一条', () => {
const stack = new CommandStack();
stack.byteBudget = 70; // 每条 32 字节,最多放 2 条
const boxes = [0, 1, 2].map(() => ({ v: 0 }));
boxes.forEach((box) => stack.push(new SetValue(box, 0, 1, 'k' + boxes.indexOf(box))));
expect(stack.depth).toBe(2);
expect(stack.bytes).toBeLessThanOrEqual(70);
stack.byteBudget = 1; // 即使预算极小也要留一条,避免栈被清空后无法撤销
expect(stack.depth).toBe(1);
});
});
本机实跑:Test Files 1 passed、Tests 7 passed;tsc --noEmit 零错误。三条语义各由一组用例钉死:redo 链在 push 后断裂、同目标连续赋值合并成一条(一次撤销即回到最初)、超限时从最旧端丢弃且字节预算下至少留一条。
小结
- 命令模式记录意图而非状态:一次编辑封成带
do/undo的对象,避免整份快照的 O(文档 × 步数) 开销。 Command契约两条铁律:构造无副作用(旧值只在构造时捕获)、do / undo 严格互逆(栈会反复调用)。CommandStack用双数组分别当 undo / redo 链;任何新编辑都清空 redo,历史是一条线。- 可选的
mergeWith让「同目标的连续赋值」合并成一条;#trim按条数上限与字节预算从最旧端裁剪,且至少保留一条。
下一节把命令栈做厚:一次批量操作要能整体撤销(事务),连续微编辑要按时间窗合并,栈还要有明确的内存上限。
阅读导航:上一节:回到目录 · 下一节:9.2 事务、合并与撤销栈 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。