结论先行撤销栈的难点从来不是栈,而是怎么定义一次操作。 按字符存会爆炸,按事务存才可用。真正要写的代码只有两个方法:
apply 和 invert。我们要做的是行内编辑——改一句文案,回车提交。富文本库那一整套 OT / CRDT 对这个场景完全是过载。
01为什么不用现成的库
试了两个库,都卡在同一个地方:它们假设你在编辑一棵树,而我们编辑的是一段带元信息的文本。 适配层的代码比自己写还多。
02命令模式的最小形态
一个命令只需要知道自己怎么执行、怎么回滚:
1interface Command {
2 apply(ctx: Ctx): void;
3 invert(ctx: Ctx): void;
4}
5
6class ReplaceRange implements Command {
7 constructor(
8 private from: number,
9 private to: number,
10 private next: string,
11 private prev: string,
12 ) {}
13
14 apply(ctx: Ctx) { ctx.splice(this.from, this.to - this.from, this.next); }
15 invert(ctx: Ctx) { ctx.splice(this.from, this.next.length, this.prev); }
16}
栈本身只有十几行:push、pop、以及一个 redo 栈。关键是切换编辑目标时清空栈, 否则回滚会把另一个字段改掉。
03合并连续的同类操作
如果不合并,敲十个字符就要按十次 Ctrl+Z。做法是给命令一个 mergeKey, 时间间隔小于 500ms 且 key 相同的,直接改上一个命令的 next,不 push 新的。
1push(cmd: Command) {
2 const last = this.undoStack[this.undoStack.length - 1];
3 const mergeable =
4 last && last.mergeKey === cmd.mergeKey && Date.now() - last.at < 500;
5
6 if (mergeable) { last.next = cmd.next; last.at = Date.now(); return; }
7 this.undoStack.push(cmd);
8 this.redoStack.length = 0; // 新操作一律清空 redo
9}
04两个没想到的坑
- 选区要一起存。只恢复文本不恢复光标位置,用户会觉得「撤销了一半」。
- 异步提交会让栈错位。保存请求失败回滚时,必须把对应的命令也弹掉。
Note
如果哪天需求变成多人同时编辑,这套东西就得整体换掉。但那个「如果」一年多了还没发生。
下一篇记录 CSS 容器查询,同样是「以为很简单,结果踩了两次」。