大家好,欢迎来到“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(当前指针)。
每次操作:
- 我们计算出一个新的状态树。
- 我们把“新状态树的引用”存入栈顶。
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 个版本。
第五讲:复杂工作流的编排与依赖
现在,我们进入了最棘手的阶段:复杂工作流。
假设你在做一个视频编辑器的工作流:
- 创建项目。
- 导入素材 A。
- 导入素材 B。
- 在素材 A 上加滤镜。
- 在素材 B 上加转场。
- 合成导出。
如果我们要撤销“加滤镜”,我们只需要撤销素材 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 时:
- 检查当前节点是否有子节点。
- 如果有子节点(比如你正在“合成导出”界面),你必须先丢弃子节点(或者询问用户是否放弃未保存的子事务),然后回退到父节点。
这就好比你在一个多级目录里删除文件。你不能直接跳到根目录删除,你必须一级一级地回退。
代码实现逻辑:
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 表单,或者是一个复杂的编辑器。用户可能会刷新页面。
这时候,我们需要把那个栈“画”到磁盘上。
实现难点:
- 序列化: 把 JS 对象变成 JSON 字符串。
- 反序列化: 把 JSON 字符串变回 JS 对象。
- 不可变性: React 需要不可变对象来触发渲染。
方案 A:全量序列化。
直接 JSON.stringify(historyStack),存到 localStorage。加载时 JSON.parse。
缺点: 对于复杂对象(如包含 Date、RegExp、循环引用、函数),这会报错或丢失数据。而且,序列化大对象会导致页面卡顿。
方案 B:只存当前状态。
只存 currentPointer 和 currentState。历史记录只在内存里。
缺点: 用户刷新后,只能回到最后一步,无法回退。
方案 C:IndexedDB + 增量存储。
利用浏览器的 IndexedDB 存储大量数据。每个动作(Action)作为一个单独的条目存储,包含 timestamp 和 payload。
优点: 数据安全,支持事务,不阻塞主线程。
缺点: 代码量巨大,需要编写大量 loader 和 saver 逻辑。
工程建议:
对于一般的 Web 应用,方案 A 足够用了,只要加上深拷贝函数的健壮性检查。
对于专业工具,使用 Immer 的 snapshot 功能或者专门的序列化库(如 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 的时候也能轻松回退。下课!