react-undo-redo-history

test12026-10-2615 次阅读

为什么需要历史栈

富文本编辑、表单设计器、画布拖拽这类场景,用户会反复改动状态。如果只存当前值,一旦改错就回不去了。把每一次变更记进一个历史栈,撤销就是弹栈、重做就是把弹出的值再压回去,逻辑非常干净。

设计状态结构

把状态拆成三段:过去、现在、未来。初始时过去和未来都是空的,只有现在。

interface HistoryState<T> {
  past: T[];
  present: T;
  future: T[];
}

type Action<T> =
  | { type: "SET"; payload: T }
  | { type: "UNDO" }
  | { type: "REDO" };

实现 reducer

SET 把当前 present 推入 past、清空 future;UNDO 从 past 取出最后一项作为新的 present,并把原 present 推入 future;REDO 反向操作。

function historyReducer<T>(
  state: HistoryState<T>,
  action: Action<T>
): HistoryState<T> {
  const { past, present, future } = state;
  switch (action.type) {
    case "SET": {
      if (action.payload === present) return state;
      return { past: [...past, present], present: action.payload, future: [] };
    }
    case "UNDO": {
      if (past.length === 0) return state;
      const previous = past[past.length - 1];
      return {
        past: past.slice(0, -1),
        present: previous,
        future: [present, ...future],
      };
    }
    case "REDO": {
      if (future.length === 0) return state;
      const next = future[0];
      return {
        past: [...past, present],
        present: next,
        future: future.slice(1),
      };
    }
  }
}

封装成 useHistory Hook

把 reducer 和 dispatch 包进一个 Hook,对外只暴露 set、undo、redo,以及 canUndo/canRedo 两个布尔,用来控制按钮可用态。

import { useReducer, useCallback } from "react";

export function useHistory<T>(initial: T) {
  const [state, dispatch] = useReducer(historyReducer, {
    past: [],
    present: initial,
    future: [],
  });

  const set = useCallback(
    (value: T) => dispatch({ type: "SET", payload: value }),
    []
  );
  const undo = useCallback(() => dispatch({ type: "UNDO" }), []);
  const redo = useCallback(() => dispatch({ type: "REDO" }), []);

  return {
    state: state.present,
    set,
    undo,
    redo,
    canUndo: state.past.length > 0,
    canRedo: state.future.length > 0,
  };
}

在组件里使用

编辑器把输入框的值接到 state,按钮按 canUndo/canRedo 决定能不能点。整个撤销重做链路不需要组件自己维护数组。

function Editor() {
  const { state, set, undo, redo, canUndo, canRedo } = useHistory("");

  return (
    <div>
      <textarea value={state} onChange={(e) => set(e.target.value)} />
      <button onClick={undo} disabled={!canUndo}>撤销</button>
      <button onClick={redo} disabled={!canRedo}>重做</button>
    </div>
  );
}

工程注意点

  • past 和 future 会随操作无限增长,长会话要设一个最大长度,超出就丢弃最旧的一段。
  • SET 里做了相等判断,连续两次相同输入不会污染历史。
  • 若状态是对象,记得每次 SET 都传新引用,否则相等判断会失效。
  • 需要把整段历史存盘时,直接把 HistoryState 序列化进 localStorage 即可。
T

test1

文章作者

为什么需要历史栈 富文本编辑、表单设计器、画布拖拽这类场景,用户会反复改动状态。如果只存当前值,一旦改错就回不去了。把...

分类
技术
发布时间
2026-10-26
字数
约 2548 字
阅读
15 次