⚠️⚠️记一次现网投诉问题 + element-ui 虚拟列表实践

1,070 阅读5分钟

1、背景

某天我正在睡觉,突然被监控的同事跑到工位上给摇醒了(睡觉了开勿扰了),我正迷糊,还没反应过来怎么回事。

同事说:有个大客户投诉,说每次打开我们的前端页面都会卡死,让我赶紧去定位一下问题。

一下我就不困了。

通过远程操作用户账号发现,还真是每次打开页面都会卡死。但是为什么之前没有投诉或者只有这一个用户投诉呢?还是先定位问题看一下吧。

通过定位发现,原来是这个用户下面有3w台云主机,打开页面的时候会渲染一个让用户选择主机的tree,组件使用elelment-uiel-tree。由于用户的云主机太多了,所有会导致两个问题:

  • 接口加载的时候导致页面一直loading。
  • 页面渲染的时候耗时过久导致页面卡死,实际测试下来需要30s左右才能渲染完成。

而这个功能是上一次迭代的时候上线的,且该投诉用户的主机也是刚订购的,之前根本没有规模这么大的用户;且用户主机数量少的时候不会有什么问题;且测试的时候没有覆盖大数据场景下的用例。这是要赶一起给我整个差绩效呀🤡。

问题定位到了那么解决方案就显而易见了:

  • 针对接口数据量大的问题,可以上分页+搜索
  • 渲染问题可以使用虚拟列表

下面是使用虚拟列表 + el-tree解决这个问题的过程。

2、虚拟列表

什么是虚拟列表?

虚拟列表的对立面是真实列表,也就是作者遭投诉的列表。当我们使用列表数据创建html元素节点时,节点的生成和渲染速度是和节点的数量正相关的,当数量过大时,就会导致页面一段时间不可操作,直到页面渲染操作完成。但是这个过程我们可以注意到一个问题:

  • 对于视图不可见的区域,列表节点是否能够渲染出来对于用户来说是无感的,渲染出来也是没有必要的。

2.jpeg

因此虚拟列表就是:只渲染视图可见部分的列表项,至于哪些可见,哪些不可见,需要我们编写一个函数或者组件根据视图的可见区域动态的进行计算。而这个实现了上述功能的列表就是所谓的虚拟列表了。

3、基本实现

有了上面的核心思路,接下来就是将上面的思路进行分解实现,分解实现如下:

  • 如何计算哪些元素在可视区域内
  • 滚动的时候动态更新可视区域的元素

针对第一个问题,我们容易得到以下结论:

  • 可视区域要展示哪些元素肯定和列表项的高度相关,列表项高度越高,可展示的数量就少,

  • 数学上来说就是:

    ListItems=f(scrollPosition,ItemHeight)ListItems = f(scrollPosition, ItemHeight)

对于一个虚拟列表来说,我们能够获得以下参数:

  • 距离列表顶部的滚动偏移量:startOffset = container.scrollTop

  • 虚拟列表容器的高度:containerHeight = container.offsetHeight (包括元素的内边距(padding)、边框(border)和水平滚动条(如果存在),但不包括外边距(margin)。)

  • 起始索引:

    startIndex=f1(scrollPosition,ItemHeight)startIndex = f1(scrollPosition, ItemHeight)
    const getStartIndex = function(scrollTop, itemHeight){
       // 已经滚动出去了多少个元素,向下取整防止还没滚出去呢就给干没了  
        return Math.floor(scrollTop / itemHeight)
    }
    
  • 需要渲染出来的列表项的数目:

    visibleItemCount=f2(containerHeight,ItemHeight)visibleItemCount = f2(containerHeight,ItemHeight)
    const visibleItemCount = function(containerHeight,itemHeight) {
      return Math.ceil(containerHeight / itemHeight)
    }
    
  • 结束索引:

    endIndex=f3(startIndex,visibleItemCount)endIndex= f3(startIndex,visibleItemCount)
    const getEndIndex = function(startIndex,visibleItemCount) {
        return startIndex + visibleItemCount
    }
    

结合以上所有参数可以求出来ListItems

1.jpeg

