9.2 事务、合并与撤销栈
9.1 的 CommandStack 一次只处理一条命令。可编辑器里真正的操作很少是「一条」:多选十个节点后一起改材质,是一次编辑;拖拽滑条时手柄每移动一像素就产生一次赋值,语义上也是一次编辑。前者要整体撤销,后者要合并成一步——这正是本节要补的两件事:事务与合并窗口。
同时,栈不能无限长。一次会话可能产生几万条命令,历史全留会吃光内存。本节最后讨论栈的条数上限与字节预算如何与事务协作。
批量事务:把一批子命令当成一条
「整体撤销」的直觉实现是把一批命令依次 push,但那样按一次 Ctrl+Z 只退一条,用户得按十次。正确的抽象是复合命令:Transaction 本身就是一个 Command,它内部持有一串子命令,do 时正向依次执行,undo 时逆序回滚。对外它只是「一条命令」,进栈后一次撤销就带走整批。
事务真正的难点是原子性。如果 do 执行到第三个子命令时抛了错,前两个已经生效的改动必须被补偿回滚,否则文档会停在一个半新半旧的脏状态里——用户既撤销不了,也说不清发生了什么。Transaction 用「已处理列表」记录进度:正向执行时把成功的子命令记下来,一旦抛错就逆序对它们调 undo;undo 过程同理,任一子命令回滚失败时把已回滚的重新 do 回去。这套「失败即补偿」是数据库事务在编辑器里的最小翻版。
下面 src/editor/command/transaction.ts 层级 L1,完整可编译。它同时给出 MergeWindow——连续编辑的时间窗判据。
// file: src/editor/command/transaction.ts
// tier: L1
// verify: npx vitest run src/editor/command/transaction.test.ts
//
// helio 编辑器:事务(复合命令)与合并策略。一次「批量操作」——比如多选后
// 一起改属性、拖拽时一帧内移动一批节点——必须整体撤销。Transaction 把若干子命令
// 包成一条命令,并保证原子性:任一子命令抛错,已生效的部分会被回滚。
// MergeWindow 负责「连续微编辑」的时间窗合并判定。
import type { Command } from './command.ts';
/**
* 复合命令:把若干子命令当成一条来 do / undo。
* do 正向依次执行;undo 逆序回滚。任一子命令抛错时,已处理的部分会被反向补偿,
* 保证「要么全部生效,要么全部不生效」。
*/
export class Transaction implements Command {
readonly label: string;
readonly #children: Command[] = [];
constructor(label: string) {
this.label = label;
}
/** 追加一条子命令,返回 this 以便链式构造。 */
add(cmd: Command): this {
this.#children.push(cmd);
return this;
}
get size(): number { return this.#children.length; }
do(): void {
const applied: Command[] = [];
try {
for (const c of this.#children) {
c.do();
applied.push(c);
}
} catch (err) {
// 原子性:把已成功的子命令逆序撤销回去
for (let i = applied.length - 1; i >= 0; i--) applied[i]!.undo();
throw err;
}
}
undo(): void {
const undone: Command[] = [];
try {
for (let i = this.#children.length - 1; i >= 0; i--) {
const c = this.#children[i]!;
c.undo();
undone.push(c);
}
} catch (err) {
// 原子性:把已撤销的子命令正向重做回去
for (let i = undone.length - 1; i >= 0; i--) undone[i]!.do();
throw err;
}
}
byteSize(): number {
let n = 0;
for (const c of this.#children) n += c.byteSize ? c.byteSize() : 0;
return n;
}
}
/**
* 连续编辑的合并窗口。判据是「同一条 label + 距上次编辑不超过 windowMs」。
* 用于属性面板的拖拽滑条:一次拖动会刷出几十条 SetProperty,但它们语义上是一次编辑。
*/
export class MergeWindow {
readonly #windowMs: number;
#lastLabel: string | null = null;
#lastTime = -Infinity;
constructor(windowMs = 400) {
this.#windowMs = windowMs;
}
/**
* 登记一次编辑,返回这次是否应并入上一条。
* 无论是否合并,都会把「上次编辑时刻」推进到 nowMs,使窗口随连续输入延展。
*/
accept(label: string, nowMs: number): boolean {
const merge = label === this.#lastLabel && nowMs - this.#lastTime <= this.#windowMs;
if (!merge) this.#lastLabel = label;
this.#lastTime = nowMs;
return merge;
}
/** 主动断开窗口:例如鼠标松开、切换选中目标时调用。 */
reset(): void {
this.#lastLabel = null;
this.#lastTime = -Infinity;
}
}
注意 undo 是逆序执行的。子命令之间常有隐式依赖:先创建节点、再给它设属性,撤销时必须先撤属性、再删节点,顺序反了就会「往一个已经不存在的节点上写值」。逆序回滚保证依赖关系天然成立,这也是所有 undo 系统的默认约定。
连续编辑的合并:时间窗判定
9.1 里 mergeWith 的判据是「同一条命令、同一目标」。这还不够:用户上午把 x 从 1 改成 2,下午又把它从 2 改成 3,两次是完全独立的编辑,不该合并。所以真正的合并判据要再加一个时间维度。
MergeWindow 把这件事抽成一个纯时间逻辑:登记 (label, nowMs),若与上次同 label 且间隔不超过 windowMs(默认 400 ms),就返回「应合并」;否则开启新的一组。它和 CommandStack.push 里的 mergeWith 是两个层次的判据——MergeWindow 决定「这一次编辑该不该并进上一次」,mergeWith 决定「两条命令合并后长什么样」。前者管时机,后者管数据。reset() 用于主动断窗:鼠标松开、切换选中目标、切换工具时调用,下一次编辑必然另起一组。
撤销栈的上限与内存
历史不能无限长。9.1 的 CommandStack 已经内建了 limit(条数上限,默认 256)与 byteBudget(字节预算,默认 8 MiB),每次 push 后从最旧的命令开始裁剪。这里有三点取舍:
- 裁最旧,不裁最新。被丢弃的一定是历史深处那条,用户最不可能再去撤销它。
- 字节预算靠
byteSize()自报。Transaction的byteSize是子命令之和,所以一个「批量移动 200 个节点」的事务会一次性吃掉可观的预算,触发更激进的裁剪——这正是我们想要的:大操作本来就该压缩历史长度。 - 至少保留一条。字节预算的循环带
length > 1条件,避免预算设得过小时把栈清空,让用户连一步都撤不了。
条数上限与字节预算是或的关系:谁先触发就按谁裁。limit 防「条数爆炸」,byteBudget 防「单条超肥」。两者都不设时,栈会随会话无限增长。
可运行产物:事务原子性与合并窗口的真跑
下面 src/editor/command/transaction.test.ts 层级 L1,用一个可注入故障的 Step 命令覆盖事务的两条原子性路径、合并窗口的三种情形,以及事务进栈后的整体撤销与内存裁剪。命令 npx vitest run src/editor/command/transaction.test.ts。
// file: src/editor/command/transaction.test.ts
// tier: L1
// verify: npx vitest run src/editor/command/transaction.test.ts
import { describe, expect, it } from 'vitest';
import { CommandStack, type Command } from './command.ts';
import { MergeWindow, Transaction } from './transaction.ts';
interface Cell { v: number }
/** 可注入故障的命令:throwOnDo / throwOnUndo 为真时对应方法抛错。 */
class Step implements Command {
readonly label: string;
#cell: Cell;
#from: number;
#to: number;
#throwOnDo = false;
#throwOnUndo = false;
#size = 0;
constructor(cell: Cell, from: number, to: number, label = 'step') {
this.#cell = cell;
this.#from = from;
this.#to = to;
this.label = label;
}
failOnDo(): this { this.#throwOnDo = true; return this; }
failOnUndo(): this { this.#throwOnUndo = true; return this; }
withSize(n: number): this { this.#size = n; return this; }
byteSize(): number { return this.#size; }
do(): void {
if (this.#throwOnDo) throw new Error(`do failed: ${this.label}`);
this.#cell.v = this.#to;
}
undo(): void {
if (this.#throwOnUndo) throw new Error(`undo failed: ${this.label}`);
this.#cell.v = this.#from;
}
}
describe('事务的批量 do / undo', () => {
it('一次撤销回滚整批子命令', () => {
const cells = [0, 1, 2].map(() => ({ v: 0 }));
const tx = new Transaction('batch move')
.add(new Step(cells[0]!, 0, 10))
.add(new Step(cells[1]!, 0, 20))
.add(new Step(cells[2]!, 0, 30));
expect(tx.size).toBe(3);
tx.do();
expect(cells.map((c) => c.v)).toEqual([10, 20, 30]);
tx.undo();
expect(cells.map((c) => c.v)).toEqual([0, 0, 0]);
});
it('do 中途抛错时,已生效的子命令被回滚(原子性)', () => {
const cells = [0, 1, 2].map(() => ({ v: 0 }));
const tx = new Transaction('partial')
.add(new Step(cells[0]!, 0, 1))
.add(new Step(cells[1]!, 0, 2))
.add(new Step(cells[2]!, 0, 3).failOnDo());
expect(() => tx.do()).toThrow('do failed');
// 前两条已生效 → 必须被补偿回滚
expect(cells.map((c) => c.v)).toEqual([0, 0, 0]);
});
it('undo 中途抛错时,已撤销的子命令被重做(原子性)', () => {
const cells = [0, 1, 2].map(() => ({ v: 0 }));
const tx = new Transaction('undo-break')
.add(new Step(cells[0]!, 0, 1))
.add(new Step(cells[1]!, 0, 2).failOnUndo())
.add(new Step(cells[2]!, 0, 3));
tx.do();
expect(() => tx.undo()).toThrow('undo failed');
// 逆序 undo:cells[2] 先回滚,再轮到 cells[1] 抛错 → cells[2] 被重做回去
expect(cells[2]!.v).toBe(3);
expect(cells[1]!.v).toBe(2);
});
it('byteSize 汇总子命令占用', () => {
const tx = new Transaction('size')
.add({ label: 'a', do() {}, undo() {}, byteSize: () => 100 })
.add({ label: 'b', do() {}, undo() {}, byteSize: () => 50 });
expect(tx.byteSize()).toBe(150);
});
});
describe('合并窗口', () => {
it('同 label 且在窗口内 → 合并', () => {
const w = new MergeWindow(400);
expect(w.accept('drag', 1000)).toBe(false); // 第一条,无上一条可并
expect(w.accept('drag', 1200)).toBe(true); // 同 label,距上次 200ms
expect(w.accept('drag', 1500)).toBe(true); // 窗口随输入延展
});
it('超出窗口或换 label → 不合并', () => {
const w = new MergeWindow(400);
w.accept('drag', 1000);
expect(w.accept('drag', 2000)).toBe(false); // 距上次 1000ms > 400
expect(w.accept('resize', 2100)).toBe(false); // 换 label
expect(w.accept('resize', 2200)).toBe(true);
});
it('reset 断开窗口', () => {
const w = new MergeWindow(400);
w.accept('drag', 1000);
w.reset();
expect(w.accept('drag', 1100)).toBe(false);
});
});
describe('事务进栈', () => {
it('事务作为一条命令进栈,undo 一次撤销整批', () => {
const cells = [0, 1].map(() => ({ v: 0 }));
const stack = new CommandStack();
const tx = new Transaction('multi')
.add(new Step(cells[0]!, 0, 7))
.add(new Step(cells[1]!, 0, 8));
stack.push(tx);
expect(stack.depth).toBe(1);
expect(cells.map((c) => c.v)).toEqual([7, 8]);
stack.undo();
expect(cells.map((c) => c.v)).toEqual([0, 0]);
});
});
describe('撤销栈的上限与内存', () => {
it('事务的 byteSize 汇总后计入栈预算,按字节裁剪最旧记录', () => {
const stack = new CommandStack();
stack.byteBudget = 250; // 每个事务 100 字节 → 预算内最多 2 条
const cells = [0, 1, 2].map(() => ({ v: 0 }));
for (const c of cells) {
stack.push(new Transaction('t').add(new Step(c, 0, 1).withSize(100)));
}
expect(stack.depth).toBe(2);
expect(stack.bytes).toBeLessThanOrEqual(250);
// 被裁掉的是最旧的一条:撤销两次只能回滚后两条
stack.undo();
stack.undo();
expect(stack.canUndo).toBe(false);
expect(cells[0]!.v).toBe(1); // 第 0 条已不可撤销
});
});
本机实跑:Test Files 1 passed、Tests 9 passed;tsc --noEmit 零错误。两条原子性用例是最有价值的:do 中途抛错后三个 cell 全回到 0(证明前两条被补偿回滚),undo 中途抛错后 cells[2] 仍是 3(证明它被重做回去,而不是留在半撤销态)。
小结
Transaction把一批子命令包成一条命令,对外一次撤销即带走整批。- 事务的原子性靠「已处理列表 + 失败补偿」实现:
do抛错就逆序undo已成功者,undo抛错就逆序do已回滚者。 undo必须逆序执行,以尊重子命令之间的依赖(先建的节点后删)。MergeWindow在 9.1 的mergeWith之上补了时间维度:同 label 且间隔在窗口内才合并,reset()主动断窗。- 撤销栈的
limit与byteBudget是或关系,从最旧端裁剪且至少保留一条;事务的byteSize汇总子命令,使大操作更早触发裁剪。
下一节把命令栈真正接到第 8 章的文档模型上:命令怎么落到节点与属性上,又怎么触发脏标记与重绘。
阅读导航:上一节:9.1 命令模式与可撤销操作 · 下一节:9.3 与数据模型的接缝 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。