撤销功能是怎么实现的?从文本编辑器到Photoshop的底层逻辑

拆解 Ctrl+Z:撤销功能如何在用户预期、数据结构与内存之间寻求平衡
本文基于 Computerphile 视频,深度剖析了撤销(Undo)功能的实现复杂性。文章指出,撤销并非软件天生具备,而是开发者显式编程的结果——程序必须在编辑流程中设置「检查点」才能知道回退到哪里。文本编辑器的难点在于判断撤销粒度(一个字符还是一次动作),而位图编辑器虽判断直观,却需备份原始像素、消耗大量内存。多级撤销通常通过双栈实现(撤销栈与重做栈),操作系统仅负责传递按键事件,核心逻辑依赖开发者或 AppKit/UIKit 等框架完成。对 Photoshop 等处理数 GB 未压缩图像的软件,内存管理尤为关键,需设上限并清理远期历史。文章最后提及片段表(Piece Table)可让文本撤销近乎零成本实现。
撤销(Undo)几乎存在于每一款软件中,用户每天都在按下 Ctrl+Z,却极少有人思考它背后发生了什么。Computerphile 的这期视频把这个「理所当然」的功能拆开来看,结果发现它触及了计算机科学的多个层面——从用户体验预期到数据结构选择,再到内存管理。看似简单的一个按键,实现起来远比想象中棘手。
没有撤销的年代
视频主讲人提到,他经历过软件根本没有撤销功能的时代。画面背后那台老电脑上的字处理软件,即便键盘上有物理的 Undo 键,按下去也毫无反应,菜单里也找不到任何撤销选项。这种体验的糟糕程度,今天的用户已经很难体会。
即便是 Photoshop,撤销功能也是相对后期才真正加入的。早期版本只有侧边的「历史记录(History)」面板,而没有一个叫做 Undo 的功能。换句话说,撤销并非软件与生俱来的能力,而是开发者一点点设计、实现出来的结果。
用户到底期望撤销什么
真正有意思的问题在于:当用户按下撤销时,他们究竟期望发生什么?答案并不像看起来那么直观。
以文本编辑器为例,如果你输入了「the cat sat on the mat」这样一长串文字,按下撤销——是只撤销最后一个字符吗?如果只撤销一个字符,那和退格键有什么区别,撤销就失去了意义。所以用户的真实预期往往是撤销「最后一个词」或「上一次编辑动作」。

这里的关键在于:计算机不会自己「思考」要撤销到哪里。程序员必须显式地告诉程序,在编辑过程中设置一个个「路标」或「检查点」。当用户按下撤销时,程序才知道该回退到哪个保存点。看似简单的功能,从这一刻起就需要在代码里写入专门的逻辑。
Word 的「隐藏编辑」
视频中用 Microsoft Word 做了一个有趣的对比实验。输入文字后按撤销,Word 并不是一次性清空全部内容,而是分步回退——它会先撤销拼写检查做出的自动更正,再撤销把普通撇号替换成「智能引号」的那一步。
这些都是用户没有主动操作、但软件在后台进行的「隐藏编辑」。Word 把文档中每一次「编辑性质发生变化」的点都记录了下来:从输入文字切换到删除、从手动输入切换到拼写检查介入、从输入切换到粘贴整块内容——每一个转折点都是一个可撤销的节点。
位图编辑器:好判断却难存储
换成位图绘图软件,情况反而出现了反转。视频中在 iPad 上画了一个带门窗的房子,每画完一笔笔尖离开屏幕,就构成一个完整的动作。按撤销就消掉一个窗户,按重做又加回来——从交互逻辑上看,这比文本编辑器更符合直觉。

但麻烦藏在存储环节。文本编辑器只需记录「在某个位置插入了哪些字符」,撤销时删掉这些字符即可,重做时再塞回去,甚至可以简单地记录每一次按键再回放。而位图一旦被修改,原始像素就没了——你无法「反向绘制」已经画上去的内容(除非用 XOR 这类特殊绘图模式,而且效果会很怪)。
唯一的办法是:在绘制之前先把原始图像(或被覆盖的那一小块区域)备份到另一处。这就引出了内存问题——每一次备份都要占用空间。判断「撤销什么」变容易了,但程序要做的实际工作反而更多了。
操作系统帮不上太多忙
一个常被问到的问题是:撤销到底是程序员自己实现,还是操作系统代劳?
答案是大部分靠开发者。操作系统只负责把按键事件传给程序——「A 键按下、A 键释放、B 键按下」,它根本不知道你在写的是字处理器还是绘图软件,也不知道该把哪些操作归为一组。

不过,一些框架确实提供了撤销支持。视频提到 macOS 的 AppKit、iOS 的 UIKit 都内置了多级撤销的能力,它们主要帮你做的是「分组并回放」操作。但即便如此,你仍然要告诉框架哪些操作该归为一组、如何回放;如果写的是图形编辑器,你的程序依然得自己负责把图像备份到别处。
用栈实现多级撤销
谈到多级撤销的实现,最自然的数据结构选择是什么?视频里给出的答案是栈(Stack)。
栈的「后进先出」特性恰好契合撤销需求:每做一次编辑,就把这次操作封装成一个对象压入栈中。文本编辑器里可能是「添加了 the cat,记录插入位置」,图形编辑器里则是「被覆盖区域的原始像素」。按下撤销时,从栈顶弹出最近一次操作,反向执行即可。

