一. 长列表性能问题
做移动端h5项目,经常会遇到的问题就是长列表展示,数据较少的时候还好,如果数据量过大时就会出现明显的性能问题,加载特别慢甚至卡死。
所以长列表性能优化是很常见的问题,我们项目组件库用的antd-mobile,所以首先想的是用antd-mobile的ListView,但是antd mobile的解决方案是先加载可视区域的数据,向下滑动时再加载下一部分数据,之前的渲染的元素不会清除。这样如果数据量特别大的话,页面上dom元素会很多,也会影响性能。
由于我们的每一行高度都是固定的,场景比较简单,所以决定自己写一个动态加载的列表。
二. 设计思路
虽然列表很长,但是大部分区域是看不到,所以只需要渲染可视区域的元素就可以了,用户在滑动的时候更新渲染的数据即可。
三. 代码设计
1.最简单实现
实现该方案,最重要的是确定两个参数,
- 开始数据的index,声明为startIndex
- 结束数据的index,声明为endIndex
观察上图就可以发现,startIndex就是背景真实列表的scrollTop除以每一行的高度,而endIndex就是startIndex加可视区域能显示的item数量,这个数量就是可视区域高度除以每一行的高度,也就是以下代码
每一个item行高rowHeight,这个是固定的,给定一个44px(我们项目中用的rem,只需要一个简单的计算即可),先写代码将页面展示出来。
//./index.jsx
import React from 'react';
import './index.less';
let rowHeight = 44;
class TestList extends React.Component {
constructor(props) {
super(props);
let data = new Array(100000);
for (let index = 0; index < data.length; index++) {
data[index] = index;
}
this.allData = data;
this.state = {
dataSource: []
};
this.startIndex = 0;
}
componentDidMount() {
const containerH = document.documentElement.clientHeight;
this.size = Math.ceil(containerH / rowHeight) + 1;
this.endIndex = this.startIndex + this.size;
this.setState({
containerH,
dataSource: this.allData.slice(this.startIndex, this.endIndex)
})
}
scroll(e) {
//核心方法,下一步再写。
}
render() {
const { dataSource, containerH } = this.state;
const renderRow = (rowData, index) => {
const top = this.startIndex * rowHeight + rowHeight * index;
return (
<div key={rowData}
className='item-row'
style={{ position: 'absolute', top: top }}>
<span>{rowData}</span>
</div>
);
};
return (
<div
style={{ height: containerH }}
className='outer-cont'
onScroll={(e) => this.scroll(e)}
>
<div className="inner-cont" style={{ height: this.allData.length * rowHeight }}>
{dataSource.map((row, index) => renderRow(row, index))}
</div>
</div>
);
}
}
export default TestList;
//./index.less
.outer-cont {
position: relative;
overflow: auto;
.inner-cont {
position: relative;
.item-row {
width: 100%;
height: 44px;
border-bottom: 1px solid;
}
}
}
这个实现只展示了最开始的几条数据,滑动的时候数据还不能刷新,下面根据之前的分析添加onScroll方法。
scroll(e) {
//如果数据量小于size,就不处理了
if (this.allData.length <= this.size) return;
const top = e.target.scrollTop;
//获取scrollTop,计算startIndex和endIndex
const topIndex = Math.floor(top / rowHeight);
//如果滑动量太小,index没有改变则不动。
if(this.startIndex === topIndex) return
this.startIndex = topIndex;
this.endIndex = this.startIndex + this.size;
//输出一下看看
console.log(this.startIndex);
//更新数据
this.setState({
dataSource: this.allData.slice(this.startIndex, this.endIndex)
})
}
上下滑动发现显示是没有问题的,但是每滑动一下都会触发数据刷新,刷新频率太高了,所以想着做一个缓冲去,一次多缓冲一些dom。
2. 缓冲区优化
基本思路如图,每次刷新数据的时候,多渲染一些dom作为缓冲,当快要滑动出缓冲区域的时候,再更新数据。
现在需要确定的细节就是,什么时候刷新数据。
- 一开始的时候,可视区域还是在安全的区间内,在这个范围内移动,不需要刷新数据。
- 当用户下滑,可视区域超出安全区域了,这个时候startIndex就需要向下移动。
- 相反,startIndex就需要向上移动。
- 其实2和3的情况不需要做区分,只需要在出了安全区域之后,移动startIndex,让topIndex落到安全区域即可,那就放在缓冲区域的最中间吧,这样算法就简单了, topIndex = startIndex + cacheSize/2。
好了,暂定cacheSzie = 20
import React from "react";
import "./index.less";
let rowHeight = 44;
class TestList extends React.Component {
constructor(props) {
//和之前代码一样
...
this.cacheSize = 20;
}
componentDidMount() {
//初始化的时候加缓冲区
this.setState({
containerH,
dataSource: this.allData.slice(
this.startIndex,
this.endIndex + this.cacheSize
),
});
}
scroll(e) {
if (this.allData.length <= this.size) return;
const top = e.target.scrollTop;
let topIndex = Math.floor(top / rowHeight);
//判断是否出了安全区域,这块的数据可以自己调整
if (topIndex - this.startIndex < 3 || topIndex - this.startIndex > 17) {
//按照上面的分析,重置startIndex
let index = topIndex - cacheSize / 2 < 0 ? 0 : topIndex - this.cacheSize / 2;
if (this.startIndex === index) return;
this.startIndex = index;
console.log(this.startIndex);
this.endIndex = this.startIndex + this.size;
this.setState({
dataSource: this.allData.slice(
this.startIndex,
this.endIndex + this.cacheSize
),
});
}
}
render() {
//代码和之前一样
...
);
}
}
export default TestList;
再测试一下
刷新频率明显降低,每滑动8行刷新一次。
3.onScroll优化
做到这里没有太大的问题了,还有一个优化点时onScroll的问题,滑动的时候onScroll触发的次数非常多,这就导致需要做很多次计算,优化onScroll第一个想到的就是防抖,我写了一个简单的防抖函数,测试了一下,发现页面会出现短暂空白,用户体验很不好。在这个过程中,还发现react组件中用防抖的一个需要注意的点,异步方法中需要使用event的话,需要添加event.persist()方法。
所以放弃防抖函数的方法,在这里贴出来记录一下。
debounce(fn, delay = 100) {
let timer = null;
return function (event) {
let context = this;
event.persist && event.persist();
if (timer) clearTimeout(timer);
timer = setTimeout(function () {
fn.call(context, event);
}, delay);
};
}
虽然防抖不行,但是还是有别的方法的,查了资料,看到有人说可以用IntersectionObserver,这个我还没有测试过,也不知道效果怎么样,后面测试完了再更新文章。