前端编辑器里,如何实现像 Ctrl + Z 一样的撤销与重做?
做前端编辑器、低代码平台、可视化搭建工具时,撤销和重做几乎是必做能力。
用户并不会关心你底层用了什么模式,他只在乎两件事:
- 刚刚的操作能不能撤回
- 撤回之后能不能再恢复
这类功能看起来简单,但真正自己实现一次就会发现,它本质上不是“监听一下 Ctrl + Z”这么简单,而是一个历史记录管理问题。你的原文里,主线其实已经很明确了:把用户操作抽象成命令,再用两个栈管理这些命令的执行、撤销和重做。 这也是前端里最常见、最灵活的一种实现方式。
这篇文章不再泛泛讲原理,直接结合代码,一步步看这套撤销/重做系统是怎么搭起来的。
一、先别急着写撤销,先统一“操作”
撤销重做的第一步,不是写 undo(),而是先定义什么叫“一次操作”。
原文里先抽象了一个统一接口:
1
2
3
4
interface Command {
execute(): void
undo(): void
}
这个接口虽然简单,但它几乎决定了整套系统的形状。
因为在编辑器里,用户操作可能有很多种:
- 输入文本
- 删除节点
- 修改样式
- 拖拽元素
- 一次动作里包含多个子操作
这些操作表面上完全不同,但只要都实现了 execute() 和 undo(),历史系统就不需要关心细节了。
它只需要知道:这是一个可执行、也可撤销的命令。
这一步特别重要。因为一旦“操作”被抽象统一,后面的撤销、重做、批量操作、快捷键触发,甚至操作日志,都会变得很好组织。原文里也明确提到,这种方式扩展性高,还适合做回放、批量执行和操作日志。
二、真正管理历史的,是两个栈
有了统一的命令接口,下一步就是管理历史。原文用的是最经典的双栈模型:
1
2
const undoStack: Command[] = []
const redoStack: Command[] = []
这两个栈就是整套系统的历史中心。
它们的职责很清楚:
undoStack:保存已经执行过的命令redoStack:保存刚刚被撤销、还可以恢复的命令
为什么这里用栈特别合适?
因为撤销和重做天然就是“后进先出”的:
- 最后执行的命令,最先被撤销
- 最后撤销的命令,最先被重做
也就是说,数据结构本身就和交互行为是贴合的。原文里把这点说得很准确:栈的 LIFO 特性,正好契合撤销重做的需求。
三、核心逻辑其实就三步
真正驱动整套系统的,不是某个按钮,而是这三个函数:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
function runCommand(cmd: Command) {
cmd.execute()
undoStack.push(cmd)
// 新操作清空 redo 栈
redoStack.length = 0
}
function undo() {
const cmd = undoStack.pop()
if (!cmd) return
cmd.undo()
redoStack.push(cmd)
}
function redo() {
const cmd = redoStack.pop()
if (!cmd) return
cmd.execute()
undoStack.push(cmd)
}
这段代码基本把撤销重做的主线全讲完了。
1)执行新操作
每次执行新命令时,会做三件事:
- 先调用
cmd.execute() - 再把这个命令放进
undoStack - 最后清空
redoStack
这里最容易被忽略的,是最后一句:
1
redoStack.length = 0
这句非常关键。
因为只要用户执行了新的操作,之前那些“被撤销但还没重做”的历史就应该失效了。原文里也专门强调了这一点:新操作改变了状态,之前的 redo 历史不能继续使用,否则状态会不一致。
2)撤销
撤销的逻辑也很直观:
- 从
undoStack里弹出最后一个命令 - 调用它的
undo() - 再把它压入
redoStack
也就是说,撤销不是“把历史删掉”,而是把它从“已执行历史”移动到“可恢复历史”。
3)重做
重做刚好相反:
- 从
redoStack里弹出一个命令 - 重新执行它的
execute() - 再放回
undoStack
所以如果只用一句话概括:
撤销重做,本质就是让命令在 undoStack 和 redoStack 之间流转。
四、第一类命令:文本追加
理解命令模式最好的方式,不是盯着概念,而是看一个最简单的命令实现。
原文先定义了一个很轻量的编辑器对象:
1
2
3
4
5
6
7
8
9
10
11
class Editor {
private text = ""
getText() {
return this.text
}
setText(val: string) {
this.text = val
}
}
这个 Editor 没有复杂逻辑,只是把文本状态包起来。
然后基于它实现了一个追加文本命令:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class AppendTextCommand implements Command {
private prevText: string
constructor(private editor: Editor, private newText: string) {
this.prevText = ""
}
execute() {
// 保存旧状态
this.prevText = this.editor.getText()
this.editor.setText(this.prevText + this.newText)
}
undo() {
// 恢复旧状态
this.editor.setText(this.prevText)
}
}
这段代码把命令模式的核心讲得非常透。
关键点只有一个:
执行前先保存旧状态。
因为如果不保存旧状态,撤销时就不知道该恢复到哪里。
这里的执行过程是:
- 调
getText()取到当前文本 - 保存到
prevText - 再把新文本追加进去
撤销时就很简单了:
1
this.editor.setText(this.prevText)
直接回滚到执行前的状态。
再看原文里的调用方式:
1
2
3
4
5
6
7
8
9
10
11
12
const editor = new Editor()
runCommand(new AppendTextCommand(editor, "Hello"))
runCommand(new AppendTextCommand(editor, ", world!"))
console.log(editor.getText()) // Hello, world!
undo()
console.log(editor.getText()) // Hello
redo()
console.log(editor.getText()) // Hello, world!
这段示例很适合放在文章前面,因为它把“命令执行、历史入栈、撤销、重做”整条链一次讲明白了。
五、第二类命令:删除 DOM 节点
如果说文本追加命令讲清楚了“保存旧值再恢复”,那删除节点命令讲清楚的就是:
有些操作不能只保存一个值,还要保存上下文。
原文里的删除节点命令是这样写的:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
class DeleteNodeCommand implements Command {
private parentNode: Node | null = null
private nextSibling: Node | null = null
private deletedNode: Node
constructor(private node: Node) {
this.deletedNode = node
}
execute() {
this.parentNode = this.node.parentNode
this.nextSibling = this.node.nextSibling
this.parentNode?.removeChild(this.node)
}
undo() {
if (this.parentNode && this.deletedNode) {
if (this.nextSibling) {
this.parentNode.insertBefore(this.deletedNode, this.nextSibling)
} else {
this.parentNode.appendChild(this.deletedNode)
}
}
}
}
这段代码很有代表性。因为它不是在恢复一个简单字段,而是在恢复一段 DOM 结构。
这里它保存了三样东西:
- 被删除的节点
deletedNode - 节点原来的父节点
parentNode - 节点原来后面的兄弟节点
nextSibling
为什么还要保存 nextSibling?
因为撤销时不仅要“把节点插回去”,还要“插回原来的位置”。如果不知道它原本在谁前面,那节点虽然能恢复,但顺序就不一定对了。
所以这段代码特别适合用来说明一个实现原则:
撤销不是把动作反着做一遍,而是恢复到原来的现场。
你保存的信息越完整,撤销就越准确。
六、第三类命令:样式修改
原文里还有一个样式修改命令:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
class ChangeStyleCommand implements Command {
private oldValue: string
constructor(
private element: HTMLElement,
private property: string,
private newValue: string
) {
this.oldValue = ""
}
execute() {
this.oldValue = this.element.style[this.property] || ""
this.element.style[this.property] = this.newValue
}
undo() {
this.element.style[this.property] = this.oldValue
}
}
它的逻辑和文本追加命令很像:执行前先把旧值记下来,撤销时恢复旧值。
但这个例子有一个很好的价值:
它说明编辑器里的很多操作,本质上都可以抽象成“某个属性从旧值变成新值”。
比如:
- 改字体颜色
- 改字号
- 改行高
- 改组件宽高
- 改定位坐标
- 改透明度
这些都可以复用这一类命令模型。
也就是说,命令模式不是只能做文本编辑,它本质上是一个通用的状态变更封装方式。
七、第四类命令:组合命令
真实项目里,一个用户动作经常不是单一操作,而是多个小动作拼起来的。
比如一次“粘贴”操作,背后可能会同时做这些事:
- 插入内容
- 调整光标位置
- 修正选区
- 更新样式或节点状态
这时候,把它拆成多个命令是合理的,但对用户来说,它仍然应该是“一次可撤销操作”。
所以原文给出了组合命令:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class CompositeCommand implements Command {
private commands: Command[] = []
addCommand(cmd: Command) {
this.commands.push(cmd)
}
execute() {
this.commands.forEach((cmd) => cmd.execute())
}
undo() {
// 反向执行撤销
for (let i = this.commands.length - 1; i >= 0; i--) {
this.commands[i].undo()
}
}
}
这段代码是整套实现里非常实用的一部分。
它最值得注意的地方不是“用数组装多个命令”,而是:
撤销时一定要逆序执行。
因为执行顺序和回滚顺序通常是相反的。
你先做 A,再做 B,那么撤销时一般要先撤销 B,再撤销 A。
原文里也提到,复杂编辑器里一个操作可能要同时更新多个属性、层级关系或连接关系,这时组合命令就很有用。
从工程角度看,组合命令解决的是:
如何把多个内部步骤,对外包装成一次完整的用户操作。
八、第五类命令:拖拽命令
拖拽是前端里很常见的一类交互,它和文本修改不太一样,因为它更像是“从一个位置移动到另一个位置”。
原文里的拖拽命令是这样实现的:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
class DragCommand implements Command {
private startX: number
private startY: number
private endX: number
private endY: number
constructor(
private element: HTMLElement,
startX: number,
startY: number,
endX: number,
endY: number
) {
this.startX = startX
this.startY = startY
this.endX = endX
this.endY = endY
}
execute() {
this.element.style.left = `${this.endX}px`
this.element.style.top = `${this.endY}px`
}
undo() {
this.element.style.left = `${this.startX}px`
this.element.style.top = `${this.startY}px`
}
}
这个例子也很典型。它说明有些命令不一定需要在 execute() 里去读取旧状态,而是可以在创建命令时,直接把“起点”和“终点”都传进去。
也就是说,命令里的状态来源通常有两种:
- 执行前临时读取旧状态
- 创建命令时直接把前后状态带进来
拖拽更适合第二种。因为你在拖拽开始时就知道起点,结束时就知道终点,直接封装成命令会更自然。
九、如果场景简单,其实也可以直接存快照
虽然这篇文章主线是命令模式,但你的原文里也保留了状态快照法,而且这一段很值得顺带提一下。
原文给出的思路是直接保存整个状态历史,并用一个 pointer 指向当前版本:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
const history: any[] = []
let pointer = -1
function saveState(state: any) {
history.splice(pointer + 1)
history.push(JSON.parse(JSON.stringify(state)))
pointer = history.length - 1
}
function undoState() {
if (pointer > 0) {
pointer--
return JSON.parse(JSON.stringify(history[pointer]))
}
return null
}
function redoState() {
if (pointer < history.length - 1) {
pointer++
return JSON.parse(JSON.stringify(history[pointer]))
}
return null
}
这套做法的优点就是简单。
如果你的状态本身不大,比如:
- 一个普通表单
- 一个小型配置面板
- 一个简单文本输入框
那快照法其实非常省事。
但它的问题也很明显:每次都要深拷贝整个状态对象,状态一大,内存和性能压力就会上来。原文里也专门提到,对于支持结构化克隆的环境,可以改用 structuredClone 来优化深拷贝。
所以这里可以把结论讲得很直白:
- 简单场景:快照法够用
- 复杂编辑器:命令模式更稳
十、真正落地时,还要补两件事
如果只做 demo,到上面其实已经够了。
但如果是给真实项目用,你原文里还有两点特别值得保留。
1)限制历史长度
原文里做了一个很实用的优化:
1
2
3
4
5
6
7
8
9
10
11
12
13
const MAX_HISTORY = 50
function runCommand(cmd: Command) {
cmd.execute()
undoStack.push(cmd)
// 限制历史长度
if (undoStack.length > MAX_HISTORY) {
undoStack.shift()
}
redoStack.length = 0
}
这段代码的价值很直接:
历史记录不能无限存,否则长时间使用后内存一定会涨。
所以撤销系统除了“能不能撤”,还要考虑“历史能存多久”。
2)合并连续输入
原文还给了一个 TextInputCommand 的示例,用防抖把连续输入合并成一次历史记录:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
class TextInputCommand implements Command {
private prevText: string
private inputBuffer: string = ""
private debounceTimer: number | null = null
constructor(private editor: Editor) {
this.prevText = ""
}
addText(text: string) {
this.inputBuffer += text
if (this.debounceTimer) {
clearTimeout(this.debounceTimer)
}
this.debounceTimer = setTimeout(() => {
this.execute()
this.inputBuffer = ""
}, 300)
}
execute() {
this.prevText = this.editor.getText()
this.editor.setText(this.prevText + this.inputBuffer)
}
undo() {
this.editor.setText(this.prevText)
}
}
这个优化很重要。因为如果用户连续输入 hello,你总不能让他按 5 次撤销才删完。原文里也明确说了,连续输入应该合并,否则既影响性能,也影响体验。
所以从交互角度讲,撤销的单位最好是“一次完整输入”,而不是“一个字符”。
十一、React 项目里,最容易落地的是 Hook 版本
如果你的场景不是自己写编辑器内核,而是在 React 页面里做普通输入编辑,那原文最后给的 useUndoRedo 就很适合直接落地。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
import { useState, useCallback } from "react";
function useUndoRedo<T>(initialState: T) {
const [history, setHistory] = useState<{
past: T[];
present: T;
future: T[];
}>({
past: [],
present: initialState,
future: [],
});
const setState = useCallback((newState: T) => {
setHistory((prev) => ({
past: [...prev.past, prev.present],
present: newState,
future: [],
}));
}, []);
const undo = useCallback(() => {
setHistory((prev) => {
if (prev.past.length === 0) return prev;
const previous = prev.past[prev.past.length - 1];
const newPast = prev.past.slice(0, prev.past.length - 1);
return {
past: newPast,
present: previous,
future: [prev.present, ...prev.future],
};
});
}, []);
const redo = useCallback(() => {
setHistory((prev) => {
if (prev.future.length === 0) return prev;
const next = prev.future[0];
const newFuture = prev.future.slice(1);
return {
past: [...prev.past, prev.present],
present: next,
future: newFuture,
};
});
}, []);
const canUndo = history.past.length > 0;
const canRedo = history.future.length > 0;
return {
state: history.present,
setState,
undo,
redo,
canUndo,
canRedo,
};
}
这套 Hook 实际上只是把前面的双栈思想换了一个 React 更习惯的表达方式。
你可以把它理解成:
past对应撤销历史present对应当前状态future对应可重做历史
本质还是同一件事,只不过不再直接操作命令栈,而是操作状态历史。
配合原文里的使用示例:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
function Editor() {
const { state, setState, undo, redo, canUndo, canRedo } = useUndoRedo("");
return (
<div>
<textarea value={state} onChange={(e) => setState(e.target.value)} />
<button onClick={undo} disabled={!canUndo}>
撤销
</button>
<button onClick={redo} disabled={!canRedo}>
重做
</button>
</div>
);
}
这已经足够支撑一个简单可用的编辑页面了。
十二、最后总结一下这套代码到底在做什么
如果把整篇文章压缩成一句话,那就是:
先把用户操作抽象成命令,再用两个栈去管理这些命令的执行、撤销和重做。
拆开看就是:
Command统一了操作模型undoStack/redoStack统一了历史管理runCommand / undo / redo统一了操作流转AppendTextCommand说明如何保存旧状态DeleteNodeCommand说明如何恢复复杂上下文ChangeStyleCommand说明如何处理属性修改CompositeCommand说明如何把多个子操作封装成一次撤销DragCommand说明如何处理位置变更useUndoRedo说明这套思想如何落到 React 项目里
所以这类功能真正难的,不是快捷键怎么监听,也不是按钮怎么禁用。
真正的关键在于:
你怎么定义“一次操作”,以及这次操作要保存哪些信息,才能准确地撤回去。
这也是为什么你的原文最后会把推荐方案落在“命令模式 + 历史栈”上。因为对复杂编辑器来说,它确实是最常见、最灵活、也最容易扩展的做法。