小程序实现最简单的长列表性能优化
代码片段
看小程序里的调试台的WXML面板,会发现不管怎么滚动,WXML的节点数量是在一定范围里的。是我们预期的效果。
为什么要优化长列表?
一般大量数据的时候我们会采用分页的方式,后端一次返回定量的数据,前端一旦分页就更换节点信息,展示新的内容。但是在移动端,分页的体验并不好,通常采用滚动的加载方式。而通常做滚动的时候,加载来的新数据都是直接添加到当前 dom 元素列表的屁股后面。加载的多了以后页面上就会有很多的节点,影响性能。
看一下会无限增加节点的伪代码。
Page({
data: {
listData: []
},
getData() {
Server.postDataToHere().then(result => {
this.setData({
// 把新来的数据直接添加到data后面
listData: this.data.listData.concat(result)
})
})
}
})
这样的实现方法可以是可以,但是不仅会遇到节点过多的问题,最后还会遇到 setData 的数据量过大的问题。小程序对 setData 的数据大小是有限制的。
WXML 结构
要滚动所以很显然要用 scroll-view。scroll-view 要绑定触底加载新数据和滚动事件。
// 最外层的scroll-view
<scroll-view
scroll-y
style="width:100%;height: 100vh"
bindscrolltolower="onScrollToLower" // 触底加载新数据
bindscroll="onScroll"> // onScroll绑定即时检查
// scroll-view里循环渲染view,要用绝对定位因为之后要算位置
<view wx:for="{{listData}}" wx:key="{{index}}"
style="position: absolute;
width:300px;height:200px;
top:{{item.top}}px; // item的top要用item在总列表里的index值给算出来
background:#333">
// view里面是什么就无所谓了,这里面放个text来显示item的index好了
<text style="color:white;display:block;">{{item.index}}</text>
</view>
</scroll-view>
JS 结构
我们的 Page 的 data 里还是只有一个 listData 作为列表的数据源。其他不用 setData 的变量我们直接声明在外层。需要一个用来放到目前为止所有数据的 Array。还要有一个 scrollTop 来表示当前滚动到的位置,和一个 lastScrollTop 来表示之前一次滚动事件的位置,来判断是向上滚动还是向下滚动。
还有一个indexRange来表示现在渲染的数据区域,如果经过滚动事件,发现需要的渲染的区域和上次不一样了,那么代表要 setData 了。
Page({
data: {
listData: [] // 作为列表的数据源
},
totalDataList: [] // 放到目前为止所有数据,
scrollTop:0,
lastScrollTop: 0,
indexRange: [0,0],
onLoad: function() {
...
}
...
})
优化基本思路
思路其实很简单,就是监听滚动事件,如果发现有离当前视窗很远的元素,用户根本就看不到,setData 就不包括这个元素,意思就是就不再渲染它。 所以这其实是一个跟随事件的即时检查。
下面这是这个滚动事件触发的回调函数的代码
reloadData() {
// 原来存放最后结果的Array
const items = []
// 如果是向下滚动就从上次渲染区域的开头开始遍历
// 如果是向上滚动就从开头开始遍历
const start = this.lastScrollTop <= this.scrollTop ? this.indexRange[0] : 0
// 从start遍历到所有数据的最后
for (let index = start; index < this.totalDataList.length; index++) {
// 如果当前数据的top和scrollTop的差距超过一定的值就不再渲染它
if (Math.abs(this.scrollTop - this.totalDataList[index].top) < 3000) {
// 如果还在预期的距离以内就把它放到要渲染的结果里
items.push(this.totalDataList[index])
}
}
// 有了要渲染的结果Array里以后, 对比这个结果Array和原有的indexRange,如果有出入就应该重新setData重新渲染了。
if (this.indexRange[1] !== items[items.length - 1].index || this.indexRange[0] !== items[0].index) {
this.indexRange[1] = items[items.length - 1].index
this.indexRange[0] = items[0].index
// 重新setData
this.setData({
listData: items
})
}
// 记录这次的scrollTop
this.lastScrollTop = this.scrollTop
}
重点
- 每次scroll的时候通过遍历检查去检查所有数据的位置。
- 每个数据的位置一定是固定的,因为使用了绝对定位,是算出来的。所以检查起来并不用去DOM里取元素的位置。
- 判断是否要重新setData根据是根据这次判断的是否渲染结果Array对比上次已经渲染了的Array。
总结
除了实时监听scroll,我们还可以使用Intersection Observer这个API,我没有试过但是因为这个API的原理好像是异步的,如果滚动过快预估会出现一些问题。
这个方法在滚动很快的时候也会出现白屏的情况,比如到很下方的时候突然滚到最顶端,但是体验上我觉得是可以接受的,市面上的小程序如花瓣LITE也有这个问题。
性能上主要就是每次scroll事件都再遍历。但是我们只在最外层的scroll-view的上放了一个event listener,如果想当然的话就会去给每一个元素加listener,这里一定要用绝对定位的思想转过来。
明白了这个原理以后,就可以实现两列布局和瀑布流布局了。
完整JS代码
不知道为什么现在代码片段分享会报系统错误
Page({
data: {
listData: [{
top: 0,
index: 0
}, {
top: 210,
index: 1
}, {
top: 420,
index: 2
}, {
top: 630,
index: 3
}, {
top: 840,
index: 4
}]
},
totalDataList: [{
top: 0,
index: 0
}, {
top: 210,
index: 1
}, {
top: 420,
index: 2
}, {
top: 630,
index: 3
}, {
top: 840,
index: 4
}],
scrollTop: 0,
lastScrollTop: 0,
indexRange: [0, 0],
onLoad: function () {
},
getData() {
const result = []
for (let index = 0; index < 5; index++) {
let base = this.data.listData[this.data.listData.length - 1];
result.push({
top: base.top + (index * 210),
index: base.index + index
})
}
this.totalDataList = this.totalDataList.concat(result)
},
onScrollToLower() {
this.getData()
this.reloadData()
},
reloadData() {
const items = []
const start = this.lastScrollTop <= this.scrollTop ? this.indexRange[0] : 0
for (let index = start; index < this.totalDataList.length; index++) {
if (Math.abs(this.scrollTop - this.totalDataList[index].top) < 3000) {
items.push(this.totalDataList[index])
}
}
//console.log(items)
if (this.indexRange[1] !== items[items.length - 1].index || this.indexRange[0] !== items[0].index) {
this.indexRange[1] = items[items.length - 1].index
this.indexRange[0] = items[0].index
//console.log(this.indexRange)
this.setData({
listData: items
})
}
this.lastScrollTop = this.scrollTop
},
onScroll(e) {
this.scrollTop = e.detail.scrollTop
this.reloadData()
}
})