2025-11-23-简谈前端编辑器Undo/Redo机制的一些实现
概述
撤销和重做几乎是所有编辑类产品的基础能力,但它恰恰是那种”做一个能用的版本只要半天,做一个真正经得起折腾的版本要反复打磨好几个迭代”的功能。无论你做的是图形画布(类似 Figma、白板、流程图)还是富文本编辑器(类似 Notion、飞书文档、各种 CMS 后台),背后的核心问题是一样的:如何精确地、可逆地描述用户对文档做的每一次改动,并且让这种回退在用户感知上是”自然”的。
它的本质是对”状态变化”建模——你要么记录状态本身,要么记录引起状态变化的操作,要么记录两者之间的差异。本文主要就这些展开阐述
一、“撤销”到底在撤销什么
动手之前先注意一个问题:编辑器把”文档状态”建模成了什么?撤销的本质就是在状态空间里来回移动,所以状态的建模方式直接决定了 undo/redo 的实现难度。
比如说文档是一棵结构化的数据树(比如画布里的图元数组、富文本里的文档节点树),那么撤销就是让这棵树回到上一个版本。如果你的文档状态散落在各处——一部分在 React state 里,一部分在 DOM 里,一部分在某个全局单例里——那么撤销就会变成一场灾难,因为你永远没法保证回退是完整的。于是做撤销写撤销逻辑之前,需要先把文档状态收敛成一个清晰、可序列化、单一来源的数据结构。
二、几种主流思路
撤销机制大体上分多个流派
| 方案 | 描述 | 适用场景 |
|---|---|---|
| 全量快照(Memento) | 每步存一份完整 state | 状态小、操作少的工具 |
| 命令模式(Command) | 存”操作”本身,靠 do/undo 反演 | 画布、编辑器主流方案 |
| CRDT / OT 历史模型 | 操作可交换 / 可变基 | 多人实时协作 |
快照式(Memento Pattern)
每次改动后,把整个文档状态(或它的不可变副本)压进一个栈里。撤销就是弹出当前状态、恢复上一个状态;重做则反过来。它的好处是实现极其简单,逻辑几乎不会出错,因为你存的就是”完整的真相”,恢复时不需要任何推理。缺点也很直接:如果文档很大,每存一份快照都很占内存。不过在配合不可变数据结构(Immer、Immutable.js 这类结构共享方案)之后,这个缺点被大幅缓解了——两个相邻快照之间没变的部分会共享同一份内存,只有改动的路径会产生新对象,所以一份”快照”实际占用的增量内存往往很小。对绝大多数中小型编辑器,我现在更倾向于推荐快照式作为起点,因为它把出错的可能性降到了最低。
class SnapshotHistory<T> { private undo: T[] = []; private redo: T[] = []; constructor( private getState: () => T, private setState: (s: T) => void, ) {}
commit() { this.undo.push(structuredClone(this.getState())); this.redo = []; } undoOnce() { if (!this.undo.length) return; this.redo.push(structuredClone(this.getState())); this.setState(this.undo.pop()!); } redoOnce() { if (!this.redo.length) return; this.undo.push(structuredClone(this.getState())); this.setState(this.redo.pop()!); }}| 优点 | 缺点 |
|---|---|
| 实现极简,几十行搞定 | 内存随历史线性增长 |
| 任意状态都能恢复,无需逐操作设计 | 大对象深拷贝有性能压力 |
| 不依赖业务语义 | 不利于协同(快照难合并) |
这个方案就比较适合状态体积小(表单、配置项、简单工具栏)、历史条数有上限(如 ≤ 50)、且不需要”局部撤销”或多人协作的场景。
2.2 命令式(Command Pattern)
不存状态的话,而是把每一次改动描述成一个”操作对象”,这个对象知道怎么把自己应用上去(apply / do),也知道怎么把自己撤销掉(invert / undo)。比如”把图元 A 从坐标 (10,10) 移动到 (50,50)“这个操作,它的撤销就是”把 A 移回 (10,10)“。撤销栈里存的是这些操作而不是状态。它的优势是内存占用极小,每个操作通常只是几个字段;而且它天然适合协同编辑,因为操作本身就是可以被序列化、传输、变换的最小单元。于是实现复杂度就高得多,必须为每一种编辑动作都写出正确的逆操作,一旦某个逆操作写错或写漏,撤销就会让文档进入一个不一致的脏状态,而这类 bug 往往很隐蔽、很难复现。
interface Command { execute(): void; undo(): void; redo(): void;}
class AddElementCommand implements Command { constructor( private store: Store, private el: CanvasElement, ) {} execute() { this.store.add(this.el); } undo() { this.store.remove(this.el.id); } redo() { this.execute(); }}
class UpdateElementCommand implements Command { constructor( private store: Store, private id: string, private before: Partial<El>, private after: Partial<El>, ) {} execute() { this.store.patch(this.id, this.after); } undo() { this.store.patch(this.id, this.before); } redo() { this.execute(); }}| 优点 | 缺点 |
|---|---|
| 内存小,只存”差量参数” | 每种操作要单独写命令类 |
| 语义清晰,便于打日志、回放、宏录制 | 容易漏写 undo/redo 导致状态漂移 |
| 易于合并(如连续拖拽合并为一条) | 需要严格的”前/后状态”约束 |
落地命令模式时要关注几个细节
执行时机一致性:execute 与 redo 必须等价,否则首次执行与重做行为会出现差异。
锁机制:执行 undo/redo 期间,所有 executeCommand 调用必须被忽略,避免历史污染。
命令合并(coalescing):拖拽过程中可能产生上百条 UpdateElementCommand,应在 mouseup 时合并为一条。
复合命令(composite):批量操作(多选删除)应聚合为一个 CompositeCommand,统一 do/undo。
class CompositeCommand implements Command { constructor(private children: Command[]) {} execute() { this.children.forEach((c) => c.execute()); } undo() { [...this.children].reverse().forEach((c) => c.undo()); } redo() { this.children.forEach((c) => c.redo()); }}2.3 选型
实践中并不是非此即彼。一个常见的折中是命令式为主、关键节点穿插快照,这样既享受了操作式的低内存,又能在出问题时用快照”兜底校正”。但如果你的团队没有特别强的理由(比如必须做实时协同),我建议先用结构共享的快照式把产品跑起来,等真的遇到内存或协同瓶颈时再演进到命令式,而不是一上来就背上命令式的复杂度。至于第三类 CRDT / OT 历史模型,它是为多人协作准备的,留到后面专门讲。
三、撤销的颗粒度:一次撤销应该回退多少
这是最影响”自然感”的一点,也是最容易被忽略的一点。技术上每一次状态变更都可以是一个撤销单元,但用户心智里的”一步”和程序里的”一次变更”几乎从来不是一一对应的。
举几个典型的例子。用户在文本里连续敲了一句话,他按下 Ctrl+Z 时期望的多半是删掉刚打的这一整个词或这一句,而不是一个字符一个字符地往回退;如果每个字符都是独立的撤销点,用户会按到崩溃。反过来,用户拖动一个图元时,鼠标移动过程中坐标可能变化了上百次,但这显然应该合并成”移动了一次”这一个撤销单元,而不是一百个。再比如,某些复合操作——“删除一个段落”在内部可能触发了”移除节点 + 调整后续节点序号 + 更新目录”等好几个子动作——对用户而言它就是一步,撤销时必须整体回退。
解决这个问题的手段主要有三类。
一是事务(transaction):用一个显式的边界把若干底层变更包成一个原子的撤销单元,比如 history.batch(() => { ...一堆改动... }),里面发生多少次变更都只产生一个撤销点,复合操作一律用事务包起来。
二是合并(coalescing / merging):对连续发生的同类操作做时间上的归并,典型策略是给操作打时间戳,如果新操作和栈顶操作类型相同、目标相同、且间隔小于某个阈值(比如几百毫秒到一秒),就把它们合并成一个,连续打字、连续拖动、连续调整滑块都适合这种处理。三是显式断点:在某些语义边界主动切断合并,比如用户敲了空格、回车、或者把光标移到了别处,就强制结束当前的合并窗口,让下一次输入成为新的撤销点——这正是大多数文本编辑器”按词撤销”的实现方式。
颗粒度需要拿真实使用场景去调,撤销单元应该对齐用户的意图,而不是对齐你的数据结构。
四、选区和视图状态
撤销有时候回退的不只是文档内容,还有用户当时的”上下文”。设想用户删掉了一段文字然后撤销,如果光标没有回到那段文字所在的位置、那段文字也没有重新被选中,用户就得自己去找”我刚才在哪”,这种体验是割裂的。在画布里也一样,撤销一次删除操作后,被恢复的图元最好重新处于选中态,并且视口最好能滚动到它可见的位置。
所以撤销单元里除了文档变更,通常还应该一并记录操作前后的选区 / 光标状态。撤销时恢复”操作前”的选区,重做时恢复”操作后”的选区。这部分状态不属于文档本身,但它对自然感的贡献非常大,很多团队是在用户反馈”撤销之后找不着北”之后才补上的,不如一开始就考虑进去。需要注意的是选区状态有时会因为内容变化而失效(比如撤销恢复的节点 id 变了),所以恢复选区时要做好兜底,定位不到就退化到一个合理的默认位置,而不是抛错。
五、以画布编辑器为例子的具体实践
图形画布的好处是它的文档模型通常比富文本干净——大多就是一个图元列表或一棵场景树,每个图元有自己的 id、坐标、尺寸、样式等属性。在这种结构上做撤销相对舒服。
如果走快照式,配合不可变更新会很顺:每次操作产生一份新的场景树,结构共享让没动的图元复用内存,撤销栈里存的就是这些树的引用。如果走命令式,你需要为每类编辑定义操作和逆操作——新增图元的逆是删除,删除的逆是带着完整属性重新插入(所以删除操作里必须把被删图元的全部数据存下来,否则没法还原),移动的逆是移回原坐标,改样式的逆是改回旧值。这里有个容易踩的坑:逆操作所需的信息必须在操作发生时就捕获好。比如删除一个图元,你不能等撤销的时候才去问”它原来长什么样”,那时它已经没了,所以要在执行删除的那一刻把它的快照塞进操作对象。
画布场景里特别需要处理好拖拽和连续变换的合并。用户拖动、缩放、旋转时会高频产生中间状态,正确做法是在交互开始时(mousedown)记下初始状态,交互过程中只更新视图不入栈,等交互结束时(mouseup)才把”从初始到最终”这一整段作为一个撤销单元提交。换句话说,撤销栈应该记录交互的结果,而不是交互的过程。
另外画布里常有一些不该进撤销历史的状态变化,比如纯粹的视口平移和缩放、图元的临时高亮、辅助线的显隐。判断标准是:这个变化算不算用户对”文档内容”的修改?只有内容性的改动才进历史,视图性的、临时性的状态不要污染撤销栈,否则用户会发现自己按了好几次 Ctrl+Z 文档却一动不动,只是画面在缩放。这里更稳的做法不是在入栈口设卡过滤,而是在数据建模阶段就把文档状态(图元的坐标、尺寸、样式、层级,需要持久化、需要协同的内容)和会话状态(视口、选中、悬停高亮、辅助线、拖拽中间态,刷新即丢、别人不需要知道)分成两条独立通道,让撤销系统从源头就只订阅文档状态那一棵树。
六、以富文本编辑器为例的具体实践
富文本是撤销机制里最难啃的一块,难点几乎全部来自 contenteditable。浏览器原生的 contenteditable 自带一套撤销栈(也就是 document.execCommand('undo')),但它基本不可控、跨浏览器行为不一致,而且一旦你用 JavaScript 直接改了 DOM,原生撤销栈就会和修改脱节直接乱掉。
所以现代富文本编辑器几乎无一例外地选择接管撤销:自己维护一个独立于 DOM 的文档数据模型(document model),所有编辑都先作用在这个模型上,再由框架把模型渲染成 DOM;撤销操作的是模型而不是 DOM。这也是 ProseMirror、Slate、Lexical、Quill 这些主流框架的共同设计——它们都有自己的文档结构和自己的历史模块,并且会主动屏蔽浏览器原生撤销。
其实完全可以用这些框架自带的历史能力,因为它们已经把无数边界情况处理过了。 如果确实要自己实现的话还是要注意几个点:
文本输入的合并,连续打字应该合并成按词撤销,所以你需要在历史模块里实现输入的归并逻辑,并在空格、换行、光标跳转、格式切换等语义边界处断开。富文本里的”格式操作”(加粗、改颜色、设标题)和”内容操作”(插入删除文字)通常应该是不同的合并组,用户连续打字时不希望中间穿插的一次加粗被一起撤销掉。
输入法(IME)。中文、日文、韩文用户通过输入法组词时,会触发一连串 compositionstart、compositionupdate、compositionend 事件,组词过程中的中间态绝对不能各自入栈,否则撤销会把用户带回半成品的拼音状态,体验非常糟糕。正确做法是在 composition 期间挂起历史记录,等 compositionend 真正确认输入之后,才把这一整段组词作为一个撤销单元提交。
选区恢复。因为文档结构变化后,原来的选区坐标可能完全失效。主流框架的做法是用一套与具体 DOM 解耦的位置表示(比如基于文档节点路径和偏移量的”position mapping”),在文档变换时把旧位置映射到新位置,撤销时再据此还原光标。自己实现的话,至少要保证撤销后光标落在一个合理且稳定的位置,而不是莫名其妙跳到文档开头。
七、传统手段下的一些工程细节
副作用和异步操作。如果某个编辑动作除了改文档还触发了网络请求、文件上传、或者修改了文档之外的外部资源,那么”撤销文档”并不能撤销那些副作用。处理原则是尽量让进入历史的操作是纯粹的、对文档模型的同步变更;把网络请求、持久化等副作用从撤销路径里剥离出去,作为模型变更的下游反应来处理,而不是让它们成为撤销单元的一部分。否则用户撤销一次,要去思考”那个已经上传成功的图片要不要删”这种很难有正确答案的问题。
内存管理。无论快照式还是命令式,历史栈都不能无限增长,通常要设一个上限(比如保留最近一两百步),超过就丢弃最老的记录。快照式尤其要注意,哪怕有结构共享,长时间编辑积累的快照也可能可观,可以做”定期合并旧快照”或”只保留近期细粒度、远期粗粒度”的分层策略。
任何新的编辑动作都应该清空 redo 栈。用户撤销了几步之后又开始打字,这时之前那条”重做”的分支就失去了意义,必须丢弃,否则重做会把文档带到一个和当前编辑逻辑冲突的状态。这是几乎所有编辑器都遵守的标准行为,但自己实现时很容易漏掉。
撤销 / 重做这种功能极其依赖测试,而且很难靠手测覆盖全。还是要写一份充分的自动化测试,“随机生成一串操作,全部撤销后断言文档回到初始状态、全部重做后又回到最终状态”这类性质测试(property-based testing),看能不能揪出那些手工很难想到的逆操作错误。
八、协同编辑会彻底改变撤销的含义
单机撤销的世界是干净的:文档只有一条时间线,撤销就是沿着这条线往回走。多人协作彻底打破了这个前提——文档的时间线变成了多个用户的操作交织在一起的一条公共历史,而每个用户心里却各自有一条”我自己做过什么”的私有时间线。撤销机制的全部难度,都来自于如何在这条交织的公共历史上,正确地撤掉某个用户的某一步,同时不破坏别人的工作。
8.1 撤销的语义是”全局”还是”局部”
假设 A 和 B 在同一张画布上,A 移动了一个矩形,紧接着 B 改了一个圆的颜色,现在 A 按下 Ctrl+Z。A 期望发生什么?几乎可以肯定,A 想撤销的是”自己刚移动的那个矩形”,而不是”全局最后一步操作”——也就是 B 改的颜色。如果你的撤销撤掉了 B 的修改,A 会困惑,B 会愤怒。
这就是局部撤销(local undo / per-user undo):让一个”过时的逆操作”适配”已经变了的文档”就是协同撤销的主要的技术问题。每个用户的撤销只作用于他自己产生的操作。它几乎是所有协作产品的唯一正确选择,Figma、飞书文档、Google Docs 都是这个语义。与之相对的全局撤销(撤销公共历史的栈顶,不管是谁做的)只在极少数单人为主、偶尔多人的场景下才说得通,绝大多数情况下你要实现的是局部撤销。用户要撤销的那一步操作,在它被提交之后,文档上很可能已经叠加了别人(甚至自己)的若干新操作,当时记录的那个”逆操作”放到现在的文档上很可能不再成立。
8.2 选择协同底座:OT 还是 CRDT
撤销不能脱离协同算法单独设计,因为”让逆操作适配当前文档”这件事,本质上就是协同算法在做的事。主流有两条路。
一条是 OT(Operational Transformation,操作变换)。它把每次编辑表示成一个操作(op),核心是一个 transform 函数:给定两个并发的操作 a 和 b,它能算出”在已经应用了 b 的文档上,a 应该变成什么样”。Google Docs 早期就是 OT。OT 的撤销依赖一个叫 transform-against-the-history 的思路——把要撤销的逆操作,依次对它之后发生的每一个操作做 transform,变换到当前状态再应用。OT 概念直观但 transform 函数极难写对,尤其是树形结构上的并发场景,是出了名的容易出 bug。
另一条是 CRDT(Conflict-free Replicated Data Type,无冲突复制数据类型)。它的思路是把数据结构设计成”无论操作以什么顺序到达、合并结果都一致”,从而免去中央服务器做 transform 的负担。Yjs、Automerge 是这条路的代表。CRDT 对应用开发者最大的好处是:它通常自带了协同感知的撤销管理器,你不用自己去实现 transform-against-history 那套东西。
绝大多数情况都可以直接用 Yjs 这类成熟 CRDT 库,它的 UndoManager 已经把局部撤销、操作变换、选区恢复这些都处理好了。下面先给出基于 Yjs 的完整落地方案(推荐路线),再讲清楚如果你必须自研 OT,撤销算法的原理长什么样。
8.3 基于 Yjs 的协同撤销
把画布的文档状态建模成一个共享数据结构,所有客户端各持一份副本,本地编辑先改自己的副本、立刻渲染(保证手感),同时把改动作为增量广播出去;收到别人的增量就合并进自己的副本。撤销由每个客户端本地的 UndoManager 负责,它只追踪”本客户端产生的”变更。
本地编辑 → 改 Yjs 文档 → 1) 本地渲染 2) 编码成 update 广播收到远端 update → 合并进 Yjs 文档 → 重新渲染Ctrl+Z → UndoManager.undo() → 只回退本客户端的变更 → 触发渲染 + 广播文档建模
把每个图元建成一个 Yjs 的 Y.Map,整张画布是一个 Y.Map(以图元 id 为 key)或 Y.Array。关键点是:所有会被撤销、会被协同的属性都必须存在 Yjs 类型里,而视口、高亮、选区这些会话状态绝对不要放进去(对应第五节强调的双通道隔离——会话状态不进文档、不进历史、也不进协同)。
import * as Y from "yjs";
const ydoc = new Y.Doc();// 整张画布:key 是图元 id,value 是描述图元的 Y.Mapconst yShapes = ydoc.getMap("shapes");
function addShape(shape) { // 用事务包裹,保证这是一个原子的撤销单元 ydoc.transact(() => { const yShape = new Y.Map(); yShape.set("x", shape.x); yShape.set("y", shape.y); yShape.set("w", shape.w); yShape.set("h", shape.h); yShape.set("fill", shape.fill); yShapes.set(shape.id, yShape); }, /* origin */ localOrigin);}其中 transact 的第二个参数 origin标记了”这次变更是谁、从哪个来源发起的”。这个参数是协同撤销的开关
配置只撤销本地操作的 UndoManager
Yjs 的 UndoManager 默认会捕获它所观察的共享类型上的所有变更,但你可以通过 trackedOrigins 限定它只追踪特定来源的变更。把本地操作统一打上同一个 origin,再让 UndoManager 只追踪这个 origin,局部撤销就实现了——远端来的变更(origin 不同)不会进入本地的撤销栈。
const localOrigin = { user: myUserId }; // 本地变更统一用这个 origin
const undoManager = new Y.UndoManager(yShapes, { trackedOrigins: new Set([localOrigin]), // 只追踪本地来源 captureTimeout: 500, // 500ms 内的连续变更合并为一个撤销单元});
// 撤销 / 重做function undo() { undoManager.undo();}function redo() { undoManager.redo();}这里 captureTimeout 就是前面讲过的”合并 / coalescing”机制——连续拖动、连续微调在 500ms 窗口内会被并成一步,避免用户按到崩溃。需要强制断点(比如交互结束、切换了操作类型)时,调用 undoManager.stopCapturing() 主动结束当前合并窗口,下一次变更就成为新的撤销单元。
连续交互仍然遵循”过程不入栈、结果才提交”
协同环境下,拖拽过程同样不应该每一帧都写进 Yjs(既污染历史,又会把上百个中间态广播出去造成网络风暴)。做法是拖动过程中只改本地会话状态驱动渲染,松手时才用一个事务把最终结果写进 Yjs:
let dragStart = null;
function onPointerDown(shapeId, pt) { const yShape = yShapes.get(shapeId); dragStart = { x: yShape.get("x"), y: yShape.get("y"), px: pt.x, py: pt.y };}
function onPointerMove(shapeId, pt) { // 只更新会话状态 → 实时渲染,不碰 Yjs,不入栈,不广播 sessionStore.setGhostPosition(shapeId, { x: dragStart.x + (pt.x - dragStart.px), y: dragStart.y + (pt.y - dragStart.py), });}
function onPointerUp(shapeId) { const ghost = sessionStore.getGhostPosition(shapeId); ydoc.transact(() => { const yShape = yShapes.get(shapeId); yShape.set("x", ghost.x); yShape.set("y", ghost.y); }, localOrigin); // 一次事务 = 一个撤销单元 undoManager.stopCapturing(); // 让这次拖拽成为独立的一步 sessionStore.clearGhost(shapeId);}网络同步
同步层负责把本地的增量编码后发出去、把收到的增量合并进来。这里只发文档增量,会话状态(光标、选区、视口)走另一条轻量的 awareness 通道,两者分开。
// 本地文档变更 → 广播ydoc.on("update", (update, origin) => { if (origin === "remote") return; // 别把远端来的又发回去 network.broadcast(encodeUpdate(update));});
// 收到远端增量 → 合并(注意origin 标成 remote,不会进本地撤销栈)network.onMessage((update) => { Y.applyUpdate(ydoc, decodeUpdate(update), "remote");});
// 任何文档变化都触发重渲染(本地、远端、撤销都会走到这)yShapes.observeDeep(() => renderCanvas(yShapes));这套配置下的局部撤销中,远端变更 origin 是 'remote',不被 UndoManager 追踪;本地变更才进撤销栈。而当 A 撤销自己的某一步时,即便这一步之后 B 已经改过别的图元,Yjs 的 CRDT 模型会保证 A 的逆操作正确合并进当前文档状态,B 的改动不受影响——这部分”逆操作适配当前文档”的复杂逻辑全部由库内部处理了。
选区与协同感知
撤销时恢复选区,在 Yjs 里靠的是相对位置(relative position)——一种和具体索引解耦、能在文档变化后映射回正确位置的坐标。UndoManager 支持在每个撤销单元上挂载额外的元信息,可以借 stack-item-added / stack-item-popped 事件把操作前的选区存进去、撤销时取出来还原:
undoManager.on("stack-item-added", (event) => { event.stackItem.meta.set("selection", sessionStore.getSelection());});undoManager.on("stack-item-popped", (event) => { const sel = event.stackItem.meta.get("selection"); if (sel) sessionStore.restoreSelection(sel); // 定位不到时内部要兜底});别人的光标、选区、视口则通过 Yjs 的 awareness 协议广播,它是临时状态、不进文档也不进历史,正好对应会话状态的归属。
8.4 OT 路线的撤销原理
有些团队因为历史包袱或特殊约束无法引入 CRDT 库,只能在自研 OT 上做撤销。这里讲清楚算法骨架,但要提醒:transform 的正确性是这条路最大的风险,务必配性质测试死守。
核心数据有两套。一是服务端维护的全局操作历史(所有人已确认的操作的有序列表)。二是每个客户端本地的两个栈:undo 栈和 redo 栈,里面存的是”本用户自己操作的逆操作”。
撤销的算法是这样的。当用户要撤销自己的操作 op(它的逆是 inv)时,inv 是基于”op 刚提交时的文档”算出来的,但现在文档上已经叠加了 op 之后的一串操作 [c1, c2, ..., cn](可能是别人的,也可能是自己后来做的)。直接应用 inv 会出错,所以要把 inv 依次对这些后续操作做 transform,得到一个适配当前文档的 inv',再应用并广播它:
inv' = invfor c in [c1, c2, ..., cn]: # op 之后发生的每一个操作 inv' = transform(inv', c) # 把逆操作"推进"到当前状态apply(inv') # 应用变换后的逆操作broadcast(inv') # 当作一个新操作发给别人push_to_redo(invert(inv')) # 它的逆进 redo 栈,以便重做transform(a, b) 的含义是”在已经发生了 b 的前提下,a 该变成什么样才能保持原本的意图”。比如 a 想删除第 5 个字符,而 b 在第 2 个位置插了 3 个字符,那么 transform 后 a 就该去删第 8 个字符。图形画布上相对简单些(移动、改属性大多互不干扰,按 id 定位即可),但涉及结构变化(删除、分组、层级调整)的并发就会变得棘手,每一对操作类型的组合都要写出正确的 transform 规则,这是工作量和 bug 的主要来源。
重做是对称的:从 redo 栈取出操作,同样对它之后发生的操作做 transform,再应用和广播。同时要守住单机时代就有的那条规则——用户产生任何新的正向编辑时,清空 redo 栈,因为重做分支已经失去意义。
8.5 协作场景特有的几个坑
第一,撤销本身也是一个要广播的操作。本地撤销改了文档,这个改动必须像普通编辑一样发给其他人,否则各客户端会发散。所以撤销不是”本地回退”,而是”产生一个让文档回退的新操作并同步出去”。
第二,注意撤销别人依赖你的内容时的连锁反应。比如 A 画了个矩形,B 把一条连线连到了这个矩形上,然后 A 撤销了”创建矩形”。这时 B 的连线指向了一个不存在的对象。CRDT 库通常会把这种悬空引用处理成一个合理的最终态(连线变成悬空或被清理),但如果是自研,你必须明确定义这种引用完整性该怎么收场,否则会出脏数据。
第三,会话状态绝不能进协同文档。这点前面反复强调过,在多人场景下更要命——如果你不小心把”选中状态”写进了共享文档,那么 A 选中一个图元,B 屏幕上那个图元也会被选中,撤销栈里还会塞满毫无意义的”选区变更”。选区、高亮、视口、拖拽中间态一律走 awareness 这类临时通道,和文档、历史彻底隔离。
第四,captureTimeout / 合并窗口在协同下要更克制。单机时连续打字合并成一步没问题,但协同时如果合并窗口太长,可能把跨越了别人操作的两段本地变更并成一个撤销单元,撤销时行为会变怪。让合并只在没有被远端操作打断的连续本地动作之间生效,是比较稳妥的策略,Yjs 的 UndoManager 内部已经考虑了这类边界。
总结
总之做这个的话先有一个单一、清晰、可序列化的文档模型;在快照式和命令式之间根据规模和协同需求做出取舍;用事务和合并把撤销颗粒度对齐到用户的意图而不是数据结构;连同选区一起回退以保证上下文不丢;针对画布处理好连续交互的合并、把会话状态与文档状态分成两条通道,针对富文本处理好 IME 和原生 contenteditable 的接管; 如果有协同就从一开始按局部撤销和操作变换来设计,多数还是用 Yjs 这类成熟框架
反正具体情况具体来看。
分享到社交平台
将本文分享给你的朋友们
Zhongye