乐趣区

基于elementui-eltable-开发虚拟列表树形列表

前言


基于之前支持表单验证的 el-table 开发完成后,在数据量过大的时候,会出现渲染慢,表格卡顿等致命问题,而 element-ui 的 el-table 本身没有像 antd 一样提供虚拟列表的 demo 和相关支持,因此本文在上次的开发基础上,继而开展虚拟列表的开发。本次分为普通列表和树形列表两种,树形在普通列表上面多了一些情况考虑,例如展开收缩等。

虚拟列表

虚拟列表 简单概述就是 滚动分页 ,通过有限的视口来切片大量的数据,因为相比于 js 运算,渲染是一个很慢的过程,因此通过一定的 js 计算,保证更少的数据渲染,通常可以获得更好的用户体验。一般虚拟列表可以通过上下的动态 padding 值,来是滚动区域一直显示当前切片出的数据,以及通过 transform 的方法来动态移动可视区。transform 这种方法理论上性能要好一些,因为浏览器渲染本身是分图层渲染的,而 transform 操作的视图,会被浏览器单独分层出来,渲染性能更优。
下图两种方式:

本次 el-table 上的虚拟列表,采用了 padding 的方案,原因是 transform 会使 el-table 的样式混乱,如果是自己开发的 table 或者其他魔改支持度较好的插件的话优先 transform。

普通列表

首先看一下 el-table 渲染 300 条的速度。

本次的测试代码有 300 条,本身并不多,但是有 8 列都是插槽中渲染的表单组件,因此渲染速度要慢很多,时间花销 6s+。(antd 的 table 渲染要快一些,后面说原因)

增加虚拟列表后渲染速度:

开发流程

step1

计算总高度

height = list.length * 65 
// height 为列表实际总高度
// 65 为每一行的行高,根据实际修改
// list 为实际数据长度

step2

计算上下 padding 值

paddingTop = scrollTop + "px";
paddingBottom = height - 10 * 65 - scrollTop + "px";
// scrollTop 为滚动的高度,即列表向下滚动的距离
// height 总高度 
// 10 为实际渲染的条数 

step3

监听列表滚动,动态为列表设置 padding 等样式。

 mounted() {console.time("render300 条时间:");
    this.form.rows = new Array(300).fill(0).map((v, i) => ({
      name: i,
      children: []}));
    this.form.rows = [...this.form.rows];
    this.setIndex(this.form.rows);
    this.calcList();
    this.$nextTick(() => {this.debounceFn = _.debounce(() => {this.scrollTop = this.$refs.table.bodyWrapper.scrollTop;}, 100);
      this.$refs.table.bodyWrapper.addEventListener("scroll", this.debounceFn);
    });
    this.$nextTick(() => {console.timeEnd("render300 条时间:");
    });
  },

监听的目标是这个:this.$refs.table.bodyWrapper,防抖的时间设置为 100。

step4

数组切片,渲染虚拟列表。

 this.startIndex = Math.floor(scrollTop / 65);
 this.virtualRows = this.form.rows.slice(
        this.startIndex,
        this.startIndex + 10
      );

根据滚动位置计算数组切片的起始点,然后截取相应的 list 渲染。

支持列 fixed(Table-column Attributes – fixed)

上面说到 el-table 要比 antd 的 table 渲染更慢,其中一条原因我个人认为是,el-table 在支持左右固定列的时候会克隆一份 table,然后按照层级关系,使得 UI 上看到左右列的固定。如果左右都设置了 fixed,就会有三个 table 同时在页面上。

而 Antd 的 table 组件在左右 fixed 时就不会有这个问题,因此本人亲测在 300 条相同数据的情境下,Antd 的性能要好不少。言归正传,要解决 fixed 的问题,就是要把这三个 table 的 padding 都去设置一遍才行,否则就会出现部分区域没有被顶下来而错位的情况。

 let mainTable = this.$refs.table.$el.getElementsByClassName("el-table__body");
      Array.from(mainTable).forEach(v => {
        v.style.height = height + "px";
        if (this.startIndex + 10 >= this.num) {
          // 由于 el-table 在滚动到最后时,会出现抖动,因此增加判断,单独设置属性
          v.style.paddingTop = scrollTop - 65 + "px";
          v.style.paddingBottom = 0;
        } else {
          v.style.paddingTop = scrollTop + "px";
          v.style.paddingBottom = height - 10 * 65 - scrollTop + "px";
        }
      });

找到当前 table 下的所有内容区域,遍历设置样式属性。

树形列表

