尧图网站建设 尧图网络
  • 首页
  • 关于我们
  • 服务项目
  • 案例展示
  • 建站流程
  • 资讯中心
  • 联系我们
首页/资讯中心/详情

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

一个 JS 撤销技巧:把混乱变成可控(用“双栈”把 Undo/Redo 写到不容易炸)
📅 发布时间:2026/6/20 0:49:10

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

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

相关新闻

  • 2025年臭氧机厂家权威推荐榜单:高浓度臭氧机/工业臭氧机/小型臭氧机/医用臭氧机/空气源臭氧机/水处理臭氧机及大型臭氧机源头厂家精选。 - 品牌推荐官
  • 2025年小包团旅行社排行榜,推荐一下不错的小包团旅行机构精选名单 - 工业推荐榜
  • 零基础搭建ARM嵌入式环境中的BusyBox根文件系统

最新新闻

  • 2026年江苏同等学力申硕机构:为何沃顿教育持续? - 品牌鉴赏官2026
  • LPC3130/3131 LCD接口配置全解析:从引脚复用到驱动实战
  • 2026年更新:国内加热美食机批发商哪个好?湖南中吉综合实力深度解析 - 品牌鉴赏官2026
  • MC68340指令集深度解析:从CISC寻址到系统控制与性能优化
  • 深入解析MC68HC908EY16A:8位MCU架构、外设与低功耗设计实战
  • MC68HC908看门狗与CPU核心:嵌入式系统可靠性的硬件守护者

日新闻

  • 信任的进化:技术实现详解——如何用JavaScript构建博弈论模拟器
  • Terrakube自定义工作流:如何集成OPA、Infracost等工具扩展IaC能力
  • grunt-concurrent快速入门:5分钟学会并行运行Grunt任务

周新闻

  • 3步解锁iOS设备:applera1n激活锁绕过完全指南
  • 39 2026 人工智能证书终极盘点,普通人选 AI 证书可以从这些方向入手
  • Redis 暴露公网有多危险?从端口检查到补救步骤

月新闻

  • 【总结】入门篇:50句话让你记住架构核心概念
  • WeChatMsg技术方案解析:实现Mac微信数据自主管理的完整解决方案
  • WeChatMsg:革新性微信数据备份方案,打造你的专属数字记忆库

关于尧图

  • 公司简介
  • 团队介绍
  • 企业文化
  • 荣誉资质

服务项目

  • 定制开发
  • 电商建站
  • UI 设计
  • 运维服务

快速链接

  • 案例展示
  • 建站流程
  • 常见问题
  • 资讯中心

联系方式

  • 📍北京市朝阳区互联网产业园 A 座 10 层
  • 📞400-888-8888
  • ✉️contact@rkmt.cn
  • 🕐周一至周日 9:00-21:00

© 2024 北京尧图网络科技有限公司 版权所有 | 京 ICP 备 XXXXXXXX 号