前端撤销重做功能实现的那些坑我替你踩过了
需求来了,编辑器需要撤销重做功能
上周接了个需求,要做一个在线编辑器,产品经理提了个很常见的功能:Ctrl+Z撤销,Ctrl+Y重做。当时想这不挺简单的吗,结果真正开始做的时候发现没那么简单。
最开始我想着用现成的库,找到了几个知名的undo-redo库,但都不太符合我的场景。我的编辑器比较特殊,涉及到DOM操作、样式修改、内容变更这些混合在一起的操作。搞了两天发现现成的库都太重了,而且跟我的架构有点冲突。
后来决定自己实现一套,没想到这个看似简单的需求,里面水还挺深的。
踩的第一个坑,状态快照太重了
刚开始我用了最暴力的方式,每次用户操作的时候就保存整个编辑器的状态快照:
class UndoRedoManager {
constructor() {
this.history = [];
this.currentIndex = -1;
this.maxHistorySize = 50; // 限制历史记录数量
}
saveState(state) {
// 清除当前索引之后的历史记录
this.history = this.history.slice(0, this.currentIndex + 1);
// 添加新的状态
this.history.push(JSON.parse(JSON.stringify(state)));
this.currentIndex++;
// 控制历史记录大小
if (this.history.length > this.maxHistorySize) {
this.history.shift();
this.currentIndex--;
}
}
undo() {
if (this.canUndo()) {
this.currentIndex--;
return this.history[this.currentIndex];
}
return null;
}
redo() {
if (this.canRedo()) {
this.currentIndex++;
return this.history[this.currentIndex];
}
return null;
}
canUndo() {
return this.currentIndex > 0;
}
canRedo() {
return this.currentIndex < this.history.length - 1;
}
}
写完测试了一下,发现内存占用飙升得厉害。特别是编辑器里有大量DOM元素的时候,每次快照都是整个DOM树的深拷贝,几十次操作下来内存就爆了。而且JSON序列化反序列化也有性能问题。
这里我踩了个大坑,纯快照的方式只适合状态量小的场景,对于复杂的编辑器来说完全不行。
换思路,命令模式才是正道
折腾了半天后发现还是得用设计模式里的命令模式。把每次操作封装成命令对象,记录操作的具体信息,而不是整个状态。这样既节省内存,又能精确控制撤销重做的逻辑。
不过我发现直接用传统的Command模式也不太合适,因为我的编辑器里有很多不同类型的操作:文本编辑、样式修改、元素移动、删除插入等等。如果为每种操作都写一个Command类,那代码会爆炸。
后来试了下发现可以用一种更灵活的方案:记录操作的元数据,包括操作类型、参数、以及撤销时需要执行的逆向操作。
最终方案,事件驱动的撤销重做
最终我实现了这样一个系统,核心思想是记录操作的”动作”而不是”状态”:
class CommandManager {
constructor() {
this.commands = [];
this.currentPosition = -1;
this.maxCommands = 50;
// 监听键盘事件
document.addEventListener('keydown', this.handleKeydown.bind(this));
}
// 注册操作命令
registerCommand(type, execute, undo, data) {
// 清除当前位置之后的命令(类似分支操作)
if (this.currentPosition < this.commands.length - 1) {
this.commands = this.commands.slice(0, this.currentPosition + 1);
}
const command = {
type,
execute,
undo,
data,
timestamp: Date.now()
};
this.commands.push(command);
this.currentPosition++;
// 控制历史长度
if (this.commands.length > this.maxCommands) {
this.commands.shift();
this.currentPosition--;
}
console.log(Command registered: ${type}, total: ${this.commands.length});
}
undo() {
if (!this.canUndo()) {
console.log('Cannot undo');
return false;
}
const command = this.commands[this.currentPosition];
console.log(Undoing command: ${command.type});
try {
command.undo(command.data);
this.currentPosition--;
return true;
} catch (error) {
console.error('Undo failed:', error);
return false;
}
}
redo() {
if (!this.canRedo()) {
console.log('Cannot redo');
return false;
}
this.currentPosition++;
const command = this.commands[this.currentPosition];
console.log(Redoing command: ${command.type});
try {
command.execute(command.data);
return true;
} catch (error) {
console.error('Redo failed:', error);
this.currentPosition--; // 回滚位置
return false;
}
}
canUndo() {
return this.currentPosition >= 0;
}
canRedo() {
return this.currentPosition < this.commands.length - 1;
}
handleKeydown(event) {
// Ctrl+Z 撤销
if ((event.ctrlKey || event.metaKey) && event.key === 'z' && !event.shiftKey) {
event.preventDefault();
this.undo();
}
// Ctrl+Y 或 Ctrl+Shift+Z 重做
else if ((event.ctrlKey || event.metaKey) &&
(event.key === 'y' || (event.key === 'z' && event.shiftKey))) {
event.preventDefault();
this.redo();
}
}
clear() {
this.commands = [];
this.currentPosition = -1;
}
}
// 使用示例
const cmdManager = new CommandManager();
// 文本编辑命令示例
function addTextCommand(oldText, newText, elementId) {
const element = document.getElementById(elementId);
cmdManager.registerCommand(
'TEXT_EDIT',
() => { element.textContent = newText; }, // 执行
() => { element.textContent = oldText; }, // 撤销
{ oldText, newText, elementId }
);
}
// 样式修改命令示例
function changeStyleCommand(oldStyles, newStyles, elementId) {
const element = document.getElementById(elementId);
cmdManager.registerCommand(
'STYLE_CHANGE',
() => { Object.assign(element.style, newStyles); },
() => { Object.assign(element.style, oldStyles); },
{ oldStyles, newStyles, elementId }
);
}
// 元素移动命令示例
function moveElementCommand(fromPos, toPos, elementId) {
const element = document.getElementById(elementId);
cmdManager.registerCommand(
'ELEMENT_MOVE',
() => { element.style.transform = translate(${toPos.x}px, ${toPos.y}px); },
() => { element.style.transform = translate(${fromPos.x}px, ${fromPos.y}px); },
{ fromPos, toPos, elementId }
);
}
这种方案的好处是:
- 内存占用小,只记录必要的操作信息
- 性能好,撤销重做就是执行预先定义好的函数
- 灵活性高,可以处理各种类型的编辑操作
- 易于扩展,新操作只需要定义execute和undo函数
一些细节处理
实际使用中还有一些需要注意的细节。比如连续的快速输入应该合并成一次操作,不能每个字符都记录一个命令。我加了个防抖机制:
let textEditTimeout;
function handleTextChange(elementId, oldText, newText) {
// 清除之前的计时器
if (textEditTimeout) {
clearTimeout(textEditTimeout);
}
// 延迟记录,避免频繁的小改动
textEditTimeout = setTimeout(() => {
cmdManager.registerCommand(
'TEXT_EDIT',
() => { document.getElementById(elementId).textContent = newText; },
() => { document.getElementById(elementId).textContent = oldText; },
{ oldText, newText, elementId }
);
}, 500); // 500ms内无新输入则记录操作
}
还有就是分组合并的问题。有时候用户的一个操作实际上包含了多个子操作,比如拖拽一个元素的同时还改变了它的样式,这两个操作应该作为一次撤销单元。这里可以通过手动控制来实现:
cmdManager.isGrouping = false;
function startCommandGroup() {
cmdManager.isGrouping = true;
}
function endCommandGroup() {
cmdManager.isGrouping = false;
}
// 在需要组合操作的地方使用
startCommandGroup();
changeStyleCommand(oldStyle, newStyle, elementId);
moveElementCommand(oldPos, newPos, elementId);
endCommandGroup();
踩坑提醒:这三点一定注意
实现过程中踩了几个坑,记录一下:
第一,异步操作的处理。如果有异步的编辑操作(比如网络请求后更新UI),一定要确保在异步完成后再记录命令,否则会出现撤销时数据不对的情况。
第二,DOM节点的引用问题。记录命令时不要保存DOM节点的直接引用,因为DOM结构可能会变。应该保存节点的唯一标识符,在执行命令时重新查找节点。
第三,边界情况的处理。撤销到初始状态后不能再撤销,重做到最后不能再重做,这些边界条件要处理好,不然会出错。
性能优化方面
对于大型编辑器,还要考虑性能问题。除了前面提到的控制历史记录数量外,还可以:
定期清理很久不用的历史记录;对于连续的相似操作进行合并;延迟执行一些非关键的命令记录。
还有就是内存管理,JavaScript的垃圾回收有时候不太及时,所以要注意及时清理不用的对象引用。
总的来说,这套方案运行起来效果还不错,基本满足了我们的需求。虽然还不够完美(比如还有些边缘情况没处理),但已经能正常工作了。
以上是我踩坑后的总结,如果你有更好的方案欢迎评论区交流。

暂无评论