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 在简单性和性能之间取得了平衡点。补丁方案除非在极端内存受限的场景,否则不值得引入。