最近暂时脱离了演示项目,开始了公司内比较常见的以表单和列表为主的项目。
干一个,爱一个了。从开始的觉得自己都做了炫酷的演示项目了,这对我来说就是个小意思,慢慢也开始踩坑,有了些经验总结可谈。
现下不得不说是个数据的时代,有数据就必定有前端来展示。
杂乱的数据通过数据分析(未碰到的点,不讲请搜),提炼出业务相关的数据维度,而前端所做的就是把这些一个个数据通过不同维度(key-value)的描述来展示到页面上。
除去花哨的展示方式(图表等),展示普通的大量列表数据有两种常用方式,分页和触底加载(滚动加载)。
分页是一种比较经典的展示方式,碰到的问题比较少,最多是因为一页展示的数据量大些的时候可以用图片懒加载,来加速一些(不过基本一页也不太会超过200个,不然就失去了分页的意义了)。
而最近在实现滚动加载时,出现了卡顿的情况。
问题背景:
数据量:1500左右;
数据描述形式:图片 + 部分文字描述;
卡顿出现在两个地方:
- 滚动卡顿,往往是动一下滚轮,就要卡个2-3s
-
单个数据卡片事件响应卡顿:鼠标浮动,本应0.5s向下延展,但是延展之前也会卡个1-2s;鼠标点击,本
分析过程:
-
卡顿首先想到是渲染帧被延长了,用控制台的Performance查看,可以看出是重排重绘费时间:
如图,Recalculate Style占比远远大于其他,一瞬间要渲染太多的卡片节点,重排重绘的量太大,所以造成了主要的卡顿。
因此,需要减少瞬间的渲染量。 -
渲染的数据项与图片渲染有关,于是会想到图片资源的加载和渲染,看控制台的Network的Img请求中,有大量的pending项(pending项参考下图所示)。
一瞬间图片加载的太多,因此可以作懒加载优化。
解决过程:
首先针对最主要的减少瞬间渲染量,逐步由简入繁尝试:
1. 自动触发的延时渲染
由定时器来操作,setTimeout
和setInterval
都可以,注意及时跳出循环即可。
我使用了setTimeout
来作为第一次尝试(下面代码为后续补的手写,大概意思如此)
使用定时器来分页获取数据,然后push进展示的列表数据中:
data() {
return {
count: -1,
params: {
... // 请求参数
pageNo: 0,
pageSize: 20
},
timer:null,
list: []
}
},
beforeDestroy() {
if (this.timer) {
clearTimeout(this.timer)
this.timer = null
}
},
methods: {
getListData() {
this.count = -1
this.params = {
... // 请求参数
pageNo: 0,
pageSize: 20
}
this.timer = setTimeout(this.getListDataInterval, 1000)
},
getListDataInterval() {
params.pageNo++
if (params.pageNo === 1) {
this.list.length = 0
}
api(params) // 请求接口
.then(res => {
if (res.data) {
this.count = res.data.count
this.list.push(...res.data.list)
}
})
.finally(() => {
if (count >= 0 && this.list.length < count) {
this.timer = setTimeout(this.getListDataInterval, 1000)
}
})
}
...
}
结果:首屏渲染速度变快了,不过滚动和事件响应还是略卡顿。
原因分析:滚动的时候还是有部分数据在渲染和加载,其次图片资源的加载量未变(暂未作图片懒加载)。
2. 改为滚动触发加载(滚动触发下的“分页”形容的是数据分批次)
滚动触发,好处在于只会在触底的情况下影响用户一段时间,不会在开始时一直影响用户,而且触底也是由用户操作概率发生的,相对比下,体验性增加。
此处有两种做法:
- 滚动触发“分页”请求数据,
缺点:除了第一次,之后每次滚动触发展示数据会比下一种耗费多一个请求的时间 - 一次性获取所有数据存在内存中,滚动触发“分页”展示数据。
缺点:第一次一次性获取所有数据的时间,比上一种耗费多一点时间
上述两种做法,可视数据的具体数量决定(据同事所尝试,两三万个数据的获取时间在1s以上,不过这个也看数据结构的复杂程度和后端查数据的方式),决定前可以调后端接口试一下时间。
例:结合我本次项目的实际情况,不需要一次性获取所有的数据,可以一次性获取一个时间点的数据,而每个时间点的数据不会超过3600个,这就属于一个比较小的量,尝试下来一次性获取的时间基本不超过500ms,于是我选择第二种
先一次性获取所有数据,由前端控制滚动到距离底部的一定距离,push一定量的数据到展示列表数据中:
data() {
return {
timer: null,
list: [], // 存储数据的列表
showList: [], // html中展示的列表
isLoading: false, // 控制滚动加载
currentPage: 1, // 前端分批次摆放数据,currentPage实为下一页
currentPageSize: 50, // 前端分批次摆放数据
lastListIndex: 0, // 记录当前获取到的最新数据位置
lastTimeIndex: 0, // 记录当前获取到的最新数据位置
}
},
created() { // 优化点:可做可不做,其中的数值都是按照卡片的宽高直接写入的,因为不是通用组件,所以从简。
this.currentPageSize = Math.round(
(((window.innerHeight / 190) * (window.innerWidth - 278 - 254)) / 220) * 3
) // (((window.innerHeight / 卡片高度和竖向间距) * (window.innerWidth - 列表内容距视口左右的总距离 - 卡片宽度和横向间距)) / 卡片宽度) * 3
// *3代表我希望每次加载至少能多出三个视口高度的数据;列表内容距视口左右的总距离:是因为我是两边固定宽度,中间适应展示内容的结构
},
beforeDestroy() {
if (this.timer) {
clearTimeout(this.timer)
this.timer = null
}
},
methods: {
/**
* @description: 获取时间点的数据
*/
getTimelineData(listIndex, timeIndex) {
if (
// this.list的第一、二层是时间轴this.list[listIdex].timeLines[timeIndex],在获取时间点数据之前获取了
this.list &&
this.list[listIndex] &&
this.list[listIndex].timeLines &&
this.list[listIndex].timeLines[timeIndex] &&
this.showList &&
this.showList[listIndex] &&
this.showList[listIndex].timeLines &&
this.showList[listIndex].timeLines[timeIndex]
) {
this.isLoading = true
// 把当前时间点变成展示状态
if (!this.showList[listIndex].active) {
this.handleTimeClick(listIndex, this.showList[listIndex])
}
if (!this.showList[listIndex].timeLines[timeIndex].active)
this.handleTimeClick(
listIndex,
this.showList[listIndex].timeLines[timeIndex]
)
if (!this.list[listIndex].timeLines[timeIndex].snapDetailList) {
this.currentPage = 1
}
if (
!this.list[listIndex].timeLines[timeIndex].snapDetailList // 第一次加载时间点数据,后面的或条件可省略
) {
return suspectSnapRecords({
...
})
.then(res => {
if (res.data && res.data.list && res.data.list.length) {
let show = []
res.data.list.forEach((item, index) => {
show[index] = {}
if (index < 50) {
show[index].show = true
} else {
show[index].show = true
}
})
this.$set(
this.list[listIndex].timeLines[timeIndex],
'snapDetailList',
res.data.list
)
this.$set(
this.showList[listIndex].timeLines[timeIndex],
'snapDetailList',
res.data.list.slice(0, this.currentPageSize)
)
this.$set(
this.showList[listIndex].timeLines[timeIndex],
'showList',
show
)
this.currentPage++
this.lastListIndex = listIndex
this.lastTimeIndex = timeIndex
}
})
.finally(() => {
this.$nextTick(() => {
this.isLoading = false
})
})
} else { // 此处是时间点被手动关闭,手动关闭会把showList中的数据清空,但是已经加载过数据的情况
if (
this.showList[listIndex].timeLines[timeIndex].snapDetailList
.length === 0
) {
this.currentPage = 1
this.lastListIndex = listIndex
this.lastTimeIndex = timeIndex
}
this.showList[listIndex].timeLines[timeIndex].snapDetailList.push(
...this.list[listIndex].timeLines[timeIndex].snapDetailList.slice(
(this.currentPage - 1) * this.currentPageSize,
this.currentPage * this.currentPageSize
)
)
this.currentPage++
this.$nextTick(() => {
this.isLoading = false
})
return
}
} else {
return
}
},
/**
* @description: 页面滚动监听,用的是公司内部的框架,就不展示html了,不同框架原理都是一样的,只是需要写的代码多与少的区别,如ElementUI的InfiniteScroll,可以直接设置触发加载的距离阈值
*/
handleScroll({ scrollTop, percentY }) { // 此处的scrollTop是组件返回的纵向滚动的已滚动距离,percentY则是已滚动百分比
this.bus.$emit('scroll') // 触发全局的滚动监听,用于图片的懒加载
this.scrolling = true
if (this.timer) { // 防抖机制,直至滚动停止才会运行定时器内部内容
clearTimeout(this.timer)
}
this.timer = setTimeout(() => {
requestAnimationFrame(async () => {
// 因为内部有触发重排重绘,所以把代码放在requestAnimationFrame中执行
let height = window.innerHeight
if (
percentY > 0.7 && // 保证最开始的时候不要疯狂加载,已滚动70%再加载
Math.round(scrollTop / percentY) - scrollTop < height * 2 && // 保证数据量大后滚动页面长的时候不要疯狂加载,在触底小于两倍视口高度的时候才加载
!this.isLoading // 保险,不同时运行下面代码,以防运行时间大于定时时间
) {
this.isLoading = true
let len = this.list[this.lastListIndex].timeLines[
this.lastTimeIndex
].snapDetailList.length // list为一次性获取所有数据存在内存中
if ((this.currentPage - 1) * this.currentPageSize < len) { // 前端分批次展示的情况
this.showList[this.lastListIndex].timeLines[
this.lastTimeIndex
].snapDetailList.push(
...this.list[this.lastListIndex].timeLines[
this.lastTimeIndex
].snapDetailList.slice(
(this.currentPage - 1) * this.currentPageSize,
this.currentPage * this.currentPageSize
)
)
this.currentPage++
} else if (
this.list[this.lastListIndex].timeLines.length >
this.lastTimeIndex + 1
) { // 前端分批次展示完上一波数据,该月份时间轴上下一个时间点存在的情况
await this.getTimelineData(
this.lastListIndex,
this.lastTimeIndex + 1
)
} else if (this.list.length > this.lastTimeIndex + 1) { // 前端分批次展示完上一波数据,该月份时间轴上下一个时间点不存在,下一个月份存在的情况
await this.getTimelineData(this.lastListIndex + 1, 0)
}
}
this.$nextTick(() => {
this.isLoading = false
this.scrolling = false
})
})
}, 500)
},
结果:首屏渲染和事件响应都变快了,只是滑动到底部的时候有些许卡顿。
原因分析:滑动到底部的卡顿,也是一个一瞬间渲染一堆数据的过程,虽然比一次性展示所有的速度快很多,但是还是存在相比一次性展示不那么严重的重排和重绘,以及图片加载堆积的情况。
3. 最终解决方案:滚动触发+图片懒加载
图片懒加载可以解决每次渲染数据的时候因为图片资源加载产生的卡顿。
滚动触发使用点2的代码。
提取通用的图片组件,通过滚动事件的全局触发,来控制每个数据项图片的加载:
如上,点2中已经在handleScroll
中设置了this.bus.$emit('scroll') // 触发全局的滚动监听,用于图片的懒加载
// main.js
Vue.prototype.bus = new Vue()
...
以下的在template中写js不要学噢
// components/DefaultImage.vue
<template>
<div class="default-image" ref="image">
<img src="@/assets/images/image_empty.png" v-if="imageLoading" />
<img
class="image"
v-if="showSrc"
v-show="!imageLoading && !imageError"
:src="showSrc"
@load="imageLoading = false"
@error="
imageLoading = false
imageError = true
"
/>
<img src="@/assets/images/image_error.png" v-if="imageError" />
</div>
</template>
<script>
export default {
name: 'DefaultImage',
props: {
src: String, // 图片源
lazy: Boolean // 懒加载
},
data() {
return {
imageLoading: true,
imageError: false,
showSrc: '', // 渲染的src
timer: null
}
},
mounted() {
if (this.lazy) {
this.$nextTick(() => {
this.isShowImage()
})
this.bus.$on('scroll', this.handleScroll)
} else {
this.showSrc = this.src
}
},
beforeDestroy() {
if (this.lazy) {
this.bus.$off('scroll', this.handleScroll)
}
if (this.timer) {
clearTimeout(this.timer)
this.timer = null
}
},
methods: {
handleScroll() {
if (this.timer) {
clearTimeout(this.timer)
}
this.timer = setTimeout(this.isShowImage, 300)
},
isShowImage() {
let image = this.$refs.image
if (image) {
let rect = image.getBoundingClientRect()
const yInView = rect.top < window.innerHeight && rect.bottom > 0
const xInView = rect.left < window.innerWidth && rect.right > 0
if (yInView && xInView) {
this.showSrc = this.src
this.bus.$off('scroll', this.handleScroll)
}
}
}
}
}
</script>
结果:在点2首屏展示快的基础上,事件交互更快了,触发展示数据也快了。
原因分析:防抖的图片懒加载之后,只在用户滚动停止时,加载视口内的图片,分散请求图片资源,不会造成请求图片资源堆积,也就不会因为不停渲染图片而影响事件交互和基础的无图卡片渲染。
以上一顿操作之后已经符合本项目的需求了。
不过我研究了一下进阶操作 🤔
还可以只渲染视口元素,非视口用padding代替,以及把计算过程放在Web Worker多线程执行,进一步提升速度。
*这篇已经太长啦,进阶操作放下一章了。
下一章:js前端展示大数据量数据的进阶操作