Undo/Redo的核心是记录状态快照并用双向链表组织,因其支持O(1)首尾访问、任意位置增删及天然前后跳转;栈仅LIFO无法跳转,数组扩容与插入删除低效且不支持分叉。

Undo/Redo 的核心不是记操作,而是记状态快照;用双向链表组织这些快照,能天然支持前后跳转、O(1)首尾访问、任意位置插入删除——这正是撤销重做最需要的结构特性。
为什么双向链表比栈或数组更适合 Undo/Redo
栈(LIFO)只能线性回退,无法跳转到中间某步再分支;普通数组扩容成本高、插入删除慢、不支持动态历史分叉。而双向链表提供:
- 每个节点代表一个完整变量状态(如编辑器光标位置、文本内容哈希、控件值集合),含时间戳与操作类型标记
- 当前指针(current)始终指向“最新已提交”状态,可向前(Undo)或向后(Redo)移动
- 执行新操作时,自动截断 current 后所有 Redo 节点,并将新状态插入 current 后——无需遍历,仅 4 次指针更新
- 支持最大步数限制:容量满时,直接移除链头(最早状态),而非低效的数组移位
关键状态节点设计与链表初始化
节点需承载可序列化状态,例如:
- data:JSON 字符串或轻量对象引用(避免深拷贝,用不可变数据结构更安全)
- timestamp:毫秒级时间戳,用于合并连续小操作(如连打字母可聚合成一次输入)
- type:"insert"/"delete"/"move"/"format" 等语义标记,辅助 UI 显示“撤销键入”而非“撤销第7步”
- isMergeable:布尔值,控制是否允许与前一步合并(如两次 backspace 可合并为一次删除)
初始化必须构建自闭环哨兵头节点:head.next = head; head.prev = head;,确保空链表也能安全执行所有操作,不依赖 null 判定。
Undo/Redo 核心逻辑三步走
- 执行 Undo:若 current ≠ head 且 current.prev ≠ head,则 current = current.prev;UI 恢复 current.data
- 执行 Redo:若 current.next ≠ head 且 current.next ≠ current,则 current = current.next;UI 应用 current.data
- 记录新操作:先调用 ListEraseAllAfter(current)(断开并释放 current 后全部节点),再 ListInsertAfter(current, newNode),最后 current = newNode
工程细节避坑指南
真实落地时容易踩的几个隐性陷阱:
- 状态快照必须深冻结或不可变:JavaScript 用 Object.freeze 或 Immutable.js;Java 用 record 类或 copy-on-write 容器
- 内存泄漏风险:节点持有 DOM 引用、闭包或大型 buffer 时,务必在 ListErase 前手动清理(如 removeEventListener、abortController.abort)
- 多端同步冲突:若支持协同编辑,需在节点中加入 clientID + sequenceID 复合主键,避免本地链表与服务端快照错序
- 性能兜底:对超大状态(如画布像素数据),改存 diff 补丁而非全量快照,链表节点只存 patch + baseVersion 指针











