编辑器升级反引众怒😤:撤销/恢复功能的性能危机到底怎么了?

挨批

到公司附近时,打开钉钉准备打卡,发现有99+未读信息和12个未接语音电话,立刻感觉事情不妙。查看信息后才知道,昨天我们的产品发布了一个新版本,但昨晚用户开始不断投诉在线编辑器使用中出现严重的卡顿问题,尤其是文档内容越多时卡顿越严重。

不久后,领导的电话就打进来了,一顿文明的输出后挂断电话。怪我了,接手过来的项目就是一堆屎山代码,本来就是一堆问题,如果不是为了生计,我真不想处理这种烂摊子。

到了工位,甚至没时间吃早餐,就开始查看这个版本更新了哪些功能。经过比对,发现问题出在新增的在线编辑器的撤销和恢复功能上。在不包含这一功能的旧版本中,编辑器运行流畅无卡顿。

代码的问题

深入分析这一新增功能的代码,我发现问题所在:这一功能使用了数组来实现历史记录,这是一个设计上的重大失误。编辑器会实时记录用户的所有操作,将这些操作添加到历史记录数组中。随着用户操作的增加,数组长度也越来越长。特别是在执行撤销和恢复操作时,数据不再仅仅是简单地添加到数组尾部,而是需要插入到数组的中间位置,这就需要移动大量的元素,导致了较高的性能开销,进而引发了明显的卡顿现象。

重新设计数据结构

对于编辑器的撤销和恢复功能,双向链表是一个更为合适的选择,尤其是在需要频繁执行这些操作的场景中。链表的动态插入和删除操作非常适合管理编辑器的历史状态。通过使用双向链表,我们可以轻松地在历史记录的任意位置插入新的状态或删除旧的状态,而不需要担心内存重新分配或大量元素移动的性能开销。

双向链表是什么

双向链表示意图如下所示:

双向链表是一种嵌套的对象结构,其中每个节点包含三个关键部分:data(节点的值),prev(指向上一个节点的引用),和next(指向下一个节点的引用)。以下是双向链表节点的一个示例结构:

js 复制代码
const node = {
  data: 'A',  // 节点的值
  prev: null,  // 指向上一个节点的引用
  next: {      // 指向下一个节点的引用
    data: 'B',
    prev: // 指向'A'节点的引用,
    next: {
      data: 'C',
      prev: // 指向'B'节点的引用,
      next: null
    }
  }
};

实现一个双向链表的历史记录列表

首先,我们定义一个双向链表节点类EditNode,每个节点保存编辑器的一个状态,以及指向前一个和后一个状态的引用。

js 复制代码
class EditNode {
  constructor(data) {
    this.data = data;  // 编辑器的当前操作数据
    this.prev = null;  // 指向前一个操作数据的引用
    this.next = null;  // 指向后一个操作数据的引用
  }
}

然后,实现一个双向链表类EditHistory来管理编辑历史。该类需要维护对当前节点的引用,以支持撤销和恢复操作。

js 复制代码
class EditHistory {
  constructor() {
    this.current = null;  // 当前编辑器的操作数据节点
  }

  // 添加操作记录
  add(data) {
    const newNode = new EditNode(data);
    if (this.current === null) {
      this.current = newNode;
    } else {
      newNode.prev = this.current;
      this.current.next = newNode;
      this.current = newNode;
    }
  }

  // 撤销操作
  undo() {
    if (this.current !== null && this.current.prev !== null) {
      this.current = this.current.prev;
      return this.current.data;
    }
    return null;
  }

  // 恢复操作
  redo() {
    if (this.current !== null && this.current.next !== null) {
      this.current = this.current.next;
      return this.current.data;
    }
    return null;
  }
}

最后,将双向链表集成到编辑器模型中,每次用户执行编辑操作时,创建一个新的操作数据节点并添加到链表中。执行撤销操作时,向前移动链表的当前指针并恢复对应的操作数据。执行恢复操作时,向后移动并恢复对应的操作数据。

js 复制代码
class Editor {
  constructor() {
    this.history = new EditHistory();
    this.data = ''; // 操作数据
  }

  // 添加操作记录
  edit(newData) {
    this.data = newData;
    this.history.add(newData);
  }

  // 撤销操作
  undo() {
    const data = this.history.undo();
    if (data !== null) {
      this.data = data;
    }
  }

  // 恢复操作
  redo() {
    const data = this.history.redo();
    if (data !== null) {
      this.data = data;
    }
  }
}

我们可以这么使用