树形列表由于多一步展开折叠的操作,以及本身数据结构的原因,数据预处理要复杂一下,不能直接 slice,而要计算出相应区间然后生成新的数组。其次,在被收缩的子项是不渲染到 table 当中的,因此,要把被收缩的项排除在外。除了普通列表的几个 step 之外,树形列表还需有以下操作。

数组切片

通过滚动计算出的起始点,以及可视区域的列表长度,可以得到一个区间,如【3,11】,即通过深度优先遍历(也是树形列表排列的顺序),找到第 3 到 11 条数据(不包含被折叠项),然后赋值到新的数组。

clacTree() {
      let count = 0;
      this.virtualRows = [];
      this.listLen = 0;
      const fn = arr => {for (let i = 0; i < arr.length; i++) {
          count++;
          this.listLen++;
          if (count >= this.startIndex && count <= this.startIndex + 10) {this.combineArr(_.cloneDeep(arr[i]));
          }
          arr[i].children && arr[i].expended === "true" && fn(arr[i].children);
        }
      };
      fn(this.form.rows);
    },
    combineArr(node) {
      let flag = false;
      node.children = [];
      const fn = arr => {
        arr.forEach(v => {if (node.pid === v.customIndex) {v.children.push(node);
            flag = true;
          }
          v.children && fn(v.children);
        });
      };
      fn(this.virtualRows);
      if (!flag) {this.virtualRows.push(node);
      }
    },

这里只对展开项进行操作,未展开的不去遍历和渲染,总高度也不计入。新数组赋值的时候,我通过二次遍历新的数组,再根据 pid 去 push 到相应位置,这种做法是因为实际业务需要,二次遍历中还有部分属性需要保持引用,以及部分属性是不可枚举的,深拷贝会丢失,如果只是截取树的一部分形成新的树,可以根据初始化得到的 path 属性,然后利用 lodash 的_.set 来完成。

展开收缩

el-table 的 expand-row-keys 传入一个数组,为默认的展开项,之后每次渲染都参考这个数组来决定列表是否展开,这个属性不能自动在展开收缩的时候把设置的 row-key 推入推出,而要手动的计算。在 @expand-change 事件中,来操作数组,以及判断被收缩的项有没有子集,如果有子集要给一个标记位,来为之后的列表渲染做准备。

 expendRow(rows, expended) {
      // const
      this.DFS_Array(this.form.rows, v => {if (v.customIndex == rows.customIndex) {v.expended = String(expended);
          v.hasChild =
            v.expended === "false" && v.children.length > 0 ? true : false;
        }
      });
      if (!expended) {this.expendArrs = this.expendArrs.filter(v => v !== rows.customIndex);
      } else {this.expendArrs.push(rows.customIndex);
      }
      this.calcList(this.scrollTop);
  },
  DFS_Array(arr, fn) {for (let i = 0; i < arr.length; i++) {fn(arr[i]);
        if (arr[i].children && arr[i].children.length > 0) {this.DFS_Array(arr[i].children, fn);
        }
      }
    }

在收缩之后由于把列表中的 children 整个移除,所以在 el-table 上面的展开箭头就不能正常显示了,因为在渲染数据中并没有子节点,而实际数据中又是有子集的,所以,在上面增加的 hasChild 属性,就起到这个作用,他标记了数据被折叠,且有子集可展开的情况。因此,需要在列表中主动把展开的箭头加一下。

 <el-table-column
            prop="customIndex"
            fixed
            label="序号"
            sortable
            width="180"
            v-slot="{$index, row}"
          >
            <span class="expanded-icon-box">
              <i class="expanded-icon" v-if="row.hasChild" @click="expendRow(row,true)">></i>
              {{row.customIndex}}
            </span>
          </el-table-column>

至此 树形列表的虚拟列表也整合完毕了。本次示例代码很多地方比较仓促,待优化情景较多,除了拼凑新的树那里,还有滚动的缓存,如果树比较大的话,js 的计算时间也要考虑入内,还有渲染的虚拟列表应该不从本身的第一位开始进入视口,这样的话,在一定范围的向上向下滚动,就可以一定程度的减少白屏。

总结

虚拟列表通过减少实际渲染数据来优化性能,在不对 element-ui 做较大改动的情况下,满足了大量数据,包括树形的结构数据的渲染场景。如果考虑之前的列表的表单验证的情景,需要让部分属性脱离引用,如 children,否则会污染源数据,其次让表单数据保持引用关联,这样就不必专门给表单组件设置事件,来匹配源数据的改动,即直接将新的列表的 item 的表单对象等于老的相应表单对象即可。

退出移动版