首頁
學習紀錄
遊戲心得影視Life書單案件檔案
Side Projects委託作品與二創互動實驗場
Kurau
百百 BLOG
首頁
學習紀錄
遊戲心得影視Life書單案件檔案
Side Projects委託作品與二創互動實驗場
Kurau

Kurau Blog

「隨心而寫,真真假假,都是我」

一個記錄生活、輸出興趣的個人空間。
遊戲、影視、閱讀、學習……每一段體驗都值得留下文字。

頁面導覽

  • 學習紀錄
  • 遊戲心得
  • 影視Life
  • 書單
  • 委託作品與二創
  • Kurau
  • 合作邀請

找到我

歡迎來 Discord 找我聊天!

“曾經發生的事不可能忘記,只是暫時想不起來而已。”-《神隱少女》

© 2026 Kurau All rights reserved

前端框架

上一步 下一步 與 清除的邏輯規則

By Kurau·2023-03-28·Updated 2026-05-09·4 分鐘閱讀

Undo / Redo / Clear 的 React state 設計

TL;DR
用 history 陣列 + currentStep 指標 實作。每次操作都把新狀態 push 到 history、step+1。Undo = step-1 取舊版本;Redo = step+1 取後版本;Clear = push 一個空狀態(不清 history)。重點:操作時要砍掉 step 之後的 future history(覆寫分支)。

核心 state 結構

type Layer = { /* ... */ };

const [layers, setLayers] = useState<Layer[]>([]);
const [layerHistory, setLayerHistory] = useState<Layer[][]>([[]]);
const [currentStep, setCurrentStep] = useState(0);
typescript
State角色
layers當下渲染用的 array
layerHistory所有歷史快照,每個元素是某時刻的完整 layers 陣列
currentStep指向 layerHistory 的 index
為什麼分兩個 state
也可以只存 layerHistory + step,渲染時 derive layers = layerHistory[step]。但快取 layers 可以避免每次 render 都查 array,效能略好,且 set layers 跟 set history 解耦更乾淨。

加入新操作(關鍵:砍掉 future)

const handleAddNewLayer = (newLayer: Layer) => {
  const newLayers = [...layers, newLayer];
  setLayers(newLayers);

  // 砍掉 currentStep 之後的歷史(因為要覆寫分支)
  const newHistory = layerHistory.slice(0, currentStep + 1);
  newHistory.push(newLayers);

  setLayerHistory(newHistory);
  setCurrentStep(currentStep + 1);
};
typescript
為什麼要砍掉 future
想像使用者 undo 了 3 步,然後做新操作:那「未來的 3 步」就被新操作 覆寫,不該保留。如果不砍直接 push,redo 會跳到不相關的舊歷史 → bug。

這是大多數 undo/redo 實作的核心 invariant:currentStep 永遠指向 history 的 分支頂端。


Undo

const handleUndo = () => {
  if (currentStep <= 0) return;          // 已在最初版本

  const newStep = currentStep - 1;
  setCurrentStep(newStep);
  setLayers(layerHistory[newStep]);
};
typescript

currentStep <= 0 守衛 必加,否則會撈到 layerHistory[-1] undefined。


Redo

const handleRedo = () => {
  if (currentStep >= layerHistory.length - 1) return;   // 已在最新版本

  const newStep = currentStep + 1;
  setCurrentStep(newStep);
  setLayers(layerHistory[newStep]);
};
typescript

Clear

const handleClear = () => {
  const newLayers: Layer[] = [];
  setLayers(newLayers);

  const newHistory = layerHistory.slice(0, currentStep + 1);
  newHistory.push(newLayers);

  setLayerHistory(newHistory);
  setCurrentStep(currentStep + 1);
};
typescript

Clear 不是「清掉 history」,而是「把目前狀態變空」。所以還是 push 新狀態,使用者可以 undo 把內容拿回來,符合「Clear → Ctrl+Z 復原」直覺。


快捷鍵綁定

import { useEffect } from 'react';