要支持重做,就需要两个栈:一个撤销栈、一个重做栈。撤销时把操作从撤销栈弹出、压入重做栈;重做时再反向搬回来。不过对于图像编辑器,重做不只是搬运对象——还得真正把存储的像素块替换回去。
该在哪里划分撤销节点
主讲人分享了一个实现层面的个人见解:划分撤销节点的方式决定了用户体验。Word 和演示用的文本编辑器是基于「操作性质切换」(比如从输入转为删除)来划分的。但如果由他来实现,他更倾向于用计时器——每次按键重置计时器,如果停顿超过一秒就设一个节点。因为快速连续打字时用户通常不想一次次撤销,而一旦停下来思考再输入,往往意味着一个新的逻辑段落开始了。
这说明同一个撤销功能可以有多种实现思路,每种都对应不同的用户体验,也对应不同的编程复杂度——这或许正是很多软件不愿做得太精细的原因。
栈(Stack)是计算机科学中最基础的数据结构之一,其核心特性是「后进先出」(LIFO,Last In First Out):最后压入的元素最先被弹出,就像一摞叠放的盘子,只能从顶部取用。与之相对的是队列(Queue),遵循「先进先出」原则。撤销历史天然契合栈的语义——用户最先想撤销的,恰好是最近一次操作,而非最早的那次。
在命令模式(Command Pattern)这一设计模式的框架下,每次用户操作都被封装成一个「命令对象」,包含执行(execute)和撤销(undo)两个方法。按下 Ctrl+Z 时,程序从栈顶取出命令对象并调用其 undo 方法,整个过程与具体操作类型解耦。这也是为什么框架层面(如 AppKit)可以提供通用的撤销支持——只要开发者按规范封装命令对象,框架便能统一管理栈的压入与弹出,而无需了解操作的具体含义。
内存才是真正的敌人
对位图编辑器来说,内存是绕不开的硬约束。视频特别强调,Photoshop 这类软件处理的是未压缩图像——不是几百 KB 的 JPEG,而是可能几百 MB、在出版印前场景下甚至达到数 GB 的原始位图。
每保留一层撤销历史都要吃掉对应的内存。如果直接用无限制的双栈实现,历史记录会迅速撑爆内存或硬盘。因此实际实现往往需要一个「带容量上限、且清楚自己存了多少数据」的结构——当内存吃紧时,它能把三小时前那些微不足道的操作(比如只点了一个点)清理掉,保留更可能被撤销的近期操作,而不是死守着几 GB 甚至十几 GB 永远用不到的历史。
一种优化思路是:图形操作不一定要存整块位图,可以只裁剪、存储被改动的区域,再在重做时放回原位。但无论怎么优化,这些信息都要收集成对象压入栈中,存储开销始终存在。
文本编辑器的「免费撤销」
视频最后留了个悬念:文本编辑器其实可以通过巧妙的数据结构让撤销「几乎免费」。此前 Computerphile 讲过的「间隙缓冲区(Gap Buffer)」需要额外用对象跟踪编辑,但还有一种叫做**片段表(Piece Table)**的结构——Microsoft Word、Visual Studio 都在用——能让撤销近乎零成本地实现。这部分将在后续视频中展开。
撤销功能的这趟拆解之旅说明了一个道理:越是用户习以为常、看似理所当然的功能,背后往往牵涉越多的工程权衡。从用户预期的把握,到数据结构的选型,再到内存的精打细算,一个小小的 Ctrl+Z 把计算机科学的多个层面都串了起来。
片段表(Piece Table)是一种专为文本编辑设计的数据结构,最早由 Charles Crowley 在 1998 年的论文中系统描述。其核心思想是:不直接修改原始文本缓冲区,而是维护一张「片段描述表」,每条记录指向原始缓冲区或新增缓冲区中的某个片段(起始位置 + 长度)。插入或删除文字时,只需在表中增删或拆分记录,原始数据始终保留。
这一特性使撤销几乎变成「免费」操作——每次编辑前,片段表的状态本身就是一份轻量级快照,保存的是元数据(指针和长度),而非复制完整文本。与之相比,间隙缓冲区(Gap Buffer)在每次光标移动时需要搬移大量字节。片段表的代价在于随机访问效率较低,需要遍历表项才能定位某个字符位置,但对以顺序编辑为主的文字处理场景影响有限,因此被 Word、VS Code 等主流编辑器广泛采用。
相关推荐

开源Hub新增虚拟聚合Provider:跨提供商模型切换实战
开源 Hub 新增虚拟聚合 Provider,支持指定模型跨提供商自动切换、AOTL Auto 自动调度、按临期积分排序优先消耗额度,并通过流量徽标实时显示当前使用的提供商,为多 API 用户提供统一调度方案。

Codex 子代理调试实录:主代理+Luna+DeepSeek 的分工与验收
一次 Codex 子代理调试实录:主代理负责拆任务与验收,Luna 做实现修复,DeepSeek Flash 做调查审查。详解多代理分工、调用链路验证、任务切分原则,以及 Provider 配置和通道路由踩坑经验。

Cline CLI v3.0.70 更新解析:修复关键Bug与模型目录刷新
Cline CLI v3.0.70 更新解析:修复提交信息生成400错误、MCP请求头丢失等关键Bug,重构命令结构,并刷新模型目录新增Claude Haiku 5.5作为多家服务商默认模型。