我们开发过程中经常会遇到这样的需求,一个数据量很大的列表。页面打开,加载第一页的数据,每次往下滚屏到接近底部,会加载下一屏,这样也是保证获取数据的http请求量是按需加载的。但是,随着我们一直加载,产生的DOM 会越来越多,内存占用也会越来越高。这时候如果有千上万的数据,就会产生大量的虚拟 DOM 和真实DOM,大量占用内存。
下面这几种滚动场景,都有可能造成卡顿
- 滚动加载,出现无限滚动时,可能给 DOM 过多页面卡死。
- 滚动加载的组件,DOM 过多,销毁组件的时候也会出现卡死。
- 任何滚动的元素,内部元素过多,都会出现卡顿现象。
针对上面的问题,我们能否有什么优化的方案?
这三类问题都有一个相似点,大量的并列的 DOM,但是这些 DOM 都在一个可滚动的容器中,无论怎么滚动,只有一部分是可见的,滚动的 DOM 元素中只展示可视区域展示的功能。如下图:

所以如果只有可见的数据才去创建虚拟 DOM,就会大量节省内存,并加快渲染速度。外元素滚动时,在非可视区域的 DOM,都暂时转存到页面的data中,但不需要渲染出来。
OK,列出问题 & 大体的解决方案后,我们来看看项目中的现有轮子
项目中的轮子: T-List
基于 ScrollView 的下拉刷新、上拉加载、无限滚动组件。
优点: 可以实现 DOM 回收,渲染速度很快。
缺点: 回收机制为新加载一条,回收最上面一条,节点限制在 12 个,对于每条高度自适应样式时,滚动条剧烈抖动,用户体验差。
现有轮子体验差,我决定对它重新优化下成为我的轮子。
我的轮子 - 限制显示页数的无限滚动列表
基于页面的下拉刷新 、 上拉加载 、无限滚动。
优点: 回收机制采用新加载一页,回收最上面一页,上滑翻页时加载上一页,回收最后一页,滚动条高度稳定且长度也稳定。且不可见区应用了骨架屏,快速滑动时,数据还未加载出来会看见骨架屏,用户体验好。

根据这个想法设计的解决方案,思路有几个关键点
- 记录数据区间:记录每页数据尺寸,待新一页的数据渲染完毕后,记录外盒(装载此数据的父元素)的高度,计算每页数据所占高度(此次外盒高度-上次外盒高度)。
- 记录滚动条位置:记录每次触底加载时滚动条的位置,在页面添加一个锚点,定位顶对齐的空元素。
- 判断当前页数:根据当前滚动位置,与之前记录的滚动条位置进行比较,触发上翻页或下翻页。
- 渲染可见页列表:只渲染出当前页和上一页的数据,不可见页用空元素占位,高度为之前记录过的当页数据的高度,这样可防止高度塌陷而抖动,且滚动条长度稳定。
解决方案的过程
记录数据区间和滚动条位置
页面结构
ID 为 anchor
的元素当做锚点,它的 position 采用绝对定位 top:0
,所以它的 top 值的相反数则为页面滚动条的位置。
ID 为 scrollList
的元素为滚动列表,装载迭代数据,每一项为一页数据,可以获取它的高度。
<View>
<View id='anchor'></View>
<View className="pre-list-title">滚动测试</View>
<View id='scrollList'>
{list.map(listitem => (
((listitem.group_id > 0) &&
(listitem.group_id == page_no || listitem.group_id == page_no - 1)) ? (
<View id={'page_no' + listitem.group_id} key={listitem.group_id}>
<View className="pre-batch-title fc6">第{listitem.group_id}页</View>
{listitem.group.map(item => {
return <Card key={item.id} item={item} onClick={this.onPreviewImage}/>
})}
</View>
): (
<View id={'page_no' + listitem.group_id}
style={{height: IsEmpty(paddingHeight[listitem.group_id]) ? 0
: paddingHeight[listitem.group_id].height + 'px'}}>
{listitem.group.map((item, index) => {
return index < paddingHeight[listitem.group_id].height / 300 ? <Card /> : null;
})}
</View>
)))}
</View>
</View>
获取需要的元素的尺寸及位置等信息逻辑
/**
* @description 获取滚动列表的尺寸和位置,锚点的位置
* 返回参数res
* res[0] 滚动列表的尺寸和位置等信息
* res[1] 锚点的位置等信息
*/
getElement = ({
callback
}) => {
let query = Taro.createSelectorQuery().select('#scrollList').boundingClientRect()
query.select('#anchor').boundingClientRect()
query.exec(res => {
console.log('scrollList',res)
callback(res)
})
}
获取结果

