React 在复杂工作流编排中的 Undo/Redo 撤销恢复栈物理实现

大家好,欢迎来到“React 深度解析”系列讲座的第二场。我是你们的主讲人,一个在 React 混了十年的“资深”……码农。

今天我们要聊的东西,有点意思。甚至可以说,有点“致郁”。

在座的各位,有没有在赶工期的时候,手一抖,把原本要提交的 PR(Pull Request)给删了?或者在 Figma 里画着画着,突然发现刚才那个滤镜加错了,想把画布瞬间拉回到五分钟前?

那一刻,人类的本能是——愤怒,然后是绝望。

在 React 的世界里,我们习惯了“声明式编程”。我们说 onClick={() => setColor('red')},React 就会把颜色变成红色。这很美好,对吧?但这有个致命的缺陷:我们无法轻易地“回退”这种美好的承诺。

如果说 React 的状态是一个单线程的时间流,那么 Undo/Redo(撤销/恢复)就是我们手握的一把时间倒流的枪。而要实现这把枪的物理结构,我们就得聊聊“栈”。

今天,我们不聊 API,也不聊 Hooks 的那些花里胡哨,我们要从底层的物理实现角度,聊聊如何在复杂工作流中,构建一个坚不可摧的 Undo/Redo 栈。

准备好了吗?让我们开始这场关于“后悔药”的工程学探讨。


第一讲:为什么 React 的 useState 是个“渣男”?

首先,我们要解决一个认知误区。

很多初级 React 开发者会想:“既然我用了 useState,那我把所有历史状态都存下来不就行了?”

比如:

const [history, setHistory] = useState([initialState]);
const [currentStep, setCurrentStep] = useState(0);

const handleClick = () => {
  const newStep = currentStep + 1;
  setHistory(prev => [...prev, newState]); // 问题出在这里
  setCurrentStep(newStep);
}

这看起来很美好,对吧?一个数组,一条直线。

但是,注意那个 newState。在 React 中,这个 newState 是从哪来的?它是从上一步计算出来的。如果我们在上一步把数据改了,newState 就是修改后的数据。

这里有个物理陷阱:内存引用。

想象一下,你手里有一个铁球。如果你把铁球往前推一步,原来的位置就空了。如果你想把整个铁球队列都往前挪,你需要复制每一个铁球。

在 React 中,如果你不深拷贝,history[0]history[1] 指向的是同一个内存地址。当你修改 history[1] 里的某个属性时,history[0] 也会跟着变。这叫“内存污染”。

这时候,如果你想 Undo(撤销),你只需要把指针往前移。但如果指针指向的那个内存块已经被修改了,那么当你展示 history[0] 时,你看到的已经是被篡改后的 history[1] 的尸体。

所以,React 的 useState 在这里扮演了一个“渣男”的角色:它承诺保存状态,但实际上它只是保存了引用。除非你每一步都给它生个“克隆”,否则它给你的永远是同一个灵魂。

结论: 我们需要一种机制,能像复印机一样,在每一步动作发生时,完整地复制一份当前的状态快照。


第二讲:深拷贝的魔法与诅咒

让我们来看看那个“魔法咒语”——JSON.parse(JSON.stringify(state))

这行代码几乎是前端 Undo/Redo 的万金油。它能干嘛?它能把你那个乱七八糟的对象树,变成一个全新的、独立的副本。

const doSomething = (currentState) => {
  // 深拷贝,把当前状态切成两半
  const prevState = JSON.parse(JSON.stringify(currentState));

  // 修改新状态
  const nextState = { ...prevState, data: 'new data' };

  return { prevState, nextState };
};

这很好用,非常直观。如果你把所有历史都存进一个数组 historyStack,Undo 就是一个 historyStack[currentStep - 1]

但是,物理实现上,这简直就是灾难。

为什么?因为内存。假设你的工作流是一个 Photoshop 文档。一张图片可能有 50MB。你每操作一次,都要 JSON.parse 一次,复制 50MB。操作了 100 次?你的浏览器内存瞬间爆表,卡死是小事,浏览器直接闪退是常态。

