前端撤销重做功能实现的那些坑我替你踩过了

欧辰的笔记 交互 阅读 1,589
赞 22 收藏
二维码
手机扫码查看
反馈

需求来了,编辑器需要撤销重做功能

上周接了个需求,要做一个在线编辑器,产品经理提了个很常见的功能: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的垃圾回收有时候不太及时,所以要注意及时清理不用的对象引用。

总的来说,这套方案运行起来效果还不错,基本满足了我们的需求。虽然还不够完美(比如还有些边缘情况没处理),但已经能正常工作了。

以上是我踩坑后的总结,如果你有更好的方案欢迎评论区交流。

本文章不代表JZTHEME立场,仅为作者个人观点 / 研究心得 / 经验分享,旨在交流探讨,供读者参考。
发表评论

暂无评论