返回笔记

Vue3 + immer 实现撤销与重做

Vue3immer撤销重做低代码

背景

页面编辑器的本质是维护一个 JSON 配置对象。用户在画布上拖拽、删除、修改属性,最终都映射为对这个 JSON 的变更。撤销/重做的本质,就是对这个 JSON 做版本管理。

两种策略

快照策略:每次修改前全量复制一份旧状态存入历史。撤销时从历史中取出上一个版本直接替换当前状态。优点是实现简单、状态一致性有保证;缺点是占内存,大对象下拷贝开销不可忽视。

补丁策略:记录每处变更的逆操作(补丁),撤销时逆序应用补丁。优点是省内存;缺点是实现复杂——你必须保证每个操作的逆操作都正确,一个补丁写错,整个状态就崩了。

对于大多数场景,快照策略足够。现代 JS 引擎下 JSON 序列化/反序列化的开销远小于补丁策略引入的 bug 成本。配合 immer 的结构共享,内存问题也能控制。

实现:快照 + 双栈 + immer

双栈模型:一个撤销栈、一个重做栈。发生变更时,旧版本压入撤销栈,清空重做栈。撤销时当前版本入重做栈,撤销栈顶出栈。重做时反向操作。

// useHistory.ts
import { ref, shallowRef, computed } from "vue";
import { produce, type Draft } from "immer";

interface HistoryState<T> {
  undoStack: T[];
  redoStack: T[];
}

export function useHistory<T extends Record<string, unknown>>(
  initialState: T
) {
  const state = ref<T>(initialState);
  const history = shallowRef<HistoryState<T>>({
    undoStack: [],
    redoStack: [],
  });

  // immer produce 返回新对象,旧对象保留在栈中
  function update(recipe: (draft: Draft<T>) => void) {
    const prev = state.value;
    history.value = {
      undoStack: [prev, ...history.value.undoStack].slice(0, 50), // 限制 50 步
      redoStack: [],
    };
    state.value = produce(prev, recipe);
  }

  function undo() {
    const { undoStack, redoStack } = history.value;
    if (undoStack.length === 0) return;
    history.value = {
      undoStack: undoStack.slice(1),
      redoStack: [state.value, ...redoStack],
    };
    state.value = undoStack[0];
  }

  function redo() {
    const { undoStack, redoStack } = history.value;
    if (redoStack.length === 0) return;
    history.value = {
      undoStack: [state.value, ...undoStack],
      redoStack: redoStack.slice(1),
    };
    state.value = redoStack[0];
  }

  const canUndo = computed(() => history.value.undoStack.length > 0);
  const canRedo = computed(() => history.value.redoStack.length > 0);

  return { state, update, undo, redo, canUndo, canRedo };
}

使用示例:

const { state, update, undo, redo, canUndo, canRedo } = useHistory({
  components: [],
  selected: null,
});

// 修改时调用 update 而非直接改 state
function moveComponent(id: string, x: number, y: number) {
  update((draft) => {
    const comp = draft.components.find((c) => c.id === id);
    if (comp) {
      comp.x = x;
      comp.y = y;
    }
  });
}

几个关键决策:

  • 历史步数上限 50 步,防止内存膨胀
  • shallowRef 优化:栈数组本身不需要深层响应式
  • immer 的 produce 只修改变更路径上的引用,未变部分共享,实际内存开销远小于全量深拷贝

小结

撤销/重做不需要过度设计。快照方案配合 immer 在简单性和性能之间取得了平衡点。补丁方案除非在极端内存受限的场景,否则不值得引入。