你的撤销按钮只是一堆煎饼
Source: Dev.to
TL;DR: 我使用栈(Stack)数据结构实现撤销功能,因为它遵循后进先出(LIFO)原则。每一次状态变化都会压入栈中。当我触发撤销时,弹出最近的操作,以 O(1) 的时间恢复应用状态,而无需重新索引整个数组的开销。
为什么撤销功能总是使用栈?
我使用栈是因为它强制保持严格的线性历史,唯一可访问的元素是最近的那个。这确保每一次撤销调用都恰好逆转最后一次执行的操作,防止状态损坏并将指针开销保持在最低。
在构建需要跟踪状态的应用时,遍历巨大的数组或为每个输入的字符移动索引是低效的。栈让我可以把状态快照放在顶部,并同样快速地取回。对栈顶进行压入和弹出是常数时间操作,符合我们在事件序列中回溯的自然方式。
煎饼类比是如何解释 LIFO 的?
LIFO(后进先出)意味着我放在盘子最上面的那块煎饼是我最先吃的。在软件中,我压入历史栈的最后一个操作是用户点击 撤销 时最先弹出的。
如果我尝试把新煎饼塞到堆底部,就会出现 O(n) 的操作并导致早餐“碎掉”——就像会产生索引移动的糟糕架构。相反,我把新操作压在顶部。需要撤销时,我取走顶部的煎饼;从底部取出会导致索引移动的噩梦和状态崩溃。
栈 vs. 数组:为何历史记录更适合栈的效率
| 功能 | 栈(LIFO) | 标准数组 |
|---|---|---|
| 插入复杂度 | O(1) – 常数时间 push | O(n) – 若需移到索引 0 时 |
| 删除复杂度 | O(1) – 常数时间 pop | O(n) – 若需重新索引历史 |
| 索引管理 | 无需(只需顶指针) | 高(需要跟踪移动的索引) |
| 实现工作量 | 最小(原生 push/pop) | 较大(手动 slice/splice) |
如何在真实的状态管理循环中实现?
// Simple undo stack implementation
const history = [];
const trackChange = (state) => {
history.push(state);
};
const handleUndo = () => {
if (history.length < 2) return; // Need at least one previous state
history.pop(); // Remove current state
const prevState = history[history.length - 1];
applyToUI(prevState);
};
如何管理庞大栈的内存占用?
我通过对栈深度设定硬限制(例如 100 条操作)来防止撤销栈无限增长。当超过限制时,我会移除最旧的条目(底部元素)或将栈视为循环缓冲区。这种权衡会丢弃非常久远的历史,但能防止应用耗尽内存。
FAQ
为什么不使用队列来实现撤销?
队列遵循 FIFO(先进先出)逻辑。使用队列进行撤销会把应用打开时的 第一个 操作撤回,而不是最近的错误——这会导致糟糕的用户体验。
“重做”按钮如何与栈配合工作?
我维护第二个栈用于 Redo。当我从撤销栈弹出一个状态时,会把它压入重做栈。如果用户进行新的更改,重做栈会被清空。这相当于在两盘煎饼之间来回移动。
能否存储差异(diff)而不是完整的状态快照?
可以。对于大型应用,仅存储 diff(具体的命令或变化)而不是完整快照可以降低 RAM 使用,同时仍保持 O(1) 的栈操作。这使得每块“煎饼”的内存占用保持在较小水平。