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

挨批

到公司附近时,打开钉钉准备打卡,发现有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;
  }
}
相关推荐
阿伟来咯~17 分钟前
记录学习react的一些内容
javascript·学习·react.js
吕彬-前端22 分钟前
使用vite+react+ts+Ant Design开发后台管理项目(五)
前端·javascript·react.js
学前端的小朱25 分钟前
Redux的简介及其在React中的应用
前端·javascript·react.js·redux·store
guai_guai_guai34 分钟前
uniapp
前端·javascript·vue.js·uni-app
也无晴也无风雨35 分钟前
在JS中, 0 == [0] 吗
开发语言·javascript
bysking2 小时前
【前端-组件】定义行分组的表格表单实现-bysking
前端·react.js
暗黑起源喵2 小时前
设计模式-工厂设计模式
java·开发语言·设计模式
王哲晓2 小时前
第三十章 章节练习商品列表组件封装
前端·javascript·vue.js
fg_4112 小时前
无网络安装ionic和运行
前端·npm
理想不理想v2 小时前
‌Vue 3相比Vue 2的主要改进‌?
前端·javascript·vue.js·面试