原始单列表优化
先看优化结果
- 对比图一:当前条件 300 条数据,且不显示所有数据的扩展列,表格滚动时的 cpu 使用率
- 修改前
- 修改后
- 对比图二, 还是 300 条数据,且不显示所有数据的扩展列
- 修改前
- 修改后
- 结论
可以看到修改前滚动时候的 cpu 使用率始终在 95% 以上,体现在页面上的感觉的是卡顿,修改后滚动时 cpu 使用率在 40% 左右,对比修改前大幅度下降,页面卡顿消失
背景
原始单列表在数据多余 300 条的时候会出现滚动卡顿(不是一般的卡,还出现过几次浏览器直接给整崩溃了),产品提出需要优化
表格大致长这样,直接上图(忽略掉我浏览器的黑暗模式):
结构分层如下:
可以看出,每列数据都有一个【宝贝】的开关用于显示该列的扩展列,扩展列里面有一个商品详情的表格(还有顾客留言、订单转化情况 两部分,这里没显示)
而且有的数据需要合并单元格,同时合并为一个单元格的多列需要共用一个扩展列。
所以当所有数据都显示扩展列的时候,相当于除了外层最大的表格外,每个扩展列都需要渲染额外的表格。
而且表格分页的最大数量我们给限制了 500 条。。。。。(天啊,300 条数据就卡成狗,500 条还不得崩溃
原因分析
简单粗暴就是 dom 节点太多表格数据复杂导致的。 什么? 3 百条数据就卡顿了,你这也太 xxxxxxxxxxxx 了吧
表格显示使用的是自定义的表格组件,由于 iview3.x 的表格不是很符合我们的业务场景,所以我们自己封装了一个表格(
固定头部,拖拽改变列宽,动态调整表格顺序,动态控制列显示隐藏,指定列单元格内容合并, 自定义扩展列内容,动态控制扩展列显示,以及常规的选择框,自定义序号 等
)
- 问:这些功能组件库的 table 组件不都有吗
- 答:你去看看 iview3 的 table 组件,看了你想哭)
iview 官网上的 table 组件的例子:
看到没,固定列的表格和底层表格根本对不齐,歪七扭八的。。。。
- 问: 那为什么不选其他组件库,比如 element 的表格不就挺好的吗
- 答: 项目历史悠久,且公司其他业务线的老项目全部都是使用 iview 组件库,应该是当时和其他业务线统一决定的,新项目目前均采用 vue3 + antd
由于采用自定义的表格,所以在扩展列我们又在里面塞了一个表格用于显示原始单的商品详情,所以在渲染 300 条数据的时候,实际上是渲染了
602
个表格(测试了下,节点数目大概在 15w 个节点),所以导致表格滑动卡顿的最大元凶找到了再看下其他会影响性能的东西————需要实时计算位置的元素,表格中表头或者部分数据都有浮层提示(iview 的 Tooptip 组件),每一列各种各样的气泡提示大概有(8-14 个, 有些需要特定条件才有),结合第三点大概可以知道也渲染了近
6000 个 Tooptip 组件
,而这个组件是需要实时计算出浮层位置的
综上,就是原始单页面表格卡顿的原因了
固定列表格的基本实现思路:
固定表格左侧某几列,包括其他组件库的实现原理基本一致,都是在单独渲染一个一模一样的表格,使用绝对定位覆盖在底层表格上,基本实现如下:
这时只需要在绝对定位的 table 外层加上一个 div, 并且 overflow: hidden
就能实现表格左侧固定,div 的宽度动态计算即可
缺点: 需要渲染两遍 table, 增加大量的 dom
,且需要同步底层表格和外层表格的滚动位置,需要监听滚动 (试过使用节流来限制滚动的触发,但是在触发间隔 50ms 的时候还是会出现肉眼可见的滚动不同步)
找到原因后看下表格滑动时的一些指标:
- 查询 300 条数据时
接口性能
- 关闭所有扩展列时的
cpu 和节点数
可以看到滚动时的 cpu 占用率接近 100%, 节点数为 8 万 7 千多个
- 打开所有的扩展列时的
cpu 和节点数
此时的节点数已经达到 30 多万个了,滚动时真的直接变成 PPT 了
解决方法
最开始想的是去除多余节点(针对那 6000 多个实时计算位置的元素),把无关紧要的 tooptip 改成 html 标签自带的 title 属性代替,减少节点数目(没错,我就是想偷懒),
结果:在不显示表格所有扩展列的时候效果不错,不会出现卡顿的情况(比不改之前好了一点),但是显示扩展列一显示,好吧,在几百个表格面前,这点改动相当于没有。
用一句话形容就是 改了,但没有完全改了
然后还是决定采用 虚拟列表
解决,之前项目中已有根据这个自定义表格重写的虚拟表格,那原始单页面为什么不一开始就有虚拟列表哪个组件呢
。。。。。。你问我,我问谁啊,这项目都 6 年了,老子才来这上班一年
(写了这么多的字,你倒是贴点代码啊,公司代码,不方便贴出,后面弄一个自己的简单实现)
虚拟表格组件相比自定义组件的优化点:
- 取消监听动态设置
scrollTop
的做法,改用transform: translateY(${this.innerTop}px)
- 优化显示效果,减少渲染节点
虚拟列表的具体实现可以参考这里: 【点我】
开始改造
1. CV 大法护体
先看项目中其他使用到虚拟表格的页面怎么使用,嗯看起来好像和原先的自定义表格的入参,触发的时间名称
soga 我明白了,直接改组件名字就行了,完美
这当然是不行的啦,首先虚拟列表的一个必要条件就是必须知道表格每一列的高度是多少,这样才能算出虚拟列表具体需要显示的是那些数据
看看原始单页面的表格大概长啥样:
可以看出每一列的高度不固定且不确定,而且存在合并单元格的行,存在合并单元格的几行共用一个扩展列,一行数据就存在或不存在独立的扩展列,本来以为工作量很小的,越改发现问题越多
而且这个优化需求产品要求 9 月份前上线(8 月 23 号提的优化需求,除去休息日开发和测试只剩下 5 天。。。。。),如果要重写整个表格的显示逻辑和显示效果的话,不仅需要改写原先的虚拟表格,测试阶段还需求回归所有改过的功能点,按期上线风险较大。
2. 修改方案
在进一步和产品确认后,发现大多数用户需要页面显示几百条数据的情况都是在进行批量操作,如订单批量作废、批量审核等,对于这部分场景,用户都是数据加载出来后直接全部勾选然后进行下一步的具体操作,至于页面显示的是啥,基本没怎么看。
所以在确认大多数用户的使用场景后,将方案改为延迟加载表格数据,这样就比较简单了
相比改造为虚拟列表,延迟加载列表的优势
- 不需要更换当前组件,页面逻辑不用改动,测试只需要测试全选功能是否正常
- 原先组件更改范围小,props 通过传入 isNeedLazyLoad 判断是否开启延迟加载
- 项目中还存在大量高度不固定的表格使用和原始单一样的表格组件,一次修改,其他页面优化时只需要加个 props 就行,不用更换组件,减少测试时间
(当然,如果不是上线时间比较急的话,还是换成虚拟列表好一点,但是需要产品重新出设计稿修改当前表格的显示逻辑,比如扩展列改为弹窗,高度不固定的数据改为行内超出滚动)
WARNING
延迟加载
主要使用的技术是 IntersectionObserver
是这个 api,具体的使用方式可以看我的另一篇文章 useIntersectionObserver
<template>
<!-- 简单实现 -->
<div>
<table>
<thead>
<tr>
......
</tr>
</thead>
</table>
</div>
<!-- tbody 和 thead 分开是为了可以固定表头 -->
<div ref="body-content">
<table>
<tbody>
<tr v-for="item in rows">
.......
</tr>
<div ref="lazyLoadRef"></div>
</tbody>
</table>
</div>
</template>
<script>
export default {
props: {
tableList: { // 所有的数据
type: Array,
default: () => []
},
isNeedLazyLoad: { // 是否开启延迟加载数据
type: Boolean,
default: false,
},
lazyAddDataNum: { // 每次延迟加载的数量
type: Number,
default: 15
}
},
data() {
return {
rows: [],
allRows: [],
ob: null,
isSupportInterSectionObserver: false
}
},
watch: {
tableList(newVal) {
this.formatRows()
}
},
mounted() {
this.isSupportInterSectionObserver = this.isNeedLazyLoad && !!window.IntersectionObserver ? true : false
if (this.isSupportInterSectionObserver) {
this.setInterSectionObserver()
}
},
unMounted() {
if (!!this.ob) {
this.ob.unobserve(this.$refs['interSectionObserverDom']);
this.ob = null;
}
}
methods: {
setInterSectionObserver() {
this.ob = new IntersectionObserver((entries) => {
entries.forEach(item => {
// 元素进入可见区域
if (item.isIntersecting) {
const allLength = this.allRows.length;
const currentLength = this.rows.length;
if (allLength - currentLength > 0) {
if (allLength - currentLength <= this.lazyAddDataNum) {
this.rows = [...this.rows, ...this.allRows.slice(currentLength)];
} else {
this.rows = [...this.rows, ...this.allRows.slice(currentLength, currentLength + this.lazyAddDataNum)];
}
}
}
})
}, {
root: this.$refs['body-content'],
threshold: 0.5, // 可见区域百分比
})
const target = this.$refs['interSectionObserverDom'];
this.ob.observe(target)
},
formatRows() {
this.allRows = this.tableList.map((item) => {
return Object.assign(item, {
// 合并单元格的
rowspan: item.hasOwnProperty('rowspan') ? item.rowspan : 1,
})
});
// 传入的所有数据数量小数每次延迟加载的数据 / 没开启延迟加载 / 浏览器不支持 IntersectionObserver 这个 api 的都直接渲染所有数据
if (this.allRows.length < this.lazyAddDataNum || !this.isSupportInterSectionObserver) {
this.rows = this.allRows;
} else {
this.rows = this.allRows.slice(0, this.lazyAddDataNum)
}
// 。。。。。。。。。其他具体操作
},
}
}
</script>
到这,大工告成,看下对比图