解锁时光机:用 React Hooks 轻松实现 Undo/Redo 功能

解锁时光机:用 React Hooks 轻松实现 Undo/Redo 功能

在日常应用开发中,撤销(Undo)和重做(Redo)功能几乎是用户体验的标配。它让用户可以大胆尝试,无需担心犯错。但你是否曾觉得实现这个功能很复杂?本文将带你深入理解一个优雅而强大的设计模式,并结合 React useReducer,手把手教你如何用最简洁的代码实现一个完整的带"时光机"功能的计数器。


思路核心:从"操作"到"状态快照"

大多数人在初次尝试实现 Undo/Redo 时,会陷入一个误区:记录操作本身。例如,我们记录下用户做了"增加"或"减少"操作。当需要撤销时,我们再根据记录反向计算出上一个状态。

这种方法看似合理,但当操作类型变得复杂时,逻辑会迅速膨胀,难以维护。

而更优雅的解决方案是:记录状态的快照。我们不关心用户做了什么,只关心每个操作发生前,状态是什么样子。这就像为每一个重要的时刻拍张照片,需要撤销时,我们直接回到上一张照片。

我们的数据模型将由三个部分组成:

  • present:当前的状态值。
  • past:一个数组,存储所有历史状态的快照。
  • future:一个数组,存储所有被撤销的状态,以便重做。

接下来,我们将基于这个思路,一步步构建我们的 React 应用。


实现详解:用 useReducer 驱动状态流转

useReducer 是一个强大的 Hook,特别适合管理复杂状态和状态间的转换。我们的"时光机"逻辑将全部封装在 reducer 函数中。

1. 初始化状态

首先,我们定义初始状态。计数器从 0 开始,pastfuture 数组都是空的。

JavaScript 复制代码
const initialState = {
  past: [],
  present: 0,
  future: []
};

2. 处理正常操作 (incrementdecrement)

当用户点击"增加"或"减少"按钮时,我们的 reducer 需要做两件事:

  1. 当前present 值,作为"历史快照",添加到 past 数组的末尾。
  2. 更新 present 的新值。
  3. 最关键的一步 :清空 future 数组。因为任何新的操作都意味着所有"重做"的历史都失效了。
JavaScript 复制代码
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)

撤销是"时光机"的核心功能。当用户点击"撤销"时:

  1. 当前的 present 值,移动到 future 数组的开头。这是为了以后能够"重做"这个状态。
  2. past 数组中取出最后一个元素(也就是上一个状态),并将其设置为新的 present 值。我们可以使用 past.slice(0, -1) 来得到新的 past 数组,并用 past.at(-1) 获取最后一个元素。
JavaScript 复制代码
if (action.type === "undo") {
  return {
    past: past.slice(0, -1),      // 移除最后一个历史状态
    present: past.at(-1),         // 上一个状态成为当前状态
    future: [present, ...future]  // 将当前状态存入未来
  };
}

4. 处理重做操作 (redo)

重做是撤销的逆过程。当用户点击"重做"时:

  1. 当前的 present 值,添加到 past 数组的末尾。
  2. future 数组的第一个元素(即下一个状态)取出,并将其设置为新的 present 值。
  3. 移除 future 数组的第一个元素。
JavaScript 复制代码
if (action.type === "redo") {
  return {
    past: [...past, present], // 当前状态存入历史
    present: future[0],       // 下一个未来状态成为当前状态
    future: future.slice(1)   // 移除已重做的未来状态
  };
}

完整的 React 组件代码

结合上述 reducer 逻辑,我们可以轻松构建出完整的 CounterWithUndoRedo 组件。

JavaScript 复制代码
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 逻辑与具体操作类型解耦。这不仅让代码变得简洁明了,更重要的是,它为未来的功能扩展奠定了坚实的基础。当你的应用变得更加复杂时,你无需修改核心的 undoredo 逻辑,只需在处理新操作时,记得保存好状态快照即可。

相关推荐
用户938515635076 小时前
从零在浏览器里跑 DeepSeek-R1:WebGPU + Transformer.js 全链路实战
前端·设计模式·typescript
CodeSheep6 小时前
稚晖君公司人事大变动,来了!
前端·后端·程序员
鱼樱前端6 小时前
用 AI 做内容变收入
前端·人工智能·ai编程
浮生望7 小时前
React + TypeScript 组件设计进阶:从类型约束到无状态组件的三次进化
前端
90后的晨仔7 小时前
Mac 开发 uni-app 终极方案:让安卓模拟器彻底脱离 Android Studio 独立运行
前端·vue.js
90后的晨仔7 小时前
别再搞混了!uni-app 中 node_modules 和 uni_modules 到底是什么关系?
前端
kyriewen8 小时前
面试官问"这段逻辑为什么这样写"——我才发现,这半年我写的代码,我自己都解释不了
前端·javascript·面试
谢小飞8 小时前
大文件上传很难?学会这招,GB文件秒传不是梦
前端·node.js
程序员黑豆9 小时前
Windows 系统 Java 环境变量配置全攻略:解决“不是内部或外部命令”
java·前端·ai编程
To_OC9 小时前
别只拿 useRef 绑 DOM 了,搭配 Web Worker 解决页面卡顿才是真的香
前端·react.js·dom