改错了能回去才叫完成:vibe 项目的撤销、重做与操作历史实战
撤销是一份操作清单,不是时光机。本指南给出按意图合并的粒度、七字段历史项、清空与截断规则、协作下的按用户分栈与十项测试清单;文中产品与人物均为虚构示例。

虚构示例说明:以下出现的产品“云雀文档”与人物“小雨”均为虚构,仅用于说明设计流程,不代表任何真实产品或用户。
几乎每个用过编辑器的人都按过 Ctrl+Z,也几乎每个人都在某个时刻发现:按下去之后,回去的不是自己想要的那一步。可能是多退了三步,把刚写好的一段也撤掉了;可能是退不回去了,因为中间有一次保存、一次刷新、一次协作同事的修改,把历史截断了;也可能是明明按了撤销,界面却毫无变化——因为撤销的那个操作,根本不在撤销栈里。撤销看起来是编辑器里最理所当然的功能,实际上是状态管理里最容易被低估的一环:它要回答的不是“能不能回去”,而是“回去的每一步,是否都和用户心里想的那一步对得上”。
这篇指南讲的就是这件事:给 vibe 项目做一套可信的撤销、重做与操作历史。范围先划清:本篇讲的是“会话内的操作历史”——用户在一次编辑会话里做的修改,如何一步步撤回、如何重做、历史如何呈现;它不讲软删除与回收站(那是文件级别的删除恢复,已有独立指南),也不讲数据库备份与灾难恢复(那是系统级别的恢复,已有独立指南),更不讲版本控制里的分支与合并。本篇的战场,是编辑器里那几十次、上百次连续操作构成的一条线。
先定心智模型:撤销栈不是“时光机”,而是“操作清单”
做撤销之前,团队要先统一心智模型。用户心里的撤销,不是“回到五分钟前的整个世界”,而是“撤掉我刚才做的那一件事”。这两者的差别,决定了撤销栈的粒度。正确的心智模型是“操作清单”:每一次用户意图完整的操作——输入一句话、删除一个段落、移动一张卡片、改一个字段——对应历史里的一项;撤销就是从清单末尾逐项撤掉,重做就是把撤掉的项逐项恢复。粒度错了,一切都错:如果把每一次按键都记成一项,用户按一次撤销只退一个字符,会觉得撤销“没用”;如果把一整次批量操作记成一项,用户想只撤其中一部分时,会发现无从下手。
粒度的黄金法则是“按意图合并,按边界切分”。连续输入要合并:用户一口气打完一句话,撤销时应该整句消失,而不是逐字消失。合并的边界是“停顿”和“操作类型切换”:用户停下来思考超过一定时间、或者从输入切换到删除、从编辑切换到移动,合并就断开,开始新的一项。批量操作要按用户意图切分:用户选中十个条目统一改分类,这是一次意图,记成一项;但如果批量操作里混入了系统自动生成的连带修改,连带修改应该单独标注,而不是悄悄并入用户操作——否则用户撤销时会发现“怎么连我没动的东西也回去了”。
以虚构的“云雀文档”为例:小雨在文档里连续输入了三句话,然后把其中一句加粗、把一段文字拖到另一节。合理的历史应该是四项:输入三句话(一项)、加粗(一项)、拖动一段(一项),再加上她之前的一次删除(一项)。如果实现把三句话拆成几十次按键,撤销体验会立刻崩坏;如果把加粗和拖动合并成一项,用户想只撤拖动时也会崩坏。粒度设计没有银弹,但有一条检验标准:让五个真实用户各做十次编辑、再各撤销十次,记录他们每次撤销后是否说“对,就是这一步”——说“对”的比例,就是粒度设计的分数。
数据模型:一条历史项,至少要记七样东西
撤销栈的数据模型,常常被 agent 写得过于简单:只记“改之前的值”和“改之后的值”。这在单字段表单里够用,在真实编辑器里远远不够。一条可信的历史项,至少要记七样东西:操作类型(输入、删除、移动、格式、属性修改);目标标识(改的是哪个对象、哪个字段);改前快照与改后快照(至少是受影响范围的快照,而不是整个文档);光标或选区位置(撤销后光标应该回到哪里);时间戳;操作来源(用户手动、agent 自动、协作同事、系统连带);以及是否可合并的标记(这项能不能和上一项合并成一项)。七样缺任何一样,都会在某个场景里露出破绽。
其中最容易被漏掉的是“光标位置”和“操作来源”。撤销后光标乱跳,是用户感知最强的“不可信”信号:内容回去了,光标却留在原地,用户要先花几秒钟确认“现在到底在哪”,信任就掉了一截。操作来源则是协作场景的生命线:当 agent 半夜自动整理了一次格式、同事同时改了一个字段,撤销时如果分不清“这是我做的”还是“这是别人做的”,用户一按撤销,就可能把别人的工作也撤掉。历史项里必须能区分来源,并且在界面上以某种方式呈现——哪怕只是用不同颜色或一行小字标注。
快照的策略也值得单独定:全量快照简单但昂贵,增量快照省空间但实现复杂。务实的折中是“受影响范围快照”:只保存被这次操作直接改动的对象及其直接关联,而不是整个文档。文档很大时,全量快照的内存和性能代价会迅速失控;但增量快照如果写错了反向操作(undo 时如何精确还原),会比全量快照更危险。给 agent 的实现要求应该是:先用受影响范围快照跑通正确性,再谈压缩与性能优化;不要一上来就追求“最省空间的增量算法”,正确性永远排在性能前面。
边界规则:什么时候清空、什么时候截断、什么时候拒绝
撤销历史的边界规则,是上线后投诉最多的地方,必须在设计时就定死。第一条是容量上限:历史项数量或内存占用达到上限时,最旧的项要被丢弃,并且要让用户知道——可以在历史面板里标注“更早的历史已超出保留范围”,而不是静默截断后让用户按到头才发现“怎么退不回去了”。上限值要根据产品定:文本编辑器可以放宽到几百项,涉及图片、画布的编辑器要收紧,并且要按内存而非单纯按项数设限。
第二条是“新操作清空重做栈”:用户撤销了三步,然后做了一个新操作,那么被撤销的三步就不能再重做了——因为新操作建立在撤销后的状态上,旧的重做路径已经失效。这是所有编辑器的标准行为,但很多 AI 生成的实现会漏掉它,导致重做后出现“既不是旧状态、也不是新状态”的混合态。实现时要把这条写成测试用例:撤销、撤销、新操作、重做——重做按钮此时必须是禁用状态。
第三条是“不可撤销操作”的清单:发布、发送、支付、删除账号、覆盖他人已提交的内容,这类操作一旦执行就无法用撤销栈收回,必须在界面上明确区分——它们不应该出现在撤销历史里“假装可以撤”,而应该在执行前就要求二次确认。把不可撤销操作混进可撤销历史,是最危险的设计:用户会以为“大不了 Ctrl+Z”,结果发现根本回不去。第四条是会话边界:刷新页面、关闭标签页、切换设备之后,撤销历史是否保留,要明确告诉用户。最诚实的做法是:会话内历史只保存在当前会话,跨会话的恢复走版本历史或自动保存的快照,而不是假装撤销栈可以穿越刷新。
协作场景:你的撤销,不能撤掉别人的工作
单人编辑器的撤销相对简单,多人协作时,撤销会立刻复杂一个数量级。核心原则只有一条:你的撤销只能撤你自己的操作,不能撤别人的操作。如果实现是简单的全局栈——不管谁做的操作都按时间顺序压进同一个栈——那么 A 撤销时,很可能把 B 刚刚提交的修改也撤掉。这在协作产品里是不可接受的事故,不是“小瑕疵”。
可行的方案有两种。第一种是“按用户分栈”:每个用户的操作各自成栈,撤销只在自己的栈里回退;当别人的操作与你的操作修改了同一对象时,撤销要先检测冲突,冲突时拒绝撤销并提示“该内容已被他人修改,请先查看最新版本”。第二种是“操作变换”思路:把操作表示为可变换的指令,撤销时根据中间发生的操作做变换后再执行。这条路更强大,但实现复杂度极高,不适合第一版。给 vibe 项目的建议是:第一版用按用户分栈加冲突检测,先保证“不撤错”,再谈“撤得顺”;不要为了追求理论上的完美协作撤销,把第一版做成一个调不通的复杂系统。
还有一个协作细节:agent 的操作要不要进撤销栈。答案是“要进,但要标注”。agent 自动执行的格式化、重构、批量修改,如果完全不进历史,用户发现内容变了却找不到“是谁改的、怎么回去”,会非常不安;如果进了历史却不标注,用户撤销时会困惑“这一步我明明没做过”。正确做法是:agent 操作正常入栈,来源标注为 agent,并在历史面板里用可识别的样式区分。用户可以选择“只撤销我自己的操作”或“连 agent 的操作一起撤销”,把选择权交给用户,而不是替他决定。
界面:历史面板、快捷键与“这一步是什么”
撤销的界面,至少要包括三样:可靠的快捷键、可见的历史面板、可读的操作描述。快捷键沿用惯例:Ctrl/Cmd+Z 撤销,Ctrl/Cmd+Shift+Z 或 Ctrl+Y 重做(Windows 上 Ctrl+Y 是常见重做键,Mac 上以 Cmd+Shift+Z 为主,文档里要同时写清两套)。快捷键本身不是本篇的重点,键盘快捷键指南已单独成篇;这里强调的是,撤销快捷键必须在所有编辑场景里行为一致,不能在某个弹窗、某个面板里突然失效。
历史面板是建立信任的关键:它把“看不见的栈”变成“看得见的清单”。面板里每一项要有一行人类可读的描述——“输入:项目目标一段”“删除:第三章第二节”“移动:卡片从待办到进行中”——而不是“update node #4821”这种只有开发者能读的内部标识。描述的生成规则要定好:操作类型加对象标题加关键信息,标题过长要截断并保留可识别的部分。面板还要支持“跳转到某一步”:用户点历史里的任意一项,直接回到那一步的状态,而不是只能一步步按撤销。这个功能实现起来是“撤销若干次或重做若干次”的组合,但对用户来说,它是“历史”与“栈”的本质区别:栈只能从顶上拿,历史可以指哪打哪。
还有一个界面细节:撤销与重做按钮的启用状态,必须实时反映栈的状态。撤销栈空了,撤销按钮就要置灰;重做栈空了,重做按钮就要置灰。按钮状态撒谎——明明没东西可撤,按钮却还是亮的——是信任崩坏的起点。同样,执行撤销后,界面要给一个轻微的反馈:光标回到撤销前的位置、被恢复的内容短暂高亮、历史面板里的当前指针移动一格。这些反馈都很轻,但它们共同告诉用户:“你刚才那一步,确实发生了,而且发生在你想的地方。”
测试清单:上线前必须跑过的十个场景
撤销功能上线前,至少要跑过十个场景,并且每个场景都要有自动化测试或手动测试记录。第一,连续输入后一次撤销,是否整句消失。第二,输入、加粗、拖动各一次,撤销三次是否精确回到起点。第三,撤销两步后做新操作,重做按钮是否禁用。第四,删除一个对象后撤销,对象是否带着原有属性完整回来,而不是只回来一个空壳。第五,撤销后光标位置是否合理,而不是留在屏幕角落。第六,刷新页面后,撤销历史是否按既定规则处理(保留或清空),并且界面如实告知用户。
第七,多人协作下,A 撤销时是否绝不撤掉 B 的操作,冲突时是否有明确提示。第八,agent 操作入栈后,用户撤销时是否能区分来源。第九,容量上限到达时,最旧历史是否按规则丢弃,并且面板里有“更早历史已超出范围”的标注。第十,不可撤销操作(发布、发送)是否根本不进撤销栈,执行前是否有二次确认。十个场景全过,才能说撤销功能“可用”;任何一个场景含糊上线,都会在真实用户手里变成一次“我再也不敢按 Ctrl+Z”的教训。
实现路线:第一版只做这五件事,别贪多
给 vibe 项目排实现优先级时,第一版只做五件事。第一,单用户会话内的撤销与重做,粒度按意图合并,覆盖输入、删除、移动、格式四类操作。第二,撤销后光标复位与重做栈清空规则,这两条是正确性的底线,没有它们,撤销栈就是半成品。第三,历史面板与人类可读的操作描述,让用户看得见自己走过的路。第四,不可撤销操作清单与二次确认,把“不能撤”的事提前说清楚。第五,容量上限与截断提示,避免历史无限增长拖垮性能。这五件做完,撤销功能就可以上线接受真实检验;协作分栈、跳转到任意一步、跨会话恢复,都可以放在第二版——先让单人场景稳如磐石,再去攻协作场景的复杂度。
实现时还有两个容易踩的坑值得提前写进任务描述。第一个坑是“撤销与自动保存的互相干扰”:自动保存把中间状态写进后端,撤销栈却还停留在前端内存,两者对“当前状态”的理解一旦脱节,就会出现“界面显示已撤销、刷新后又变回去”的灵异现象。解决办法是:自动保存必须以撤销栈的当前状态为准,而不是以最后一次编辑为准;保存失败时,撤销栈不得擅自回滚,要明确提示用户。第二个坑是“撤销与表单校验的互相干扰”:撤销回一个曾经合法、现在因规则变化而不合法的状态时,校验应该怎么处理?答案是:撤销操作本身不受新校验规则的阻拦——用户有权回到过去的状态;但回到过去状态后,再次编辑或提交时,新规则正常生效。把“回到过去”与“提交新内容”混为一谈,是很多表单撤销 bug 的根源。
最后回到小雨的“云雀文档”:她在文档里改了一上午,按了十几次撤销,每一次都精确地回到她心里想的那一步,历史面板里每一项都写着她能读懂的话,agent 改过的那几处标得清清楚楚。她不会因此夸你的撤销系统设计得好——她只会觉得“这个编辑器很稳”。而“稳”,恰恰是撤销系统唯一的、也是最高的评价标准。撤销不是炫技的地方,它是用户敢于大胆修改的前提:只有确信改错了能回去,用户才敢往前改。把撤销做稳,就是把用户的勇气还给用户。

图注:撤销是一份操作清单而非时光机——一次用户意图对应一项历史,新操作会清空重做栈。
评论 (0)
相关文章

快捷键的失败,往往始于“先列键、后分层”。本指南按频率与风险四层分流、借用通用惯例、避让浏览器与输入法及屏幕阅读器冲突,并给出提示系统与上线清单;文中产品与人物均为虚构示例。

推送、邮件与站内通知中心是三种不同的东西,混成一套系统只会让用户全部关掉。本指南给出三级定级法、四张逻辑表、围绕“清零”设计的交互规则与三项衡量指标;文中产品与人物均为虚构示例。

删除不是一个按钮,而是一台状态机:请求、告知后果、宽限期、清除、确认、复核。本指南给出删除范围三分类、六状态流程与十项工程验收清单,并点名备份、索引、第三方与账单四处最容易漏的环节;文中产品与人物均为虚构示例。