判断当前页数
根据滚动条位置判断上翻下翻

/**
* @description 页面滚动事件
* @memberof Index
*/
onPageScroll = e => {
this.throttle({
callback: () => {
const {page_no, paddingHeight} = this.state
console.log(e.scrollTop,page_no)
if(!IsEmpty(paddingHeight[page_no - 1]) && e.scrollTop < paddingHeight[page_no - 1].top - 800) {//上翻一页
this.up(page_no)
}else if (!IsEmpty(paddingHeight[page_no]) && page_no < paddingHeight.length -1 && e.scrollTop > paddingHeight[page_no].top - 1000) {//下翻一页,之前加载过的一页
this.next({})
}
},
wait: 1000,
mustRun: 1000
});
};
这里的 throttle 方法为防抖节流函数,如果想提高反应速度可减小 wait 和 mustRun 的值。
上翻页事件
/**
* @description 上翻一页
* @memberof Index
*/
up = (page_no) => {
console.log('上页',page_no,'-',page_no -1)
this.setState({
page_no: page_no - 1
})
}
下翻页事件
/**
* @description 下翻一页
* @memberof Index
*/
next = ({
page
}) => {
let { page_no, page_size, noMore, paddingHeight, list } = this.state;
if(!IsEmpty(page) && page == 1) {//如果为第一页,初始化数据
page_no = 0
paddingHeight = [
{
page_no: 0,
paddingtop: 0,
height: 0,
top: 0
}
]
noMore = false
}
if((!noMore && page_no == list.length - 1) || page_no == 0) {//下翻一页,未加载过,请求接口
page_no = page_no + 1;
console.log('请求下页数据','page_no',page_no, noMore,)
this.getList({
page_no: page_no,
page_size: page_size,
callback: res => {
let data = [];
let list = [];
//判空
if (!IsEmpty(res)) {
data = res.data;
list = res.list;
}
this.getElement({
callback: res => {
paddingHeight[paddingHeight.length - 1].top = -res[1].top
this.setState({
noMore: data.length < page_no * page_size ? true : false,
page_no,
data,
list,
paddingHeight
},() => {
this.getElement({
callback: ress => {
paddingHeight.push({
page_no: page_no,
height: ress[0].height - paddingHeight[paddingHeight.length - 1].paddingtop,
paddingtop: ress[0].height,
})
console.log('paddingHeight',paddingHeight)
}
})
});
}
})
}
});
}else if(page_no < list.length - 1) {//下翻一页,已加载过的一页
console.log('下页',page_no,'-',page_no + 1)
this.setState({
page_no: page_no + 1
})
}
}
渲染可见列表
根据当前页码 page_no,渲染当前页和上一页,不可见区用骨架屏填充,防止快速滑动时,大面积空白影响视觉效果。
骨架屏放在 Card 组件中,如果不传给 Card 组件任何 props,则 Card 显示骨架屏,否则显示商品卡片
(listitem.group_id == page_no || listitem.group_id == page_no - 1)) ? (
<View id={'page_no' + listitem.group_id} key={listitem.group_id}>
<View className="pre-batch-title fc6">第{listitem.group_id}页</View>
{listitem.group.map(item => {
return <Card key={item.id} item={item} onClick={this.onPreviewImage}/>
})}
</View>
): (
<View id={'page_no' + listitem.group_id}
style={{height: IsEmpty(paddingHeight[listitem.group_id]) ? 0
: paddingHeight[listitem.group_id].height + 'px'}}>
{listitem.group.map((item, index) => {
return index < paddingHeight[listitem.group_id].height / 300 ? <Card /> : null;
})}
</View>
)
效果展示

总结
滚动条无抖动,滚动条长度符合正常比例(滚动条长度 / 屏幕高度 = 屏幕高度 / 页面实际高度)。
快速滑动时显示骨架屏,优化用户等待体验,但是如果数据过多建议不使用骨架屏,因为骨架屏也占节点,过多会卡顿。
限制节点数,每页 10 条,可见区 2 页共 20 条,实现了 DOM 回收。