上一步 下一步 與 清除的邏輯規則
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);
| 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);
};
為什麼要砍掉 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]);
};
currentStep <= 0 守衛 必加,否則會撈到 layerHistory[-1] undefined。
Redo
const handleRedo = () => {
if (currentStep >= layerHistory.length - 1) return; // 已在最新版本
const newStep = currentStep + 1;
setCurrentStep(newStep);
setLayers(layerHistory[newStep]);
};
Clear
const handleClear = () => {
const newLayers: Layer[] = [];
setLayers(newLayers);
const newHistory = layerHistory.slice(0, currentStep + 1);
newHistory.push(newLayers);
setLayerHistory(newHistory);
setCurrentStep(currentStep + 1);
};
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]);
快捷鍵慣例:
Ctrl/Cmd + Z→ UndoCtrl/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);
};
抽象成自訂 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 };
}
使用:
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>
替代:用套件
npm install zundo # Zustand undo middleware
npm install use-undo # 純 React hook
npm install immer # 配合 RTK / Zustand 寫不可變更新
| 套件 | 適合 |
|---|---|
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: [] });
};
只存差異而非完整快照,大型畫布類 app 必須這樣做(Photoshop / Figma 都是 patch-based)。