js 复制代码
const editor = new Editor();
editor.edit("A操作");
editor.edit("B操作");
editor.undo();  // 撤销到A操作
console.log(editor.data);  // 输出: A操作
editor.redo();  // 恢复到B操作
console.log(editor.data);  // 输出: B操作

如何直接撤销或恢复到某个操作

要实现直接撤销或恢复到某个操作的功能,需要对EditHistory类进行一些扩展,使其能够接收一个特定的状态或索引,并能够根据这个输入找到链表中对应的节点,然后更新current指针到该节点。这种功能的实现依赖通过索引直接访问链表中的节点。

可以在EditHistory类中添加moveToState方法,该方法接受一个目标索引,然后将current指针移动到对应的节点上。

js 复制代码
class EditHistory {
  constructor() {
    this.current = null;  // 当前编辑器的操作数据节点
    this.head = null;     // 链表的头部节点
  }
  
  // 原先代码

  // 新增方法:移动到指定索引的状态
  moveToState(index) {
    let temp = this.head;
    let i = 0;
    while (temp !== null) {
      if (i === index) {
        this.current = temp;
        return temp.data;
      }
      temp = temp.next;
      i++;
    }
    return null;
  }
}

集成到编辑器模型中,在Editor类中添加方法来支持直接撤销和恢复到指定的状态。

js 复制代码
class Editor {
  constructor() {
    this.history = new EditHistory();
    this.data = '';// 操作数据
  }

  // 新增方法:移动到指定索引的操作记录
  moveTo(index) {
    const data = this.history.moveToState(index);
    if (data !== null) {
      this.data = data;
    }
  }
}

我们可以这么使用

js 复制代码
const editor = new Editor();
editor.edit("A操作");
editor.edit("B操作");
editor.edit("C操作");
editor.moveTo(0);  // 直接移动到A操作
console.log(editor.data);  // 输出: A操作
editor.moveTo(2);  // 直接移动到C操作
console.log(editor.data);  // 输出: C操作

优化遍历链表性能开销

假设编辑历史链表很长,且在频繁进行这种操作的场景下,遍历链表会有性能问题。

所以得优化查找效率,我们可以基于传入的索引值决定是从头部还是尾部开始查找。这需要在EditHistory类中增加对链表尾部节点的引用tail以及一个记录当前链表长度的变量length,然后改造addmoveToState方法,使其根据索引值的相对位置决定从哪一端开始遍历。

js 复制代码
class EditHistory {
  constructor() {
    this.head = null;  // 链表头部节点
    this.tail = null;  // 链表尾部节点
    this.length = 0;   // 链表长度
  }

  add(data) {
    const newNode = new EditNode(data);
    if (this.head === null) {
      this.head = this.tail = newNode;
    } else {
      this.tail.next = newNode;
      newNode.prev = this.tail;
      this.tail = newNode;
    }
    this.length++;
  }

  // 根据索引查找状态的方法
  moveToState(index) {
    if (index < 0 || index >= this.length) {
      return null; // 索引超出范围
    }
    let targetNode;
    if (index < this.length / 2) {
      // 从头部开始查找
      targetNode = this.head;
      for (let i = 0; i < index; i++) {
        targetNode = targetNode.next;
      }
    } else {
      // 从尾部开始查找
      targetNode = this.tail;
      for (let i = this.length - 1; i > index; i--) {
        targetNode = targetNode.prev;
      }
    }
    return targetNode ? targetNode.data : null;
  }
}
相关推荐
程序员黑豆6 小时前
Java类型推断完全指南:从var到菱形运算符,掌握使用限制与最佳实践
java·前端·ai编程
To_OC7 小时前
踩了个 TS 的坑之后,我终于把 type 和 interface 掰明白了
前端·react.js·typescript
GreenTea7 小时前
深度解读 Anthropic 多智能体报告:更强的模型 ≠ 更好的协调
前端·后端·算法
浮生望7 小时前
前端API工程化:用 Mock 数据与 Axios 配置实现独立于后端的并行开发
前端
万少8 小时前
给 DeepSeek Harness 装个"应用商店":一条命令,595 个插件随你逛
前端·javascript·后端
波波0078 小时前
C# 15 重磅新特性: 带标签 break 与 continue:重新定义嵌套循环控制流
服务器·前端·c#
Brown.alexis9 小时前
es6知识点1-自备使用
前端·ecmascript·es6
小爬的老粉丝10 小时前
JavaScript 纯前端预览 WPS:先识别容器,再路由解析器
前端·javascript·wps
计算机魔术师10 小时前
新兴多智能体系统的模式与问题
前端
用户0595401744611 小时前
AI Agent 上下文污染踩坑实录:用 pytest + Redis 揪出 3 类记忆串号 bug,排查 6 小时
前端·css