编辑器的undo和redo

Posted by abining on March 7, 2026

前端编辑器里,如何实现像 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

所以如果只用一句话概括:

撤销重做,本质就是让命令在 undoStackredoStack 之间流转。


四、第一类命令:文本追加

理解命令模式最好的方式,不是盯着概念,而是看一个最简单的命令实现。

原文先定义了一个很轻量的编辑器对象:

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)
  }
}

这段代码把命令模式的核心讲得非常透。

关键点只有一个:

执行前先保存旧状态。

因为如果不保存旧状态,撤销时就不知道该恢复到哪里。

这里的执行过程是:

  1. getText() 取到当前文本
  2. 保存到 prevText
  3. 再把新文本追加进去

撤销时就很简单了:

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 项目里

所以这类功能真正难的,不是快捷键怎么监听,也不是按钮怎么禁用。
真正的关键在于:

你怎么定义“一次操作”,以及这次操作要保存哪些信息,才能准确地撤回去。

这也是为什么你的原文最后会把推荐方案落在“命令模式 + 历史栈”上。因为对复杂编辑器来说,它确实是最常见、最灵活、也最容易扩展的做法。