可视化编辑器撤销重做怎么实现才不会乱?

Good“培乐 阅读 24

我在做一个拖拽组件的可视化编辑器,现在想加撤销重做功能,但每次操作后状态同步老出问题。比如拖动一个元素后撤销,位置没变回来,或者重做时报错。

我试过用一个数组存历史快照,每次操作就 push 一个新状态,但数据量大了特别卡。也看过别人用命令模式,但不太会写。有没有轻量又稳定的方案?

目前状态是这样存的:history.push(JSON.parse(JSON.stringify(currentState))),但感觉这不是长久之计……

我来解答 赞 8 收藏
二维码
手机扫码查看
2 条解答
a'ゞ文雅
这个问题我前段时间刚踩过坑,撤销重做确实是个头疼的问题。你现在的JSON深拷贝方案问题很大,我来说说为什么:

首先,每次操作都完整拷贝整个状态有两个致命问题:内存爆炸(特别是有大量组件时),以及循环引用会直接报错。我之前一个项目用类似方法,500次操作后内存直接飙到2GB...

推荐用命令模式+增量快照的方案,这样实现:

1. 定义操作命令基类,每个具体操作都要实现execute和undo方法
2. 用两个栈存储undo和redo记录
3. 只记录变更的部分,而不是全量状态

具体代码这样写(以移动组件为例):

// 命令基类
class Command {
execute() {}
undo() {}
}

// 移动命令
class MoveCommand extends Command {
constructor(component, oldPos, newPos) {
super()
this.component = component
this.oldPos = oldPos
this.newPos = newPos
}

execute() {
this.component.position = this.newPos
}

undo() {
this.component.position = this.oldPos
}
}

// 撤销管理器
class History {
constructor() {
this.undoStack = []
this.redoStack = []
}

execute(command) {
command.execute()
this.undoStack.push(command)
this.redoStack = [] // 新操作会清空重做栈
}

undo() {
const command = this.undoStack.pop()
if (command) {
command.undo()
this.redoStack.push(command)
}
}

redo() {
const command = this.redoStack.pop()
if (command) {
command.execute()
this.undoStack.push(command)
}
}
}


使用时这样调用:
const history = new History()
history.execute(new MoveCommand(component, oldPos, newPos))

需要注意的几个关键点:
1. 每个命令必须保证原子性,要么完全成功要么完全失败
2. 操作前要先保存旧状态,undo时才能恢复
3. 新操作后要清空redo栈,避免状态混乱

性能方面,比起全量拷贝,这种方案内存占用减少90%以上(实测10万次操作内存才200MB左右)。因为只存储变更的引用和少量数据,而不是整个对象树。

如果遇到复杂操作(比如同时移动多个组件),可以实现复合命令(CompositeCommand),把多个命令打包成一个原子操作。
点赞
2026-03-07 17:06
夏侯香利
别存全量快照了,用命令模式只记录增量变化。

// 每个操作就是一个命令对象
const commands = {
move: (id, oldPos, newPos) => ({
execute: () => updatePos(id, newPos),
undo: () => updatePos(id, oldPos)
}),
resize: (id, oldSize, newSize) => ({
execute: () => updateSize(id, newSize),
undo: () => updateSize(id, oldSize)
})
};

// 撤销重做栈
let history = [];
let pointer = -1;

function doCommand(cmd) {
cmd.execute();
history = history.slice(0, pointer + 1); // 干掉后面的
history.push(cmd);
pointer++;
}

function undo() {
if (pointer >= 0) history[pointer--].undo();
}

function redo() {
if (pointer < history.length - 1) history[++pointer].execute();
}


拖动时只存 { id, oldPos, newPos } 这点数据,撤销时反向操作,搞定。
点赞 5
2026-03-01 12:07