三行代码,让你的 React 项目优雅地支持 Undo/Redo

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 加撤销重做,你很快会遇到三个工程问题(而不是算法问题):

  1. 存什么? 如果把所有 state 都存进去,你会把 UI state 也记录下来;如果只存一部分,你需要维护额外的拆分/同步逻辑。
  2. 怎么合并? 连续输入、连续拖拽,到底算一次 undo 还是 100 次?
  3. 怎么控内存? 快照实现下,历史长度 × state 大小,是一个非常直觉的上限。

优雅的 Undo/Redo 从来不是"再写一套更复杂的 history 容器",而是把工程约束做对:该记的记,不该记的别记;该合并的合并,不能合并的别合并。

示例 2:用 withHistory 中间件接入 Undo/Redo

Zenith 的 History 中间件 withHistory 是基于 Immer Patches 的实现,它把 Undo/Redo 里最难做漂亮的两件事,直接变成了默认能力:

  • 低内存的历史记录:记录差异而不是整份快照(尤其适合文档/画布/编辑器这类大 state)。
  • 更接近用户直觉的合并策略:提供 debounce 合并;也允许显式控制"把一段操作合成一个 undo 单元"。

最小接入点(核心 3 个点):

  1. 在 Store 构造时打开 patches:super(initialState, { enablePatch: true })
  2. 调用 withHistory(this, options) 拿到 undo/redo
  3. 在 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,也可以改代码看效果。

业务逻辑零侵入

回头看一下 addTodotoggleTododeleteTodo 这些业务 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 就是其中一个实现:它把接入成本压到最低,同时把合并与内存问题放在了默认路径上。

相关推荐
小金鱼Y2 小时前
从父子到跨层:JavaScript组件通信的 “全链路解决方案”
前端·react.js
Rsun0455111 小时前
React相关面试题
前端·react.js·前端框架
我命由我1234515 小时前
React - state、state 的简写方式、props、props 的简写方式、类式组件中的构造器与 props、函数式组件使用 props
前端·javascript·react.js·前端框架·html·html5·js
C澒15 小时前
React + TypeScript 编码规范|统一标准 & 高效维护
前端·react.js·typescript·团队开发·代码规范
@大迁世界17 小时前
精通 React 面试:从零到中高级
前端·javascript·react.js·面试·前端框架
无知的小菜鸡19 小时前
React 零散知识记录
前端·react.js·前端框架
我命由我1234520 小时前
React - React 初识、创建虚拟 DOM 的两种方式、jsx 语法规则、React 定义组件
前端·javascript·react.js·前端框架·html·html5·js
白兰地空瓶21 小时前
手写 Mini React:从 0 实现 createElement 和 render,理解 React 的底层原理
react.js
我命由我123451 天前
前端开发 - this 指向问题(直接调用函数、对象方法、类方法)
开发语言·前端·javascript·vue.js·react.js·html5·js