小程序长列表渲染优化另一种解决方案
前言
有些需求需要展示长列表,无限下拉都会一直显示出更多的数据。但是当一个页面展示的DOM节点过多的时候,会造成小程序页面的卡顿严重的会直接白屏。
原因有以下几点:
- 列表数据很大,不断获取下一屏的数据,setData的数据越来越多的时候耗时高
- 渲染DOM 结构多,每次 setData 都需要创建新的虚拟- 树、和旧树 diff 操作耗时都比较高
- DOM 结构多,占用的内存高,造成页面被系统回收的概率变大,会白屏
针对这个场景,小程序官方已经有一个解决方案recycle-view:但是使用之后,我发现了很多问题,比如下一页的页面渲染不完整,或者拉取下一页的数据会闪屏。
这些问题都已经反馈给相关开发,但是还没有得到回复,所以我也不确定是不是我没有用对,万一等了半个月最后得到的结论是,官方组件不能满足我们的场景,那就GG了。所以我只能暂时先追求另外一种解决方案了。
通过查看官方文档跟组件代码,可以看到他们的实现思路是这样的:
由此我猜想,为什么会出现渲染不完的情况,应该是由于它需要靠着用户提供的item的高度来算哪些item需要渲染,然后来计算应该渲染出来的屏幕高度。那如果这个计算渲染屏幕高度偏少,就会有渲染不完的情况,计算的高度偏多,又会有渲染出空白的情况。当然我没有去调试它的代码,毕竟看别人的代码是痛苦的,只是瞎猜一通而已。
既然我猜他是因为高度的问题,才出现那么多问题,同时依赖用户提供高度我觉得总是不靠谱,万一他给错了,就会使得渲染有问题。可不可以不要知道item的高度也可以知道哪些元素被渲染出来呢?
答案是可以的。
我们可以以一屏为一个单位,而不是以一个item为一个单位,这样我无需开发者给我提供他的高度。我自己去记录每一屏的高度,然后onscroll的时候,根据scollTop来计算当前应该渲染哪一屏,我把它首尾两屏的元素也一起加起来算是我总的需要渲染元素。
有人可能会说:如果我是一屏拉取所有的数据而不是分屏,那你这个方法就不可行了。确实是这样的,但是我觉得应该没有什么场景需要一次性拉取所有的数据。原因如下:
1、一屏你拉取所有节点,用户根本看不完,所以意义不大
2、数据太大,造成网络传输慢
3、setState也慢造成首屏慢
我感觉没啥好处,坏处倒是挺多的。所以不太建议一屏直接返回所有数据。
实现思路:
这里我们通过改造一个通过分屏无限下拉长列表来说一下整个的实现思路。
https://developers.weixin.qq.com/s/I9KsIKmo7yhz
这是一个长列表,它一屏获取20个数据,由于元素都长的一样,所以我用文案标明当前渲染的第几个元素。特别说明一下:由于我每一个item只渲染一个元素,并且渲染的数据也很简单,所以不会有我说的那些卡顿,白屏的问题。但是后面的实践可以用户复杂的项目中。
我的实现方案分为下面两步:
1、将渲染列表的数组list改成二维数组。
2、只渲染当然可视区域的那一屏以及它前后一屏的元素。其他用空白div占位
要做到第二步,我们还需要分成三小步
- 需要知道每一屏的高度,这样我们才能给这个占位的空白div元素设置高度。
- 渲染下一屏last的数据,除了保留last,以及last-1那一屏的渲染,其他节点应该为空。
- 如果是获取非最后一屏幕的,通过监听onscrollTop 获取到scrollTop的值,算取当前应该渲染哪一屏,在重新组装数据setState
第一步
1、将渲染列表的数组list改成二维数组
为什么要改成二维数组:
因为我们是以一屏的数据为单位的,所以用二维数组来装每一屏的数据,方便以屏为单位渲染元素。
原来我们是不停的获取到新一屏的数据就不断的concat进来,再去setState
这样的话,到后期这个finalArr会越来越大,由于setData的调用会把数据从逻辑层传到渲染层,数据太大会增加通信时间。在性能不好的机型上,setState就会占用很长的时间,从而造成页面卡顿。
而我们通过改成二维数组,二维数组里的每一个子数组都用来装一屏的数据,然后每次只setState当前的下一屏幕的数据,就可以减少这个通信时间。
说了那么多,来看看具体如何操作。
首先需要给每一屏一个索引值index来来表示当前是第几屏,这里我们用wholePageIndex来表示。
onLoad: function() {
this.wholePageIndex = 0;
this.index= 0 ;
const arr = [
{
idx: this.index++
},
{
idx: this.index++
},
{
idx: this.index++
}
]
this.setData({ ['list[' + this.wholePageIndex + ']']: arr })
},
然后setData的时候,只是将当前这一屏幕的数据赋值。同理,下拉刷新之后获取渲染下一屏的数据也一样,只是需要给wholePageIndex加1表示是下一屏,在赋值即可。
this.wholePageIndex = this.wholePageIndex + 1;
this.setData({ ['list[' + this.wholePageIndex + ']'] : arr })
完成后的代码如下:
https://developers.weixin.qq.com/s/LSQsSKmj7bhb
这样我们就可以看到,整个渲染是以屏为单位来渲染,比如当前渲染了三屏,那么dom的结构如下:
第二步
2、只渲染当然可视区域的那一屏以及它前后一屏的元素。其他用空白div占位来省去渲染过多DOM节点造成的白屏问题
这里我做了第一屏,或者最后一屏,渲染两屏的数据即可。因为首屏没有上面一屏,最后一屏没有下一屏。
前面说了要做到这一步,我们还得拆分成好几步:
- 需要知道每一屏的高度,这样我们才能给这个占位的空白div元素设置高度。
- 渲染下一屏last的数据,除了保留last,以及last-1那一屏的渲染,其他节点应该为空。
- 如果是获取非最后一屏幕的,通过监听onscrollTop 获取到scrollTop的值,算取当前应该渲染哪一屏,在重新组装数据setState。
在开始工作之前,先定义几个变量一会使用。
wholeVideoList:用来装所有屏的数据
currentRenderIndex: 当前正在渲染哪一屏
pageHeightArr: 用来装每一屏的高度
windowHeight: 当前屏幕的高度
在onLoad里定义
onLoad: function() {
this.index = 0 ;
this.wholePageIndex = 0;
this.wholeVideoList = []; // 用来装所有屏的数据
this.currentRenderIndex = 0; // 当前正在渲染哪一屏
this.pageHeightArr = []; // 用来装每一屏的高度
this.windowHeight = 0; // 当前屏幕的高度
const arr = [
{
idx: this.index++
},
{
idx: this.index++
},
{
idx: this.index++
},
{
idx: this.index++
}
]
this.setData({ ['list[' + this.wholePageIndex + ']']: arr })
}
同时为了区分好每一屏,我们给每一屏的最外层的div都加一个id: wrp_{{pageIndex}}。
<view class="page">
<view wx:for="{{ list }}" id="wrp_{{pageIndex}}" wx:for-index="pageIndex" wx:for-item="listSingleItem" wx:key="index">
<view wx:if="{{ listSingleItem.length > 0 }}">
<view class="wrp" wx:for="{{ listSingleItem }}" wx:for-index="index" wx:for-item="listItem" wx:key="index">
当前是第{{ listItem.idx }}个元素,为第 {{ pageIndex }} 屏数据
</view>
</view>
<view wx:else style="height: {{ listSingleItem.height}}px">
</view>
</view>
</view>
完成之后,效果如下:
这样,每一屏的最外层都有一个带id的元素包裹,我们将要利用它帮助我们去获取这一屏的高度。
做完预备工作之后,接下来就可以完成我们的第一步骤:获取每一屏幕的高度,在这里我把获取高度放进一个setHeight的函数来完成。
注意:这一步一定要在setState之后,也就是页面渲染完成之后完成,这样才能获取元素的高度。
在setHeight里,我们通过wx.createSelectorQuery去获取当前最新渲染这一屏的高度,将其赋值给用于记录每一屏高度的数组:pageHeightArr。
获取首屏的高度,需要在onload里做,而获取非首屏的高度需要在获取下一屏数据的函数getVideoInfoData做。
onLoad: function() {
// ...
this.setData({ ['list[' + this.wholePageIndex + ']']: arr }, () => {
this.setHeight(); // 获取首屏高度
})
},
setHeight: function() {
const that = this;
const wholePageIndex = this.wholePageIndex;
this.query = wx.createSelectorQuery();
this.query.select(`#wrp_${wholePageIndex}`).boundingClientRect()
this.query.exec(function(res){
that.pageHeightArr[wholePageIndex] = res[0] && res[0].height;
console.log('that.pageHeightArr' + that.pageHeightArr)
})
},
onReachBottom: function () {
this.getVideoInfoData();
},
getVideoInfoData: function () {
const arr = [
{
idx: this.index++
},
{
idx: this.index++
},
{
idx: this.index++
}]
this.wholePageIndex = this.wholePageIndex + 1;
this.setData({ ['list[' + this.wholePageIndex + ']'] : arr }, () => {
this.setHeight(); // 获取第二屏的高度
})
},
经过这样操作之后,我们就知道当前每一屏的高度为多少了,这里我把这个pageHeightArr打印出来:
完成第一小步之后,接着来看第二小步:渲染下一屏last的数据,除了保留last,以及last-1那一屏的渲染,其他节点用一个空白div占位。
这里有个难点,如何做到只渲染后两屏的数据,其他用空白div占位又不会造成闪屏呢?其实也很简单,我们前面已经记录了每一屏的高度,所以设置空白div的高度为这个高度占位即可。二维数组里的子元素我们要渲染的屏幕还是用数组来表示,不渲染的屏幕我们直接用一个对象 {height: xXX}来表示。这样在wxml里就可以根据元素是否有length属性来判断是要渲染真的节点还是空节点。
下面来实现一下:
getVideoInfoData: function () {
const arr = [
{
idx: this.index++
},
{
idx: this.index++
},
{
idx: this.index++
}
]
this.wholePageIndex = this.wholePageIndex + 1;
const wholePageIndex = this.wholePageIndex;
// 新增代码
this.currentRenderIndex = wholePageIndex;
this.wholeVideoList[wholePageIndex] = arr;
let datas = {};
let tempList = new Array(wholePageIndex + 1).fill(0);
if(wholePageIndex > 2) {
tempList.forEach((item, index) => {
if(index < tempList.length -2) {
tempList[index] = { height: this.pageHeightArr[index]};
} else {
tempList[index] = this.wholeVideoList[index];
}
})
datas.list = tempList;
} else {
datas['list[' + wholePageIndex + ']'] = arr;
}
this.setData(datas, () => {
this.setHeight();
})
},
首先我先把最新渲染的index赋值给表示当前正在渲染的屏幕的currentRenderIndex,同时将最新的数据赋值给装有所有屏幕数据的wholeVideoList。currentRenderIndex在第三步要使用,而wholeVideoList一会就会用到。
然后,我先同样定义了一个数组tempList,当wholePageIndex > 2时,也就是渲染到第四屏的时候就有选择的渲染,这个不是固定的,你可以根据你的需求改。接着我们用一个循环,判断当前是否是后两屏,如果是,才是有数据的数组,这里的数据就从我们刚刚赋值的wholeVideoList获取 ,否则子元素直接为一个对象 { height: this.pageHeightArr[index]}。
给tempList赋值完之后,setState即可,
完成到这里还不够,还需要对wxml改造一下:
<view class="page">
<view wx:for="{{ list }}" id="wrp_{{pageIndex}}" wx:for-index="pageIndex" wx:for-item="listSingleItem" wx:key="index">
<view wx:if="{{ listSingleItem.length > 0 }}">
<view class="wrp" wx:for="{{ listSingleItem }}" wx:for-index="index" wx:for-item="listItem" wx:key="index">
当前是第{{ listItem.idx }}个元素,为第 {{ pageIndex }} 屏数据
</view>
</view>
<view wx:else style="height: {{ listSingleItem.height}}px">
</view>
</view>
</view>
这里可以看到wx:if="{{ listSingleItem.length > 0 }}"
才去渲染数据,否则只是渲染一个空节点
<view wx:else style="height: {{ listSingleItem.height}}px">
</view>
到这里当我们滚动到第四屏的时候,就可以看到效果了,除了后面两屏真的渲染了数据,前面的都为一个div占位,高度为我们之前记录的高度。
到这里第二小步完成。
接着第三小步:如果是获取非最后一屏幕的,通过监听onscrollTop 获取到scrollTop的值,算取当前应该渲染哪一屏,在重新组装数据setState。
这一块的思路是这样的,在onPageScroll的回调函数里,通过e.scrollTop获取到当前的滚动距离。
注意:这里考虑到性能问题,对onPageScroll做一个节流。
根据这个滚动距离以及每一屏的高度,来计算当前应该渲染哪一屏的数据。这个需要怎么去算?其实很简单,循环去遍历pageHeightArr,用第一屏的高度tempScrollTop 与滚动高度realScrollTop + 当前屏幕的高度this.windowHeight做比较,如果tempScrollTop比较小,则用tempScrollTop累加第二屏的高度,一直到tempScrollTop > realScrollTop + this.windowHeight
就是当前应该渲染的屏幕。
上述文字翻译成代码如下:
onPageScroll: throttle(function(e) {
const realScrollTop = e.scrollTop;
const that = this;
// 滚动的时候需要实时去计算当然应该在哪一屏幕
let tempScrollTop = 0;
const wholePageIndex = this.wholePageIndex;
for(var i=0;i<this.pageHeightArr.length;i++) {
tempScrollTop = tempScrollTop + this.pageHeightArr[i];
if(tempScrollTop > realScrollTop + this.windowHeight) {
console.log('set this.computedCurrentIndex' + i);
this.computedCurrentIndex = i;
break;
}
}
}, 500),
计算出当前应该渲染的屏幕之后,接着我们就可以需要去对比,当前正在渲染的屏幕index: currentRenderIndex 是不是跟我们计算出来的computedCurrentIndex一样,如果不同,就说明我们需要调整渲染的屏幕数据,调整的方式跟第二步很像,唯一的区别就是,在这里我们渲染的是computedCurrentIndex,以及computedCurrentIndex +-1(即前后两屏)的数据而不是后两屏的数据。最后将computedCurrentIndex 赋值给currentRenderIndex即可。
onPageScroll: throttle(function(e) {
const realScrollTop = e.scrollTop;
const that = this;
// 滚动的时候需要实时去计算当然应该在哪一屏幕
let tempScrollTop = 0;
const wholePageIndex = this.wholePageIndex;
for(var i=0;i<this.pageHeightArr.length;i++) {
tempScrollTop = tempScrollTop + this.pageHeightArr[i];
if(tempScrollTop > realScrollTop + this.windowHeight - 30) {
console.log('set this.computedCurrentIndex' + i);
this.computedCurrentIndex = i;
break;
}
}
const currentRenderIndex = this.currentRenderIndex;
if(this.computedCurrentIndex !== currentRenderIndex ) {
// 这里给不渲染的元素占位
let tempList = new Array(wholePageIndex+1).fill(0);
tempList.forEach((item, index) => {
if(this.computedCurrentIndex-1 <= index && index <=this.computedCurrentIndex+1) {
tempList[index] = that.wholeVideoList[index];
} else {
tempList[index] = { height: that.pageHeightArr[index]};
}
})
this.currentRenderIndex = this.computedCurrentIndex;
this.setData({ list: tempList }, () => {
this.setHeight();
})
}
}, 500),
完整代码如下:
https://developers.weixin.qq.com/s/u4VWjKm67mhz
到这,我们就完成了,一起来看看效果。
可以看到,除了首尾只渲染两屏,中间都是渲染三屏,其他用的是空白div占位。
总结
可以看到整个过程的思路也是比较简单,就是以屏为单位,只渲染屏幕前后两屏的数据,不渲染的屏数用空白div占位即可。
但是!当我写完这篇文章,我想起来一个问题,为啥要通过监听scroll 的判断哪一屏来渲染, 直接用observeAPI 就好了啊,把这些scroll里乱七八糟的计算去掉。这里大家可以自己试试,答案在下一篇。