撤销重做:基于历史栈的 Undo/Redo
目标
Ctrl+Z 是编辑器标配。本节课写一个基于快照的历史栈 History:每次改动压入新状态,撤销/重做只是移动指针。这一段完全在 Node 里可运行。
运行环境:Node.js v18+。运行方式:保存为 history.js,执行 node history.js。
// history.js —— 基于快照的命令历史栈(也可用于编辑器内容)
class History {
constructor(initial) {
this.stack = [initial] // 快照数组
this.index = 0 // 当前指针
}
get current() { return this.stack[this.index] }
push(state) {
// 压新状态时,丢弃指针之后的"重做分支"
this.stack = this.stack.slice(0, this.index + 1)
this.stack.push(state)
this.index = this.stack.length - 1
}
undo() { if (this.index > 0) this.index--; return this.current }
redo() { if (this.index < this.stack.length - 1) this.index++; return this.current }
}
// ===== 调用示例 =====
const h = new History('')
h.push('hello')
h.push('hello world')
console.log('当前:', h.current) // hello world
console.log('undo :', h.undo()) // hello
console.log('redo :', h.redo()) // hello world
h.push('hello world!') // 分叉:旧的重做分支被丢弃
console.log('分叉后 undo:', h.undo()) // hello world
console.log('还能 redo?', h.redo()) // hello world!指针法的精髓:撤销不是"反向执行",而是"回到上一个快照";在新状态上再编辑会丢掉 redo 分支(像真的编辑器一样)。
名词解释
撤销(Undo)/ 重做(Redo):回到上一次 / 下一次编辑状态。Ctrl+Z 撤销,Ctrl+Y 或 Ctrl+Shift+Z 重做。
快照(Snapshot):某一时刻内容的完整副本。每次改动存一份快照,撤销就是切回旧快照。
历史栈指针:用数组存快照、index 指当前位置。压新状态时截断 index 之后的分支,这正是"编辑后旧重做失效"的来源。
课后练习
练习:上面的 History 每次都存整份字符串,内容大时会很占内存。请说明一种改进思路(不用写代码)。
答案:用"命令模式"替代整份快照——不存完整内容,而是存"这次改了什么"(如 {type:'insert', pos:5, text:'ab'} 与对应的反向命令)。撤销=执行反向命令,重做=执行正向命令。这样内存从 O(n×步数) 降到 O(步数×改动量),ProseMirror 等就是这么做的(基于 step/transaction)。
总结
Undo/Redo 看似简单,却是编辑器工程里的经典难题。最朴素的"整份快照"法人人能写(本课就是),但它随内容变大越来越费内存。生产编辑器的进化方向是"命令/事务(transaction)":不存结果,存"变更本身",撤销就是反向应用变更。这带来两个好处——省内存,且天然支持协同(把变更广播给别人就能复现你的编辑)。本课你建立的指针模型是关键直觉:撤销 = 移动指针,不是倒放操作。这个直觉直接通向 OT/CRDT 协同(第10节)。