在“复杂工作流”中,数据通常极其庞大,且结构深不可测。深拷贝不是银弹,它只是把内存问题推迟到了 GC(垃圾回收)触发的那一秒。

所以,我们要换一种思路。我们能不能不复制“数据”,而是复制“动作”?


第三讲:命令模式——给未来写情书

在软件工程中,有一种古老而优雅的设计模式,叫“命令模式”。

它的核心思想是:不要直接修改状态,而是记录下“为了从状态A变成状态B,我执行了什么动作”。

如果是 Undo/Redo,这就好办了。撤销就是执行“逆向动作”,恢复就是再次执行“正向动作”。

让我们来看看怎么用 React 实现这个逻辑。我们不再存“状态”,我们存“动作”。

// 1. 定义动作基类
class Action {
  constructor(name) {
    this.name = name;
  }
  execute(state) { /* 覆盖它 */ }
  undo(state) { /* 覆盖它 */ }
}

// 2. 具体的动作
class AddItemAction extends Action {
  constructor(item) {
    super('Add Item');
    this.item = item;
  }

  execute(state) {
    return [...state, this.item];
  }

  undo(state) {
    return state.slice(0, -1);
  }
}

class DeleteItemAction extends Action {
  constructor(index) {
    super('Delete Item');
    this.index = index;
  }

  execute(state) {
    return state.filter((_, i) => i !== this.index);
  }

  undo(state) {
    return [...state.slice(0, this.index), state[this.index], ...state.slice(this.index + 1)];
  }
}

这看起来比深拷贝优雅多了,对吧?逻辑清晰,数据量小。你只需要存一个 Action 数组。

但是! 这只适用于简单的状态。一旦你的工作流涉及复杂的业务逻辑,比如“导入 Excel -> 解析 -> 验证 -> 更新 UI -> 发送通知 -> 写日志”,单一的 Undo/Redo 就会变得像一团乱麻。

如果你撤销了“导入 Excel”,你还要撤销“发送通知”吗?通常情况下不需要。这时的“动作”粒度太粗了。

所以,我们面临一个更高级的问题:混合架构。 我们需要深拷贝来保存 UI 层的状态快照(为了展示给用户看),用命令模式来处理逻辑层的事务。或者,我们需要更智能的依赖图。


第四讲:指针的艺术与物理栈的实现

回到文章标题,“物理实现”。这听起来很土,但实际上非常硬核。

让我们抛弃 React 的 useState 这种高层封装,直接来操作底层的“栈”。

什么是栈?Stack 是一个 LIFO(Last In, First Out)的数据结构。就像一个餐盘堆。你只能拿最上面那个盘子。

在内存中,栈就是两个指针:sp(栈顶指针)和 base(栈底指针)。

对于 React 来说,我们的 Undo/Redo 栈不仅仅是数据,它是当前视图的根节点

高级实现思路:Snapshot Graph(快照图)

我们不存全量的 history 数组。我们只存 currentPointer(当前指针)。

每次操作:

  1. 我们计算出一个新的状态树。
  2. 我们把“新状态树的引用”存入栈顶。
  3. currentPointer++

如果我们用 React 的 Immutable 数据(比如 Immer),引用的消耗其实很低。因为 Immutable 数据在修改时,只会修改路径上那一条链,而不是整棵树。

// 这是一个极其简化的物理栈逻辑
const StackManager = {
  stack: [],
  pointer: -1,

  push(newState) {
    // 如果我们在栈中间操作了,比如指针指向了第 10 步,
    // 然后我们又走了一步。第 11 步之后的所有历史(11, 12...)都应该被丢弃。
    // 这就是“栈的物理限制”。
    this.stack = this.stack.slice(0, this.pointer + 1);

    this.stack.push(newState);
    this.pointer++;
  },

  undo() {
    if (this.pointer > 0) {
      this.pointer--;
      return this.stack[this.pointer];
    }
    return this.stack[0]; // 到头了
  },

  redo() {
    if (this.pointer < this.stack.length - 1) {
      this.pointer++;
      return this.stack[this.pointer];
    }
    return this.stack[this.pointer];
  }
};

