news 2026/6/9 22:43:00

一个 JS 撤销技巧:把混乱变成可控(用“双栈”把 Undo/Redo 写到不容易炸)

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
一个 JS 撤销技巧:把混乱变成可控(用“双栈”把 Undo/Redo 写到不容易炸)

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

撤销(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技巧合集

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/6/5 6:04:27

Miniconda-Python3.9镜像集成常用AI工具链

Miniconda-Python3.9 镜像集成常用 AI 工具链 在现代 AI 开发中,一个常见的痛点是:明明本地跑通的模型,换台机器却因为“某个包版本不对”而报错。这种“在我机器上能跑”的尴尬局面,本质上源于开发环境缺乏标准化与可复现性。尤其…

作者头像 李华
网站建设 2026/6/9 21:28:13

Webhook自动化部署实战:3天从零搭建智能触发器系统

Webhook自动化部署实战:3天从零搭建智能触发器系统 【免费下载链接】webhook webhook is a lightweight incoming webhook server to run shell commands 项目地址: https://gitcode.com/gh_mirrors/we/webhook 还在为每次代码更新都要手动登录服务器、执行繁…

作者头像 李华
网站建设 2026/6/9 17:46:58

FanFicFare:从网页到电子书的智能转换专家

FanFicFare:从网页到电子书的智能转换专家 【免费下载链接】FanFicFare FanFicFare is a tool for making eBooks from stories on fanfiction and other web sites. 项目地址: https://gitcode.com/gh_mirrors/fa/FanFicFare 还在为收藏喜爱的同人小说而烦恼…

作者头像 李华
网站建设 2026/6/9 17:47:06

终极指南:如何用DeepLabCut实现AI姿势识别与动物行为分析

终极指南:如何用DeepLabCut实现AI姿势识别与动物行为分析 【免费下载链接】DeepLabCut Official implementation of DeepLabCut: Markerless pose estimation of user-defined features with deep learning for all animals incl. humans 项目地址: https://gitco…

作者头像 李华
网站建设 2026/6/8 22:23:21

你以为的职业危机是 35 岁?

点击关注公众号,Java 干货及时推送↓推荐阅读:今年后端行情真不错。。大家好,我是R哥。2025 年都快过完咯,今天咱不讲技术,讲点人话。你以为的职业危机是 35 岁?事实上很多人 25 岁就开始躺平了……应一些正…

作者头像 李华
网站建设 2026/6/9 17:44:51

AD16终极元件封装合集:5分钟提升PCB设计效率

AD16终极元件封装合集:5分钟提升PCB设计效率 【免费下载链接】AD16最全封装库自用 本仓库提供了一个名为“AD16最全封装库(自用).rar”的资源文件下载。该文件包含了各种CPU、存储器、电源芯片、几乎所有接口(如DB9、DB15、RJ45、…

作者头像 李华