绵阳市网站建设_网站建设公司_表单提交_seo优化
2025/12/30 9:41:03 网站建设 项目流程

我有一支技术全面、经验丰富的小型团队,专注高效交付中等规模外包项目,有需要外包项目的可以联系我

撤销(Undo)这种功能,看起来很简单:点一下回到上一步嘛。 但你真做过就知道,它最擅长的不是“回退”,而是悄悄把你的状态系统炸成一团

我做过好几次 undo 栈:最早那种指针方案,基本都活不到上线就开始报undefined;到了第三次,我决定换思路——做一个轻量、但很难写崩的方案。

我的目标很明确:

  • 不要任何“指针 + 下标运算”

  • 只要纯粹、可预测的数据流

  • 出错面尽量小

于是我选了一个老而稳的思路:Undo/Redo 用两条独立的栈来表示。

为什么你需要一个“极简撤销栈”

只要你的应用允许用户修改数据(改文字、拖拽、加条目、删记录、改设置……),你几乎都需要撤销/重做。

一般有两种模式:

  • 版本历史(Version history):像 Photoshop 时间线那样,可以回到任何历史节点

  • 撤销栈(Undo stack):线性的,撤一步、再撤一步;重做也是线性的

现实里,绝大多数产品只需要第二种。

那问题就变成:怎么让这个“线性撤销”又简单又可靠?

指针方案的问题:你迟早会踩到越界

很多实现会用一个数组 + 一个指针:

  • push:指针前进

  • undo:指针后退

  • redo:指针再前进

听起来很合理。 但在 JS 里,它很容易出现“指针漂移”这种阴间 bug:

  • 指针没更新对

  • redo 历史没清干净

  • 指针越界后读到了不存在的 index

  • 然后你就开始看见cannot read property ... of undefined

我不想再追着 index 跑了。

于是我把它拆成两条栈:

  • past:过去(可撤销)

  • future:未来(可重做)

双栈:最干净的 Undo/Redo 结构

双栈的核心动作特别像“倒沙子”:

  • 执行新动作:放进past,并清空future(因为未来已经被你改写了)

  • undo:从past弹出,执行 undo,再放进future

  • redo:从future弹出,执行 do,再放回past

代码长这样(保留原结构、只做小幅调整让逻辑更顺):

function createUndoStack() { let past = []; let future = []; return { push(doFn, undoFn) { doFn(); past.push({ doFn, undoFn }); // 新动作会抹掉所有可重做的历史 future.length = 0; }, undo() { const action = past.pop(); if (action) { action.undoFn(); future.unshift(action); } }, redo() { const action = future.shift(); if (action) { action.doFn(); past.push(action); } }, get canUndo() { return past.length > 0; }, get canRedo() { return future.length > 0; } }; }

这套方案的好处是:完全没有指针。所以也就不会有“指针跑偏导致访问越界”的经典事故。

缺点是:会多占一点内存。 但换来的是:更简单、更可读、更不容易写崩。

但还有一个暗坑:闭包会“偷走你的最新状态”

上面那套写法有个非常常见的陷阱:作用域捕获(closure capture)

在 JavaScript 里,函数定义在另一个函数内部,会保留外层变量的引用。 这意味着:你以为保存的是“当时的数据”,实际可能在 undo 时读到的是“后来变化后的数据”。

结果就会出现一种很恶心的现象:

你点了 undo,但恢复出来的不是当时的状态,而是某个“被更新过的版本”。

解决方案很直接:在 push 的那一刻,把你需要的数据克隆下来。

现代 JS 很方便:用structuredClone()直接深拷贝参数,让 do/undo 永远拿到同一份“冻结的输入”。

加上 structuredClone:把撤销做成“稳到离谱”

下面是更稳的一版(保留你原本结构,仍然是双栈,只把数据捕获变成克隆):

function createUndoStack() { const past = []; const future = []; return { push(doFn, undoFn, ...withArgumentsToClone) { const clonedArgs = structuredClone(withArgumentsToClone); const action = { doWithData() { doFn(...clonedArgs); }, undoWithData() { undoFn(...clonedArgs); } }; action.doWithData(); past.push(action); future.length = 0; }, undo() { const action = past.pop(); if (action) { action.undoWithData(); future.unshift(action); } }, redo() { const action = future.shift(); if (action) { action.doWithData(); past.push(action); } }, get undoAvailable() { return past.length > 0; }, get redoAvailable() { return future.length > 0; }, clear() { past.length = 0; future.length = 0; return true; } }; }

逻辑还是那套逻辑,但现在每个 action 都带着一份“当时就定格”的参数快照。 你不再怕变量后续变化造成“撤销时回不去”。

使用示例:像按 Ctrl+Z 一样简单

const items = []; const undoStack = createUndoStack(); // add 1 undoStack.push( (v) => items.push(v), // doFn () => items.pop(), // undoFn 1 // 会被克隆并在 do/undo 中复用 ); console.log(items); // add 2 undoStack.push( (v) => items.push(v), () => items.pop(), 2 ); console.log(items); // add 3 undoStack.push( (v) => items.push(v), () => items.pop(), 3 ); console.log(items); // [1, 2, 3] // undo (remove 3) undoStack.undo(); console.log(items); // [1, 2] // redo (add 3 back) undoStack.redo(); console.log(items); // [1, 2, 3] undoStack.undo(); // [1, 2] undoStack.undo(); // [1] undoStack.redo(); // [1, 2] // 清空历史 undoStack.clear(); console.log(items); // [1, 2]

每个动作的数据只在 push 时克隆一次,之后无论 undo/redo 都能安全复现。 没有陈旧引用,也没有“闭包偷换状态”的诡异问题。

最终结论

想把 Undo 做到“稳定、简单、可预测”,你只需要两件事:

  1. 用双栈代替指针数组:past / future 让 undo/redo 像倒沙子一样流动,彻底告别指针漂移

  2. push 时克隆参数:用structuredClone()把数据快照锁死,避免闭包拿到“最新值”导致回不去

这套方案紧凑、好读、耐操,而且你调试时不会突然被它背刺。

下次项目里你要做撤销,先试试这个双栈写法。 一旦用顺了,你会发现自己再也不想回到“数组 + 指针”那条路了。

全栈AI·探索:涵盖动效、React Hooks、Vue 技巧、LLM 应用、Python 脚本等专栏,案例驱动实战学习,点击二维码了解更多详情。

最后:

CSS终极指南

Vue 设计模式实战指南

20个前端开发者必备的响应式布局

深入React:从基础到最佳实践完整攻略

python 技巧精讲

React Hook 深入浅出

CSS技巧与案例详解

vue2与vue3技巧合集

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询