完整代码如下:

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Document</title>
  </head>
  <body>
    <div class="scroll-container">
      <div class="scroll-items"></div>
    </div>
  </body>
  <style>
    body {
      height: 100vh;
      width: 100%;
      display: flex;
      justify-content: center;
      align-items: center;
    }
    .scroll-container {
      height: 400px;
      width: 400px;
      border: 1px solid #000;
      overflow: auto;
      box-sizing: border-box;
    }
    .scroll-items {
      height: 10000px;
      width: 100%;
      box-sizing: border-box;
    }
    .scroll-item {
      height: 100px;
      width: 300px;
      box-sizing: border-box;
      border: 1px solid #333;
      margin: 0 auto;
    }
  </style>
  <script>
    const itemHeight = 100;
    let itemContainer;
    let scrollContainer;
    let list = [];
    const init = function () {
      itemContainer = document.querySelector(".scroll-items");
      scrollContainer = document.querySelector(".scroll-container");
      for (let i = 0; i < 100; i++) {
        list.push({ id: i, text: `this is ${i}` });
      }
    };
    const getStartIndex = function (scrollTop, itemHeight) {
      // 已经滚动出去了多少个元素,向下取整防止还没滚出去呢就给干没了
      return Math.floor(scrollTop / itemHeight);
    };
    const getVisibleItemCount = function (containerHeight, itemHeight) {
      return Math.ceil(containerHeight / itemHeight);
    };
    const getEndIndex = function (startIndex, visibleItemCount) {
      return startIndex + visibleItemCount;
    };
    const createListItemsEle = function (startIndex, endIndex, list) {
      const fragement = document.createDocumentFragment();
      for (let i = startIndex; i < endIndex && i < list.length; i++) {
        const item = document.createElement("div");
        item.className = "scroll-item";
        item.innerText = list[i].text;
        fragement.appendChild(item);
      }
      return fragement;
    };
    const renderListItems = function (itemHeight, list) {
      itemContainer.innerHTML = "";
      const startIndex = getStartIndex(scrollContainer.scrollTop, itemHeight);
      const visibleItemCount = getVisibleItemCount(
        scrollContainer.offsetHeight,
        itemHeight
      );
      const endIndex = getEndIndex(startIndex, visibleItemCount);
      const listItemsEle = createListItemsEle(startIndex, endIndex + 2, list);
      itemContainer.appendChild(listItemsEle);
    };
    const handleScroll = () => {
      renderListItems(itemHeight, list);
    };
    window.onload = () => {
      init();
      handleScroll();
      scrollContainer.addEventListener("scroll", handleScroll);
    };
    window.onunload = () => {
      scrollContainer.removeEventListener("scroll", handleScroll);
    };
  </script>
</html>

在线查看如下:

但是此时有一个问题,虽然滚动的时候,列表项更新了,但是列表项随着滚动逐渐不可见了,因此,需要随着滚动给列表容器加上一个paddingTop来保证:渲染出来的列表项始终在可视区域内

const renderListItems = function (itemHeight, list) {
      itemContainer.innerHTML = "";
      const startIndex = getStartIndex(scrollContainer.scrollTop, itemHeight);
      const visibleItemCount = getVisibleItemCount(
        scrollContainer.offsetHeight,
        itemHeight
      );
      const endIndex = getEndIndex(startIndex, visibleItemCount);
      const listItemsEle = createListItemsEle(startIndex, endIndex + 2, list);
      // 加上这一句
      itemContainer.style.paddingTop = scrollContainer.scrollTop + "px";
      itemContainer.appendChild(listItemsEle);
    };

但是此时还是有问题的:没有滚动效果,列表项元素切换是突然变化的,而不是连续变化的,因此需要在容器的顶部和底部设置几个缓存元素来实现连续的变换。这里我们使用绝对定位和缓冲元素来模拟连续的滚动,不再使用padding的方式来改变元素位置

     const createListItemsEle = function (startIndex, endIndex, list) {
      const fragement = document.createDocumentFragment();
      for (let i = startIndex; i < endIndex && i < list.length; i++) {
        const item = document.createElement("div");
        item.className = "scroll-item";
        item.innerText = list[i].text;
        // 新增这一行
        item.style.top = `${i * itemHeight}px`;
        fragement.appendChild(item);
      }
      return fragement;
    };
    const cacheSize = 4
    const renderListItems = function (itemHeight, list) {
      const startIndex = getStartIndex(scrollContainer.scrollTop, itemHeight);
      const visibleItemCount = getVisibleItemCount(
        scrollContainer.offsetHeight,
        itemHeight
      );
      const endIndex = getEndIndex(startIndex, visibleItemCount);

      // 调整缓冲区大小,使其对称
      const bufferSize = Math.floor(cacheSize / 2);

      // 计算新的开始和结束索引
      const newStartIndex = Math.max(0, startIndex - bufferSize);
      const newEndIndex = Math.min(list.length, endIndex + bufferSize);
      const listItemsEle = createListItemsEle(
        newStartIndex,
        newEndIndex,
        list,
        scrollContainer.scrollTop
      );
      itemContainer.innerHTML = "";
      itemContainer.appendChild(listItemsEle);
    };

初始状态下,不带滑动如下:

image.png

完整的演示如下: 这里只是一个基本原理的展示,还有很多工作没有做,比如:

  • 不定高列表项
  • 滚动节流

这里不再赘述

4、问题解决

产生问题的工程项目使用的是elementUI + vue2.x,因此需要在el-tree上做一些定制化修改以支持虚拟滚动,同时满足使用逻辑和样式的不变。(当然可以引入其它支撑虚拟滚动的组件,但是需要定制化修改样式)

核心逻辑是使用vue-virtual-scroll-list 封装一下。

    <virtual-list
      v-if="height"
      :style="{ 'max-height': height, 'overflow-y': 'auto' }"
      :data-key="getNodeKey"
      :data-sources="visibleList"
      :data-component="itemComponent"
      :extra-props="{
        renderAfterExpand,
        showCheckbox,
        renderContent,
        onNodeExpand: handleNodeExpand
      }"
    />
 export default {
  data() {
    return {
      itemComponent: ElVirtualNode
        }
   }
  }

当然,如果工程没有之前的包袱,最好直接引入一个稳定的虚拟滚动库,防止自己在原有组件库上的改动再引起其他的问题。

以上,通过这次投诉也是记住了:不能有侥幸心理呀,事前想着用虚拟列表优化一下来着,想着不应该有那么大的数据量就偷懒了,想想怎么把锅甩给后端和测试🐶。

image.png