解锁时光机:用 React Hooks 轻松实现 Undo/Redo 功能
在日常应用开发中,撤销(Undo)和重做(Redo)功能几乎是用户体验的标配。它让用户可以大胆尝试,无需担心犯错。但你是否曾觉得实现这个功能很复杂?本文将带你深入理解一个优雅而强大的设计模式,并结合 React useReducer
,手把手教你如何用最简洁的代码实现一个完整的带“时光机”功能的计数器。
思路核心:从“操作”到“状态快照”
大多数人在初次尝试实现 Undo/Redo 时,会陷入一个误区:记录操作本身。例如,我们记录下用户做了“增加”或“减少”操作。当需要撤销时,我们再根据记录反向计算出上一个状态。
这种方法看似合理,但当操作类型变得复杂时,逻辑会迅速膨胀,难以维护。
而更优雅的解决方案是:记录状态的快照。我们不关心用户做了什么,只关心每个操作发生前,状态是什么样子。这就像为每一个重要的时刻拍张照片,需要撤销时,我们直接回到上一张照片。
我们的数据模型将由三个部分组成:
present
:当前的状态值。past
:一个数组,存储所有历史状态的快照。future
:一个数组,存储所有被撤销的状态,以便重做。
接下来,我们将基于这个思路,一步步构建我们的 React 应用。
实现详解:用 useReducer
驱动状态流转
useReducer
是一个强大的 Hook,特别适合管理复杂状态和状态间的转换。我们的“时光机”逻辑将全部封装在 reducer
函数中。
1. 初始化状态
首先,我们定义初始状态。计数器从 0
开始,past
和 future
数组都是空的。
const initialState = {
past: [],
present: 0,
future: []
};
2. 处理正常操作 (increment
和 decrement
)
当用户点击“增加”或“减少”按钮时,我们的 reducer
需要做两件事:
- 将当前的
present
值,作为“历史快照”,添加到past
数组的末尾。 - 更新
present
的新值。 - 最关键的一步:清空
future
数组。因为任何新的操作都意味着所有“重做”的历史都失效了。
if (action.type === "increment") {
return {
past: [...past, present], // 存储当前值到历史
present: present + 1, // 更新为新值
future: [] // 新操作清空未来
};
}
if (action.type === "decrement") {
return {
past: [...past, present],
present: present - 1,
future: []
};
}
past: [...past, present]
这一行是整个设计的核心。我们存的不是“操作”,而是“操作前的状态值”。
3. 处理撤销操作 (undo
)
撤销是“时光机”的核心功能。当用户点击“撤销”时:
- 将当前的
present
值,移动到future
数组的开头。这是为了以后能够“重做”这个状态。 - 从
past
数组中取出最后一个元素(也就是上一个状态),并将其设置为新的present
值。我们可以使用past.slice(0, -1)
来得到新的past
数组,并用past.at(-1)
获取最后一个元素。
if (action.type === "undo") {
return {
past: past.slice(0, -1), // 移除最后一个历史状态
present: past.at(-1), // 上一个状态成为当前状态
future: [present, ...future] // 将当前状态存入未来
};
}
4. 处理重做操作 (redo
)
重做是撤销的逆过程。当用户点击“重做”时:
- 将当前的
present
值,添加到past
数组的末尾。 - 将
future
数组的第一个元素(即下一个状态)取出,并将其设置为新的present
值。 - 移除
future
数组的第一个元素。
if (action.type === "redo") {
return {
past: [...past, present], // 当前状态存入历史
present: future[0], // 下一个未来状态成为当前状态
future: future.slice(1) // 移除已重做的未来状态
};
}
完整的 React 组件代码
结合上述 reducer
逻辑,我们可以轻松构建出完整的 CounterWithUndoRedo
组件。
import * as React from "react";
const initialState = {
past: [],
present: 0,
future: []
};
function reducer(state, action) {
const { past, present, future } = state;
if (action.type === "increment") {
return {
past: [...past, present],
present: present + 1,
future: []
};
}
if (action.type === "decrement") {
return {
past: [...past, present],
present: present - 1,
future: []
};
}
if (action.type === "undo") {
// 如果没有历史记录,则不执行
if (!past.length) {
return state;
}
return {
past: past.slice(0, -1),
present: past.at(-1),
future: [present, ...future]
};
}
if (action.type === "redo") {
// 如果没有未来记录,则不执行
if (!future.length) {
return state;
}
return {
past: [...past, present],
present: future[0],
future: future.slice(1)
};
}
throw new Error("This action type isn't supported.")
}
export default function CounterWithUndoRedo() {
const [state, dispatch] = React.useReducer(reducer, initialState);
const handleIncrement = () => dispatch({ type: "increment" });
const handleDecrement = () => dispatch({ type: "decrement" });
const handleUndo = () => dispatch({ type: "undo" });
const handleRedo = () => dispatch({ type: "redo" });
return (
<div>
<h1>Counter: {state.present}</h1>
<button className="link" onClick={handleIncrement}>
Increment
</button>
<button className="link" onClick={handleDecrement}>
Decrement
</button>
<button
className="link"
onClick={handleUndo}
disabled={!state.past.length} // 禁用条件:past为空
>
Undo
</button>
<button
className="link"
onClick={handleRedo}
disabled={!state.future.length} // 禁用条件:future为空
>
Redo
</button>
</div>
);
}
通过这种 “状态快照” 的思维方式,我们成功地将 Undo/Redo 逻辑与具体操作类型解耦。这不仅让代码变得简洁明了,更重要的是,它为未来的功能扩展奠定了坚实的基础。当你的应用变得更加复杂时,你无需修改核心的 undo
和 redo
逻辑,只需在处理新操作时,记得保存好状态快照即可。