现下不得不说是个数据的时代,有数据就必定有前端来展示。
杂乱的数据通过数据分析(未碰到的点,不讲请搜),提炼出业务相关的数据维度,而前端所做的就是把这些一个个数据通过不同维度(key-value)的描述来展示到页面上。
除去花哨的展示方式(图表等),展示普通的大量列表数据有两种常用方式,分页和触底加载(滚动加载)。
分页是一种比较经典的展示方式,碰到的问题比较少,最多是因为一页展示的数据量大些的时候可以用图片懒加载,来加速一些(不过基本一页也不太会超过200个,不然就失去了分页的意义了)。
而最近在实现滚动加载时,出现了卡顿的情况。
数据量:1500左右;
数据描述形式:图片 + 部分文字描述;
卡顿出现在两个地方:
卡顿首先想到是渲染帧被延长了,用控制台的Performance查看,可以看出是重排重绘费时间:
如图,Recalculate Style占比远远大于其他,一瞬间要渲染太多的卡片节点,重排重绘的量太大,所以造成了主要的卡顿。
因此,需要减少瞬间的渲染量。
首先针对最主要的减少瞬间渲染量,逐步由简入繁尝试:
由定时器来操作,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)
}
})
}
...
}
结果:首屏渲染速度变快了,不过滚动和事件响应还是略卡顿。
原因分析:滚动的时候还是有部分数据在渲染和加载,其次图片资源的加载渲染量未变(暂未作图片懒加载)。
滚动触发,好处在于只会在触底的情况下影响用户一段时间,不会在开始时一直影响用户,而且触底也是由用户操作概率发生的,相对比下,体验性增加。
此处有两种做法:
上述两种做法,可视数据的具体数量决定(据同事所尝试,两三万个数据的获取时间在1s以上,不过这个也看数据结构的复杂程度和后端查数据的方式),决定前可以调后端接口试一下时间。
例:结合我本次项目的实际情况,不需要一次性获取所有的数据,可以一次性获取一个时间点的数据,而每个时间点的数据不会超过3600个,这就属于一个比较小的量,尝试下来一次性获取的时间基本不超过500ms,于是我选择第二种
先一次性获取所有数据,由前端控制滚动到距离底部的一定距离,push一定量的数据到展示列表数据中:
data() {
return {
timer: null,
list: [], // 存储数据的列表
showList: [], // html中展示的列表
isLoading: false, // 控制滚动加载
currentPage: 1, // 前端分批次摆放数据
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)
},
结果:首屏渲染和事件响应都变快了,只是滑动到底部的时候有些许卡顿。
原因分析:滑动到底部的卡顿,也是因为一瞬间渲染一堆数据,虽然比一次性展示所有的速度快很多,但是还是存在相比一次性展示不那么严重的重排和重绘,以及图片不停加载渲染的情况。
图片懒加载可以解决每次渲染数据的时候因为图片按加载顺序不停渲染产生的卡顿。
滚动触发使用点2的代码。
提取通用的图片组件,通过滚动事件的全局触发,来控制每个数据项图片的加载:
如上,点2中已经在handleScroll
中设置了 this.bus.$emit('scroll') // 触发全局的滚动监听,用于图片的懒加载
// main.js
Vue.prototype.bus = new Vue()
...
以下的在template中写js不要学噢
// components/DefaultImage.vue
结果:在点2首屏展示快的基础上,事件交互更快了,触发展示数据也快了。
原因分析:防抖的图片懒加载之后,只在用户滚动停止时,加载视口内的图片,就没有后续不断的加载渲染图片,也就不会因为不停渲染图片而影响事件交互和基础的无图卡片渲染。
以上一顿操作之后已经符合本项目的需求了。
不过我研究了一下进阶操作
还可以只渲染视口元素,非视口用padding代替,以及把计算过程放在Web Worker多线程执行,进一步提升速度。
转载:https://segmentfault.com/a/1190000038351930