useEffect(() => {
  const handleKeyDown = (e: KeyboardEvent) => {
    const isMod = e.ctrlKey || e.metaKey;
    if (!isMod) return;

    if (e.key === 'z' && !e.shiftKey) {
      e.preventDefault();
      handleUndo();
    } else if ((e.key === 'z' && e.shiftKey) || e.key === 'y') {
      e.preventDefault();
      handleRedo();
    }
  };

  window.addEventListener('keydown', handleKeyDown);
  return () => window.removeEventListener('keydown', handleKeyDown);
}, [handleUndo, handleRedo]);
tsx

快捷鍵慣例:

  • Ctrl/Cmd + Z → Undo
  • Ctrl/Cmd + Shift + Z 或 Ctrl + Y → Redo

進階:limit history 大小

長時間編輯會讓 history 越來越大,吃記憶體。可以加 cap:

const MAX_HISTORY = 50;

const handleAddNewLayer = (newLayer: Layer) => {
  const newLayers = [...layers, newLayer];
  setLayers(newLayers);

  let newHistory = layerHistory.slice(0, currentStep + 1);
  newHistory.push(newLayers);

  // 超過上限,砍掉最舊
  if (newHistory.length > MAX_HISTORY) {
    newHistory = newHistory.slice(-MAX_HISTORY);
    setCurrentStep(MAX_HISTORY - 1);
  } else {
    setCurrentStep(currentStep + 1);
  }
  setLayerHistory(newHistory);
};
typescript

抽象成自訂 Hook

function useUndoRedo<T>(initial: T) {
  const [history, setHistory] = useState<T[]>([initial]);
  const [step, setStep] = useState(0);

  const current = history[step];
  const canUndo = step > 0;
  const canRedo = step < history.length - 1;

  const set = (newValue: T) => {
    const newHistory = history.slice(0, step + 1);
    newHistory.push(newValue);
    setHistory(newHistory);
    setStep(newHistory.length - 1);
  };

  const undo = () => canUndo && setStep(step - 1);
  const redo = () => canRedo && setStep(step + 1);
  const reset = (newValue: T) => set(newValue);

  return { current, set, undo, redo, reset, canUndo, canRedo };
}
typescript

使用:

const { current: layers, set: setLayers, undo, redo, canUndo, canRedo } = useUndoRedo<Layer[]>([]);

<button onClick={undo} disabled={!canUndo}>Undo</button>
<button onClick={redo} disabled={!canRedo}>Redo</button>
tsx

替代:用套件

npm install zundo            # Zustand undo middleware
npm install use-undo         # 純 React hook
npm install immer            # 配合 RTK / Zustand 寫不可變更新
bash
套件適合
use-undo⭐ 最簡單,純 React hook
zundo用 Zustand 時搭配
@reduxjs/toolkit + redux-undo用 Redux 時搭配
immer不直接做 undo,但簡化 immutable 更新

進階考量:大型 state 的 patch-based undo

如果 layers 是大物件 每次完整複製太貴,可以用 Immer 的 patches 只存差異:

import { produce, applyPatches, Patch } from 'immer';

const [patches, setPatches] = useState<{ undo: Patch[][]; redo: Patch[][] }>({ undo: [], redo: [] });

const updateState = (mutator: (draft: State) => void) => {
  const [next, undoPatches, redoPatches] = produceWithPatches(state, mutator);
  setState(next);
  setPatches({ undo: [...patches.undo, undoPatches], redo: [] });
};
typescript

只存差異而非完整快照,大型畫布類 app 必須這樣做(Photoshop / Figma 都是 patch-based)。

目錄

    ◆ 相關文章

    • HTML React 打字機效果

      2026-05-09
    • Framer Motion 打造比最棒還要棒的動畫

      2026-05-09
    • Redux

      2026-05-09
    • react-app-env.d.ts

      2026-05-09
    ← 上一篇保護你的API KEY (React 前端)下一篇 →tsx 轉 jsx

    ◆ 關於作者

    Kurau

    個人寫作 / 創作的 SoT,記錄遊戲、影視、學習與生活。

    更多 Kurau 的文章