这有什么玄机?

当你在第 10 步 undo 到第 9 步时,第 10 步及之后的数据还在内存里吗?在。

如果你一直在 undo,内存会无限增长。这就是为什么 Undo/Redo 栈必须有容量限制

这就好比你玩游戏,读档读到了 1 小时前,然后你开始疯狂地点击“存档”。你的内存占用会直线上升,直到游戏崩溃。

物理实现细节:
在生产环境中,我们通常会维护一个“固定大小”的环形缓冲区(Circular Buffer),或者每存 50 步就自动清理最早的记录。这就像一个自动清理垃圾桶,只保留最近的 50 个版本。


第五讲:复杂工作流的编排与依赖

现在,我们进入了最棘手的阶段:复杂工作流

假设你在做一个视频编辑器的工作流:

  1. 创建项目。
  2. 导入素材 A。
  3. 导入素材 B。
  4. 在素材 A 上加滤镜。
  5. 在素材 B 上加转场。
  6. 合成导出。

如果我们要撤销“加滤镜”,我们只需要撤销素材 A 的状态。但是,如果我们撤销了“合成导出”,我们要把素材 A、B、滤镜、转场全部复原吗?这看起来很傻,因为合成导出通常是一个只读的结果。

这里涉及到一个概念:Transaction(事务)

Undo/Redo 栈不应该只是平铺直叙的数组,它应该是一个树状结构

// 简化的树状结构
class TransactionNode {
  constructor(actions) {
    this.actions = actions; // 该节点包含的动作列表
    this.children = [];     // 该节点产生的后续节点
  }
}

const rootTransaction = new TransactionNode([createAction1, createAction2]);
const subTransaction = new TransactionNode([addFilter]);
rootTransaction.children.push(subTransaction);

当你执行 Undo 时:

  1. 检查当前节点是否有子节点。
  2. 如果有子节点(比如你正在“合成导出”界面),你必须先丢弃子节点(或者询问用户是否放弃未保存的子事务),然后回退到父节点。

这就好比你在一个多级目录里删除文件。你不能直接跳到根目录删除,你必须一级一级地回退。

代码实现逻辑:

const WorkflowStack = {
  currentRoot: null,
  currentPointer: null,

  // 假设我们正在进行一个复杂的操作
  commitAction(action) {
    // 1. 执行动作
    const newState = action.execute(this.currentState);

    // 2. 将动作存入当前指针的记录
    this.currentPointer.actions.push(action);

    // 3. 如果这是新的一步(不是 Undo 的延续),我们通常选择“重置”子树
    // 在复杂工作流中,一旦你开始新的一步,之前的嵌套状态可能就过时了
    // 除非你实现了复杂的“快照合并”

    this.currentState = newState;
  },

  undo() {
    if (this.currentPointer.actions.length > 0) {
      const action = this.currentPointer.actions.pop();
      // 执行逆操作
      this.currentState = action.undo(this.currentState);
      return true;
    }
    return false;
  }
};

第六讲:持久化——从浏览器内存到硬盘

物理栈不仅要放在内存里,有时候我们要把它“永久保存”。

想象一下,你在写一个极其复杂的 React 表单,或者是一个复杂的编辑器。用户可能会刷新页面。

这时候,我们需要把那个栈“画”到磁盘上。

实现难点:

  1. 序列化: 把 JS 对象变成 JSON 字符串。
  2. 反序列化: 把 JSON 字符串变回 JS 对象。
  3. 不可变性: React 需要不可变对象来触发渲染。

方案 A:全量序列化。
直接 JSON.stringify(historyStack),存到 localStorage。加载时 JSON.parse
缺点: 对于复杂对象(如包含 Date、RegExp、循环引用、函数),这会报错或丢失数据。而且,序列化大对象会导致页面卡顿。

方案 B:只存当前状态。
只存 currentPointercurrentState。历史记录只在内存里。
缺点: 用户刷新后,只能回到最后一步,无法回退。

