撤销/重做与命令模式
通过命令栈可逆编辑 — 撤销弹出并反转,重做再应用。
UX 模式高级
为什么
无撤销的破坏性动作(删除、移动)迫使确认对话框,用户反射性关闭。动作后撤销更快(无对话框)且更安全(可恢复)— Gmail 模式。
Nielsen Norman Group 研究显示用户在离开前平均停留 10-15 秒。清晰的视觉层次和渐进式披露将任务完成率提高 40-60%。
何时使用
用于文档编辑器(文本、画布、表单构建器)、列表重排、批量操作、及任何确认对话框会拖慢流的破坏性动作。跳过一次性不可逆操作(支付、发邮件)。
如何做
1. 命令对象 — 每动作 { do(): void, undo(): void };do 应用、undo 反转
2. 栈 — do 后 push 命令;undo() 弹出并调 command.undo();redo() 再 push
3. 合并 — 合并连续相同命令(5 字符按键 → 1 '输入 "hello"' 命令)
4. 内存上限 — 留末 50 命令;旧编辑不可恢复(避免无界内存)
5. 快捷键 — Cmd/Ctrl+Z 撤销、Cmd+Shift+Z 重做;全局绑定非每输入
6. 快照替代 — 复杂状态存不可变快照而非 do/undo(更简、更耗内存)
7. 标签 — toast 或菜单项显将撤销何("撤销删除 3 项")
示例
interface Command { do(): void; undo(): void; }
class History {
private stack: Command[] = [];
private redoStack: Command[] = [];
push(cmd: Command) { this.stack.push(cmd); this.redoStack = []; } // 新动作清重做
undo() { const c = this.stack.pop(); if (c) { c.undo(); this.redoStack.push(c); } }
redo() { const c = this.redoStack.pop(); if (c) { c.do(); this.stack.push(c); } }
}
// 删除命令 — do 删、undo 插回
function deleteCmd(list: T[], idx: number): Command {
const removed = list[idx];
return {
do: () => { list.splice(idx, 1); },
undo: () => { list.splice(idx, 0, removed); },
};
}
// 用法
const history = new History();
const cmd = deleteCmd(items, 2);
cmd.do(); history.push(cmd); // 删项 2
history.undo(); // 回来了
history.redo(); // 又删了
// 键盘
useHotkey("mod+z", () => history.undo());
useHotkey("mod+shift+z", () => history.redo());
常见问题
- ⚠新动作不清重做栈 — 新编辑后重做应用陈旧命令
- ⚠无界栈 — 画布应用 1000 编辑,内存涨至崩
- ⚠命令直接变共享状态 — 中间有其他编辑时撤销反转错状态
最佳实践
- ✓每新动作清重做栈 — 重做仅在撤销后立即有意义
- ✓栈上限(50–100 命令)— 旧编辑不可恢复,内存有界
- ✓显将撤销何 — toast/菜单 '撤销删除 3 项',用户更信