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

挨批

到公司附近时,打开钉钉准备打卡,发现有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;
  }
}
相关推荐
mldong2 小时前
你的 Vue3 项目也能有钉钉同款审批流设计器:npm 装包,10 分钟画出第一条审批流
前端·vue.js
2分钟速写快排3 小时前
什么是 RAG?如何用 RAG 实现一个用户记忆?
前端·后端·ai编程
passerby60613 小时前
如何自己造一个时间处理库
前端·javascript·github
走到天涯海角4 小时前
react里面的长列表渲染优化
前端·react.js·前端框架
小羊没烦恼!4 小时前
Hello Web API系列教程——Web API与国际化
java·服务器·前端·javascript·php
北岛贰5 小时前
迷茫焦虑期,我做了一个带支付带官网的 AI 聊天虚拟恋人 App
前端·人工智能·后端
mayaairi6 小时前
Vue2 组件通讯(三):全局事件总线、PubSub、插槽与组件实例属性
前端·javascript·vue.js
kyriewen7 小时前
面试官问我:AI 都能写代码了,前端凭什么还值 25K
前端·javascript·人工智能
风骏时光牛马7 小时前
AI源码分析:拆解模型底层实现逻辑
前端
BillKu8 小时前
全局样式变量:CSS 自定义属性(--border-color)和SCSS 变量($border-color)的说明
javascript·css·scss