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

14 阅读4分钟

解锁时光机:用 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 需要做两件事:

  1. 当前的 present 值,作为“历史快照”,添加到 past 数组的末尾。
  2. 更新 present 的新值。
  3. 最关键的一步:清空 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)

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

  1. 当前的 present 值,移动到 future 数组的开头。这是为了以后能够“重做”这个状态。
  2. 从 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)

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

  1. 当前的 present 值,添加到 past 数组的末尾。
  2. 将 future 数组的第一个元素(即下一个状态)取出,并将其设置为新的 present 值。
  3. 移除 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 逻辑,只需在处理新操作时,记得保存好状态快照即可。