一个 describe() 让撤销栈多占 18MB:命令模式省内存这条共识的实测复盘 翻十篇讲撤销重做的文章会看到十份几乎一样的结论表格全量快照实现简单但内存爆炸命令模式只存 diff 所以轻量因此编辑器应该用命令模式。这个结论是对的。但它省略了一个前提而这个前提在真实工程里经常不成立——我在一个画布工具上把四种历史栈的内存实测了一遍发现给命令对象多加一个看起来完全无害的describe()方法只读取一个数组的length200 步撤销栈就从 51KB 涨到了 18.4MB相差 372 倍。这个数字已经逼近同规模下内存爆炸的全量快照方案。换句话说命令模式省的不是内存是你没写错的时候的内存。背景这条共识缺了哪个前提主流建议把方案分成两类快照法Memento存整个状态命令法Command存动作差分。差分当然比全状态小所以命令模式赢。但这个推理里有个隐含假设命令对象的大小 ≈ 它声明的那几个字段的大小。在 JavaScript 里这个假设很脆弱。命令模式的教科书写法是让do()和undo()作为闭包捕获前后值function moveLayerCommand(layer, before, after) { return { label: 移动图层, do() { layer.x after; }, undo() { layer.x before; }, }; }闭包捕获的粒度不是变量而是作用域上下文Context。同一个作用域里创建的所有闭包共享同一个 Context 对象只要有一个闭包引用了某个变量这个变量就会被写进 Context然后被所有兄弟闭包一起钉住。而撤销栈的定义就是长期持有这些闭包。两者一叠加就有了本文要测的东西。实验设计四种历史栈同一份画布状态被测状态模拟一个矢量画布{ layers: [...] }每个图层是 12 个字段的普通对象id、name、x、y、w、h、rotation、opacity、fill、stroke、locked、visible。编辑动作固定为拖拽改一个图层的 x连续 200 步。四种历史栈实现方案每步存什么snapshotstructuredClone(state)全量深拷贝command带do()/undo()闭包的命令对象教科书写法command-plain纯数据命令{id, before, after}由外部执行器解释structural结构共享只复制 layers 数组和被改的那个图层其余共享引用内存测量方式是连续两次global.gc()后取process.memoryUsage().heapUsed的前后差值并对每种方案先跑一次小规模预热以排除 JIT 噪声。每个方案跑在独立进程里避免相互污染。图1四种方案的内存增长模型。快照与结构共享随状态规模 O(N) 增长命令模式与状态规模无关常数但常数项取决于闭包捕获了什么。复现命令# Node v22.22.2需要 --expose-gc for N in 50 500 5000; do for M in snapshot command command-plain structural; do node --expose-gc history-mem.js $M $N 200 done done第一组数据共识在大状态下确实成立先说结论主流建议在大状态下不仅成立而且差距比多数文章描述的还要夸张。200 步编辑后历史栈保留的堆内存每步平均字节图层数snapshotstructuralcommandcommand-plain5012,523 B644 B226 B77 B500127,323 B4,244 B226 B77 B50001,279,333 B40,253 B226 B77 B换算成 200 步的总占用5000 图层下全量快照保留244 MB结构共享 7.68 MB命令模式 44 KB纯数据命令 15 KB。244MB 对 44KB差 5600 倍。比内存更值得注意的是写入延迟。同一组测试里 200 步的构建耗时图层数snapshot 每步structural 每步500.052 ms—5000.505 ms—50005.68 ms0.021 ms5000 图层下每次编辑要花 5.68ms 做深拷贝。60fps 的一帧预算是 16.7ms这一下就吃掉 34%——而这还只是记录历史没算重绘。拖拽掉帧的锅很多时候在撤销栈上。第二组数据结构共享的交叉点在 29 个图层结构共享常被当作快照法的优化版一笔带过但它的内存模型和全量快照不同每步只多分配一个 layers 数组N 个指针和一个新图层对象。所以它是 O(N) 但常数极小。而命令模式是 O(1)——与状态规模完全无关。两条线必然相交。把图层数从 4 扫到 64图层数commandstructural4226 B44 B8226 B73 B16226 B130 B24226 B187 B28226 B216 B30226 B230 B64226 B474 B交叉点落在29 个图层附近。图2命令模式常数 226 B/步与结构共享随图层数线性增长的交叉点在 29 个图层附近低于此规模结构共享反而更省。这个数字本身不重要——它取决于图层对象大小和命令对象字段数。重要的是交叉点存在而且位置很低。一个表单构建器、一个配色方案编辑器、一个只有十几个节点的流程图状态规模都在交叉点以下。在这类工具里选命令模式付出了每个动作写一个类的工程成本换来的是更差的内存表现。顺带测了另一个方向的代价——从当前位置跳回 200 步之前图层数命令逐条回放结构共享取索引5000.0068 ms0.000055 ms50000.0040 ms0.000020 ms结构共享快 100 倍以上因为它是数组下标寻址而命令模式必须逐条undo()回放。但绝对值都在微秒级200 步深度下这个差距对用户不可感知不构成选型依据。诚实地说这一项是平局。第三组数据一个 describe() 让内存涨了 372 倍现在回到开头那个反例。真实的拖拽操作会产生一次性的中间数据——指针轨迹采样、事件对象、临时路径点。测试里模拟为每步生成 2000 个采样点约 94KB这份数据用完就该被回收。三个变体唯一区别是命令对象上多了什么方法// 变体 Alean —— do/undo 只引用 before/after/layer const cmd { label: 移动图层, do() { layer.x after; }, undo() { layer.x before; }, }; // 变体 Bdescribe —— 多一个方法只读了 sample.points.length const cmd { label: 移动图层, do() { layer.x after; }, undo() { layer.x before; }, describe() { return 拖拽采样 sample.points.length 点; }, }; // 变体 Cplain —— 纯数据命令采样点数提前算成标量 h.push({ id, before, after, pointCount: sample.points.length });200 步后的实测结果变体撤销栈保留每步相对 leanAlean51 KB259 B1×Bdescribe18,832 KB96,418 B372×Cplain14 KB74 B0.29×变体 A 证明了 V8 做得不错sample虽然在作用域里但没有任何闭包引用它所以它没进 Context正常回收。变体 B 只多了一个方法而且那个方法只读了.length这一个数字。但sample因此被写进 Contextdo()和undo()作为兄弟闭包共享同一个 Context——于是整份 94KB 的采样点被撤销栈钉住 200 份涨到 18.4 MB。对照第一组数据18.4 MB 已经接近 500 图层全量快照的 24.3 MB。号称省内存的方案因为一行 UI 提示文案退化到了它要取代的那个方案的量级。图3同一作用域内的闭包共享一个 Context 对象。describe() 引用 sample 后sample 被写入 Contextdo()/undo() 也随之持有它整份采样数据被撤销栈长期滞留。变体 C 是解法命令只存标量执行逻辑交给一个集中式执行器按type分发。没有闭包就没有 Context命令对象的大小回到它字面上声明的那几个字段。局限这次实测没有覆盖什么测量方法是近似的。heapUsed差值不等于精确的 retained size小规模数值有几 KB 级噪声这也是为什么交叉点只给29 附近而非精确值。要定位真实项目的滞留链还是得用 heap snapshot 看 retainer 路径。只测了单引擎单场景。Node v22.22.2 / Windows单一编辑动作、单一状态形状。Context 分配策略是 V8 实现细节不同版本、不同引擎JSC、SpiderMonkey结果可能不同。没有测合并coalescing和持久化。真实撤销栈会把连续同类操作合并这会同时降低所有方案的步数跨会话持久化则是快照系的主场命令模式需要额外的序列化设计。结构共享用的是朴素实现。只做了一层数组 slice没上 Immer / 持久化数据结构。真正的 HAMT/RRB-Tree 在大 N 下会比线性 slice 好得多交叉点会右移。闭包不是原罪。变体 A 说明只要作用域干净闭包命令的 226 B/步完全可用。问题出在作用域里混了大对象而这在拖拽、粘贴、导入这类动作里非常常见。结论与下一步一句话方法论撤销栈的选型不看命令 vs 快照看状态规模在交叉点哪一侧和作用域里有没有大对象。落成可执行的清单状态小于约 30 个实体表单、配色器、小型流程图→ 用结构共享快照。实现比命令模式简单一个数量级内存还更低回溯是 O(1)。状态大或持续增长画布、文档、低代码编排→ 用命令模式但写成纯数据命令 集中式执行器不要do()/undo()闭包。这次实测里纯数据命令是 74–77 B/步是闭包写法的 1/3。必须用闭包命令时隔离作用域。把命令构造放进只接收标量参数的独立函数别让它和临时大对象共享作用域。把撤销栈的内存写进测试。加一条200 步后堆增长不超过 X的断言比任何 code review 都可靠——describe()这类改动在 diff 里看起来完全无害。第 4 条是我这次最大的收获这个 372 倍的退化不会有任何 lint 规则报警不会让功能出错只会让用户在编辑半小时后觉得这网页越用越卡。本文的四个基准脚本history-mem.js/history-extra.js/history-capture.js都是零依赖单文件node --expose-gc直接跑欢迎在自己的状态形状上复测——交叉点会挪但结论的形状不会变。开源地址矩阵门户GitHub - wangzifan396-wzf/WB: 一个标签页收纳你全部的开发者工具。24 个单文件、零依赖、本地优先的开源工具下载即用。 · GitHub单文件工具聚合器GitHub - wangzifan396-wzf/nano-workbench: Single-file tabbed launcher for the nano-tools matrix - one tab, all 28 tools, instant switch. Zero-dep. Part of nano-tools. · GitHubGitHub 组织主页wangzifan396-wzf (WangZi) · GitHub