双栈逆转混乱:如何用JavaScript打造永不崩溃的Undo/Redo系统
导语:指针漂移让撤销成为前端噩梦。用双栈+克隆思路,你能获得一个稳定到离谱的Undo/Redo实现,再也不怕越界与状态错乱。
为什么你的撤销栈容易崩溃?
撤销(Undo)功能在任何一个允许用户修改数据的应用中都不可或缺——无论是文本编辑器、图形工具、表单配置,还是各类拖拽界面。它的存在感很低,点一下 Ctrl+Z 就回去了,好像理所当然。但亲手实现过的人都知道,撤销真正擅长的事并不是“回退”,而是“悄悄把你的状态系统炸成一团”。
常见的破坏模式包括:指针跑偏读出 undefined、重做历史被污染、撤销回去的状态是错乱的。这些 bug 往往在开发后期才暴露,而且极难追踪。如果你已经踩过坑,这篇文章会给你一套“很难写崩”的轻量方案;如果你正准备第一次实现撤销,这套方案能让你直接跳过所有阵痛期。
指针方案:看似简单,实则遍地是坑
大多数人的第一反应是用一个数组加一个指针:
- push 时指针前移;
- undo 时指针后退,执行反向操作;
- redo 时指针前进,重新执行正向操作。
这个模型在静态语言里或许能运转,但在动态的 JavaScript 中,你很快会遭遇“指针漂移”。例如:
- 用户执行几次操作后,又撤回了三步,此时指针停在中间位置。然后用户突然做了一个新动作,旧的重做历史没有被清理干净;下次 redo 时指针越界,抛出一连串
cannot read property ... of undefined。 - 或者你的数组是用 splice 动态管理长度的,某个边界条件导致指针指向的索引被意外删除,同样产生 undefined 访问。
这些问题本质上是“可变数组 + 可变指针”带来的状态混乱,修复往往需要加上一堆 if (index <= stack.length) 的防御代码,让逻辑越来越臃肿。
双栈模型:Past 与 Future 的倒沙哲学
一个更干净的思路是放弃指针,改用两条独立的栈:past(可撤销栈)和 future(可重做栈)。它的运行方式就像倒沙:
- 执行新动作:将动作封装好放入 past,并清空 future,因为新动作意味着未来分支已被改写。
- Undo:从 past 弹出末尾动作,执行其 undo 逻辑,然后把这个动作压入 future。
- Redo:从 future 弹出末尾动作,执行其 do 逻辑,再压入 past。
整个过程完全对称,没有任何索引计算,更没有指针越界的风险。即使你疯狂交替按下 Ctrl+Z 和 Ctrl+Y,两条栈的容量总和永远等于历史深度,边界清晰到不用加一行防御代码。
基础双栈实现
先来看一个最简单的版本,只处理 do/undo 函数的传递:
class UndoManager {
constructor() {
this.past = [];
this.future = [];
}
push(doFn, undoFn) {
this.past.push({ do: doFn, undo: undoFn });
this.future = []; // 新动作改写未来
}
undo() {
if (!this.past.length) return;
const action = this.past.pop();
action.undo();
this.future.push(action);
}
redo() {
if (!this.future.length) return;
const action = this.future.pop();
action.do();
this.past.push(action);
}
}
这种写法已经能规避指针问题,但它仍然藏着一个极难发现的暗坑:闭包捕获。
隐藏的定时炸弹:闭包捕获偷换状态
在 JavaScript 中,定义在函数内部的函数会保留对外层作用域变量的引用。这意味着你推入撤销栈的 do/undo 回调可能引用的是外层变量本身,而不是那个时刻的“快照”。
设想这样一个场景:用户拖拽一个滑块,从 50 拖到 80。你记录动作时只保存了“把值设为 80”的 do 函数和“把值设回原值”的 undo 函数。如果 undo 函数里引用了外部变量 previousValue,而这个变量后来被其他逻辑修改了,那么当你真正 undo 时恢复的就不是 50,而是某个莫名其妙的中间值。
这种“闭包偷换状态”的现象在异步场景或多次快速操作时尤其容易复现,导致撤销回不到正确的历史状态,而且调试时日志打印的值完全正常——因为那时变量的值刚好还没变。
用 structuredClone 冻结时间
解决思路很简单:在 push 的瞬间,用深拷贝把当前参数“冻住”,让 do/undo 函数永远只操作那份凝固的快照。现代浏览器已经内置了 structuredClone(),它能深度克隆大多数原生类型(包括对象、数组、Map、Set 等),且性能比 JSON 序列化更好,还支持循环引用。
我们只需在 push 时克隆参数,然后用克隆品生成操作函数:
push(doFn, undoFn, ...args) {
const snapshot = structuredClone(args);
const doAction = () => doFn(...snapshot);
const undoAction = () => undoFn(...snapshot);
this.past.push({ do: doAction, undo: undoAction });
this.future = [];
}
这样,每个动作都自带一份参数快照,无论外部变量如何变化,撤销和重做都能准确复现当时的数据。

稳定版实现要点
将上述改造整合后,一个真正能稳定运行的 UndoManager 就基本成型了。使用时只须为每个操作定义 do/undo 逻辑,并把所需参数传入 push:
const um = new UndoManager();
// 用户将文本从 "Hello" 改为 "World"
um.push(
(newVal) => { state.text = newVal; },
(oldVal) => { state.text = oldVal; },
'Hello', 'World'
);
核心思想只有两点:
- 用双栈替代指针数组,让撤销/重做的边界天然安全;
- 在 push 时克隆参数,用
structuredClone()消除闭包副作用。
这套方案紧凑、可读,而且极难出错。增加撤销堆栈大小限制、监听栈变化等功能也可以轻易地在双栈结构上扩展。
结语
撤销功能不该成为一个项目的隐患。当你习惯了双栈 + 结构化克隆的写法,会发现它比指针方案更符合直觉,调试时再也不会被突然爆出的 undefined 背刺。下次项目里要上 Undo/Redo,不妨先试试这个模式——或许你会从此告别数组加指针的老路。