方案 C:IndexedDB + 增量存储。
利用浏览器的 IndexedDB 存储大量数据。每个动作(Action)作为一个单独的条目存储,包含 timestamppayload
优点: 数据安全,支持事务,不阻塞主线程。
缺点: 代码量巨大,需要编写大量 loader 和 saver 逻辑。

工程建议:
对于一般的 Web 应用,方案 A 足够用了,只要加上深拷贝函数的健壮性检查。
对于专业工具,使用 Immersnapshot 功能或者专门的序列化库(如 flatted,支持循环引用)。

代码示例(简单的 localStorage 持久化):

import { produce } from 'immer';

const UndoRedoContext = React.createContext();

const UndoRedoProvider = ({ children }) => {
  const [history, setHistory] = useState([]);
  const [pointer, setPointer] = useState(-1);
  const [canUndo, setCanUndo] = useState(false);
  const [canRedo, setCanRedo] = useState(false);

  const dispatch = (action) => {
    // 1. 生成当前状态的快照(使用 Immer 的 produce 生成不可变副本)
    const currentState = history[pointer] || {};
    const nextState = produce(currentState, draft => {
      // 执行动作
      if (action.type === 'ADD_ITEM') {
        draft.items.push(action.payload);
      }
    });

    // 2. 如果我们在中间位置进行了新操作,丢弃后面的历史
    const newHistory = pointer >= 0 
      ? history.slice(0, pointer + 1) 
      : history;

    setHistory([...newHistory, nextState]);
    setPointer(pointer + 1);

    // 3. 持久化
    try {
      localStorage.setItem('app_history', JSON.stringify(history));
    } catch (e) {
      console.warn('History storage full');
    }
  };

  const undo = () => {
    if (pointer > 0) {
      setPointer(pointer - 1);
      // 恢复持久化数据(这里简化了,实际应该从 LS 读取最新的)
    }
  };

  // ... 其余逻辑
};

第七讲:性能优化与垃圾回收

最后,我们聊聊“物理实现”中最容易被忽视的部分——性能。

我们的栈可能会无限增长吗?在理想状态下,我们会限制它的深度。

const MAX_HISTORY = 50; // 50 步回退,对于人类操作来说已经够用了

// 在 dispatch 中:
if (history.length >= MAX_HISTORY) {
  history.shift(); // 删除最早的一个
}

另外,对于使用了 React.memo 的组件,或者使用了 useMemo 缓存的组件,每次历史栈更新(pointer 变化),React 都会重新渲染。

如果你的状态树很大(比如一个包含 1000 个节点的树),每次重渲染都会遍历这 1000 个节点进行比较。这是非常昂贵的。

优化技巧: 虚拟化历史栈。

你不需要把所有历史步骤都渲染出来。你只需要把“当前步骤”渲染给用户看。历史记录通常只显示为几个按钮(第 1 步,第 2 步…)。

React 提供了 <VirtualList /> 的概念。只有当用户点击“回到第 50 步”时,你才需要把那个巨大的状态树渲染出来。

这就好比一个图书馆,书架(UI)只有一排,但图书馆里(内存里)存了成千上万本书。


总结(当然,不是 AI 总结)

好了,朋友们,我们今天聊了很多。

从 React useState 的引用陷阱,到深拷贝的性能诅咒;
从命令模式的优雅逻辑,到指针指向的物理内存管理;
从树状的事务编排,到 IndexedDB 的持久化策略。

实现一个 Undo/Redo 栈,本质上是在内存中管理时间。

  • 简单应用:用深拷贝存快照,数组存历史。
  • 复杂应用:用命令模式(Action)记录操作,配合不可变数据结构(如 Immer)优化性能。
  • 生产环境:必须限制栈大小,处理内存泄漏,考虑持久化方案。

不要试图在 React 里手动操作 DOM 来实现撤销,那是 2010 年的做法。也不要以为 JSON.stringify 万能,它处理不了你的复杂的业务对象。

真正的物理实现,不是代码写得多复杂,而是当你按下 Ctrl+Z 时,那个过程快如闪电,且不会把你的浏览器带进坟墓。

祝大家的代码都能拥有“时光倒流”的能力,哪怕是在写 Bug 的时候也能轻松回退。下课!

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注