TL;DR
- 三行初始化代码接入 Undo/Redo,业务逻辑一行不用改 :
withHistory(this)在 Store 外层挂载历史能力,所有已有的 action 原封不动。 - 这是 opt-out 设计,不是 opt-in:默认记录所有状态变更;只在你明确不需要的地方(如 UI state)声明排除。
- 合并粒度必须可控:打字/拖拽是高频更新,不做合并就是糟糕体验。要么 debounce 自动合并,要么在交互边界显式合并。
- 大 state 优先选 Patch 思路:基于 Immer Patches 记录差异,内存占用远小于全量快照;快照更适合小 state + 低频更新。
- 在 Zenith 里,Undo/Redo 是按需启用的能力:
withHistory(this)挂上去,然后直接调用store.undo()/store.redo()。
这篇文章用两个最小 Todo 示例把"Baseline → 接入 Undo/Redo"的差异讲清楚:改了什么、没改什么、为什么业务代码不需要动。
背景 / 问题定义
在 React 里做 Undo/Redo,常见路径大致分两类:
- Snapshot(快照栈) :维护
past / present / future,每次更新把present推进past。 - Patch(补丁栈):记录"这次变更的差异",undo 时应用 inverse patches。
快照栈实现直观,也确实能跑起来;但它只要撞上下面这些真实场景,就会迅速变得笨重:
- state 很大(例如 1MB 文档、上千节点画布),历史记录很快就会把内存吃满
- 高频更新(输入、拖拽)会产生"颗粒度过细"的 undo 单元,体验差
- UI state 混进来后,撤销会变得"看起来像 bug"(撤销把弹窗关了/把 hover 取消了)
下面用两个最小示例把问题讲清楚:先做一个干净的 Todo(不带历史),再用一行中间件把 Undo/Redo 接进来。
示例 1:一个不带历史的最小 Todo(Baseline)
目标:先把"业务状态、派生状态、行为"收敛到一个清晰的 Model 里,UI 只负责触发意图。
在线示例 :Zenith Todo (baseline) --- CodeSandbox 打开后可以直接运行、修改代码,实时看到效果。
为什么很多 Undo/Redo 最终会变"脏"
给 Todo 加撤销重做,你很快会遇到三个工程问题(而不是算法问题):
- 存什么? 如果把所有 state 都存进去,你会把 UI state 也记录下来;如果只存一部分,你需要维护额外的拆分/同步逻辑。
- 怎么合并? 连续输入、连续拖拽,到底算一次 undo 还是 100 次?
- 怎么控内存? 快照实现下,历史长度 × state 大小,是一个非常直觉的上限。
优雅的 Undo/Redo 从来不是"再写一套更复杂的 history 容器",而是把工程约束做对:该记的记,不该记的别记;该合并的合并,不能合并的别合并。
示例 2:用 withHistory 中间件接入 Undo/Redo
Zenith 的 History 中间件 withHistory 是基于 Immer Patches 的实现,它把 Undo/Redo 里最难做漂亮的两件事,直接变成了默认能力:
- 低内存的历史记录:记录差异而不是整份快照(尤其适合文档/画布/编辑器这类大 state)。
- 更接近用户直觉的合并策略:提供 debounce 合并;也允许显式控制"把一段操作合成一个 undo 单元"。
最小接入点(核心 3 个点):
- 在 Store 构造时打开 patches:
super(initialState, { enablePatch: true }) - 调用
withHistory(this, options)拿到undo/redo - 在 UI 上绑定两个按钮(或快捷键)
最小可复制代码如下(用法形态来自 Zenith 文档,字段名按你的 Todo 调整即可):
ts
import { ZenithStore } from "@do-md/zenith";
import { withHistory } from "@do-md/zenith/middleware";
type State = {
todos: { id: number; text: string; completed: boolean }[];
// ui: { ... } // 建议把 UI state 单独分层,并默认不记录进 history
};
class TodoStore extends ZenithStore<State> {
undo!: () => void;
redo!: () => void;
constructor() {
super({ todos: [] }, { enablePatch: true });
const history = withHistory(this, {
maxLength: 50,
debounceTime: 200,
});
this.undo = history.undo;
this.redo = history.redo;
}
addTodo(text: string) {
this.produce((state) => {
state.todos.push({ id: Date.now(), text, completed: false });
});
}
}
如果你需要"某些更新绝不进入历史栈"(典型就是 UI state),在 produce 的第二个参数里禁用记录即可:
ts
this.produce(
(state) => {
// state.ui.open = true
},
{ disableRecord: true }
);
在线示例 :Zenith Todo (withHistory) --- CodeSandbox 打开后可以直接试 Undo/Redo,也可以改代码看效果。
业务逻辑零侵入
回头看一下 addTodo、toggleTodo、deleteTodo 这些业务 action------接入 withHistory 前后,它们一行都没改。
这不是巧合,而是设计意图:历史记录是基础设施,不是业务逻辑的一部分。
很多 Undo/Redo 方案做着做着就会把 history 的概念渗透到业务层:action 里要手动调 saveSnapshot(),或者要把每次操作包在 recordHistory(() => { ... }) 里。一旦走上这条路,业务代码就开始为"历史记录"服务------忘记包一层就没有 undo,包错了位置就出现脏快照。
withHistory 的做法是反过来的:
- 默认记录所有
produce调用,业务 action 不需要知道 history 的存在 - 只在需要"排除"的地方声明一次
{ disableRecord: true }(典型场景:UI state 更新) - 合并策略由中间件统一管理(debounce、maxLength),业务侧不参与
换句话说,这是一个 opt-out 而非 opt-in 的设计。业务代码的默认路径是"什么都不用管,history 自动工作";只有当你明确知道"这个操作不该进历史栈"时,才需要加一个选项。
这带来一个实际好处:你可以先写完所有业务逻辑,最后再决定要不要接入 Undo/Redo。接入的时候不需要回去改已有的 action,也不需要重新测试业务流程------因为业务层根本不知道 history 的存在。
常见坑与排查
- 忘记开启
enablePatch: true- 表现:
withHistory接上了,但 undo/redo 没效果或报错 - 处理:确认
super(..., { enablePatch: true })
- 表现:
- 把 UI state 也记录进了历史
- 表现:撤销时弹窗/选中态/hover 等跟着回滚,用户会觉得"撤销把界面弄乱了"
- 处理:对 UI state 的更新使用
produce(..., { disableRecord: true })(History 中间件会增强produce支持此选项)
- debounceTime 不合适
- 表现:打字时 undo 太碎(debounceTime 太小)或撤销跨度太大(debounceTime 太大)
- 处理:按交互类型设置:打字 200-400ms、拖拽 50-100ms、表单填写 300-800ms(按体验调)
- maxLength 不设上限
- 表现:长时间使用后内存持续增长
- 处理:总是设
maxLength,并按数据量/目标设备做压测
结尾:行动项
- 先把 state 结构分层:Domain State / UI State(后者默认不进 undo 栈)
- 再决定 undo 单元:哪些操作应该合并、哪些必须独立
- 最后才选实现:如果你需要不可变语义 + 大 state + 高性能历史记录,可以考虑基于 patches 的实现
如果你的产品形态就是"编辑器 / 画布 / 复杂交互表单",那么 Undo/Redo 不是锦上添花,而是基础设施。你可以自己实现一套,但更务实的做法是直接复用成熟的 patch-based history。Zenith 的 withHistory 就是其中一个实现:它把接入成本压到最低,同时把合并与内存问题放在了默认路径上。