Skip to content

Ctrl+Z 撤销是怎么实现的

更新: 6/18/2026 字数: 0 字 时长: 0 分钟

Ctrl+Z 是一个非常基础的操作,但是它的实现却非常复杂。

Ctrl+Z 撤销的实现原理

先纠正一个常见误解:撤销不归操作系统管

Ctrl+Z 不是操作系统提供的功能,而是每个应用自己实现的

操作系统在这件事里只干一件事——把键盘事件传给当前焦点窗口。它告诉应用"用户按下了 Ctrl 和 Z",至于这俩键合起来要做什么,操作系统不管。同样是 Ctrl+Z,在 Word 里是撤销上一次编辑,在终端(Unix shell)里是把前台进程挂起(发 SIGTSTP 信号),在某些游戏里可能啥也不干。含义完全由应用自己定义。

所以严格说,没有"操作系统的撤销机制"这种东西。有的是每个应用各自维护的一套撤销逻辑。理解这点,后面的栈模型才站得住——栈是建在应用内存里的,不是系统级的。

一个例外是操作系统自带的应用,比如 Windows 资源管理器的"撤销重命名/移动文件"。但那也是资源管理器这个程序自己实现的,跟内核没关系。

核心数据结构

撤销的标准实现是两个栈:撤销栈(undo stack)和重做栈(redo stack)。

撤销与重做的双栈模型

工作流程拆开看就三条规则:

  • 做一个操作 → 把这个操作压进撤销栈,同时清空重做栈(一旦有新动作,原来"被撤销的未来"就作废了)。
  • 按 Ctrl+Z 撤销 → 从撤销栈弹出栈顶操作,执行它的"反向动作",再把它压进重做栈。
  • 按 Ctrl+Y 重做 → 从重做栈弹出栈顶,重新执行,再压回撤销栈。

栈的"后进先出"特性正好匹配撤销的语义:永远先撤销最近做的那一步。这跟你的直觉完全一致。

那个"撤销后一做新操作,就再也重做不回去了"的现象,根源就是第一条规则里的清空重做栈——很多人踩过这个坑,现在知道为什么了。

关键问题:栈里存的到底是什么

栈是骨架,真正的设计难点是每个栈元素存什么。这里有两套主流思路。

命令模式实现撤销

思路一:命令模式(Command Pattern)—— 存"操作本身"

把每个操作封装成一个命令对象,对象里带两个方法:execute() 执行,undo() 撤销。栈里压的就是这些命令对象。

javascript
// 一个"插入文字"命令
class InsertTextCommand {
  constructor(doc, pos, text) {
    this.doc = doc; this.pos = pos; this.text = text;
  }
  execute() { this.doc.insert(this.pos, this.text); }
  undo()    { this.doc.delete(this.pos, this.text.length); } // 反向操作
}

const undoStack = [];
function doCommand(cmd) {
  cmd.execute();
  undoStack.push(cmd);
  redoStack.length = 0; // 清空重做栈
}
function undo() {
  const cmd = undoStack.pop();
  if (cmd) { cmd.undo(); redoStack.push(cmd); }
}

关键在于每个命令都得知道怎么把自己反向做掉:插入的反向是删除,删除的反向是插回去(所以删除命令得把删掉的内容也存着)。

优点是省内存——只记录变化量(增量),不存整个文档。缺点是每个操作都要手写正反两套逻辑,复杂操作(比如一次替换多处)写起来容易出错。

思路二:备忘录模式(Memento)—— 存"状态快照"

不记操作,而是每做一步就把整个文档当前状态完整存一份快照,栈里压的是一个个快照。撤销就是把栈顶快照丢掉,恢复到上一个快照。

命令记录与状态快照两种策略对比

优点是实现简单,不用为每种操作写反向逻辑,恢复时直接覆盖。缺点是费内存——文档大、操作频繁时,存几十份完整副本扛不住。

两者怎么选:

维度命令模式(增量)备忘录模式(快照)
栈里存什么操作 + 反向操作完整状态副本
内存占用
实现复杂度高,每种操作要写 undo低,存取即可
适合场景文本/代码编辑器、大文档状态小的场景、画板的局部

实际产品常混用:主体用命令模式省内存,对某些难以反向的复杂操作用局部快照兜底。也有用"反向 diff"的——只存这一步改了哪几个字符、改前是什么,介于两者之间。

撤销发生在哪一层

回到最初的问题,把链路理清楚就明白边界了。

撤销发生在应用层而非操作系统

一次 Ctrl+Z 的完整路径:

  1. 硬件 / 驱动:键盘产生按键信号,驱动转成扫描码。
  2. 操作系统:把按键事件包装好,投递给当前焦点窗口。它的职责到此为止——只负责"送信"。
  3. 应用程序:收到 Ctrl+Z 事件,查自己的快捷键映射,发现这是"撤销",于是调用自己维护的 undo 逻辑,从撤销栈弹出一项执行反向操作。

每个应用各管各的栈,互不相通。这就是为什么你在 Word 里疯狂 Ctrl+Z 撤不动浏览器里刚填的表单——它们是两套完全独立的栈,活在各自的进程内存里。

几个工程上的细节

真正做一个好用的撤销系统,还有几处要处理:

  • 操作合并:连续打字如果每个字符都进栈,撤一次只退一个字太难受。编辑器通常按时间或语义把连续输入合并成一组,撤销一次退掉一整个词或一段连续输入。
  • 栈深度上限:撤销栈不能无限长,否则内存爆掉。一般设上限(比如 100 步),超了就丢最早的。
  • 不可撤销操作:有些动作没法回退(比如已经发出去的网络请求、已保存到磁盘并被别处读取的内容),这类操作要么不入栈,要么入栈时给用户提示。
  • 协同编辑的复杂化:多人同时改一份文档时,"撤销我自己刚才的操作"会跟别人的操作交织,简单的栈模型不够用,需要 OT(操作变换)或 CRDT 这类算法来处理,难度高一个量级。

一句话总结: 撤销靠栈、撤销即出栈。额外补充的两点是:(1) 这是应用层的事,操作系统只负责传按键;(2) 标准做法是双栈(撤销栈 + 重做栈),栈里存的可以是"反向操作"(命令模式,省内存)或"状态快照"(备忘录模式,好实现),实际产品按场景权衡甚至混用。