前言
在某个夜黑风高的夜晚,蒙娜打蛋躲在被窝里下拉小程序时发现,随着数据量的加载,列表加载 <math xmlns="http://www.w3.org/1998/Math/MathML"> 开始卡顿 \color{red}{开始卡顿} </math>开始卡顿、当达到一定数量的时候,小程序出现了甚至 <math xmlns="http://www.w3.org/1998/Math/MathML"> 白屏闪退 \color{red}{白屏闪退} </math>白屏闪退, EXM ???

探索卡顿原因
相信有一年工作经验但是加班算起来有三年经验的你也猜到是内存溢出导致小程序闪崩,接下来我们在微信开发者工具上查看内存的变化。
参考打蛋开发的下拉加载场景小程序,在首屏进入的时候,在内存栏内存记录此时内存快照,发现此时小程序内存占用大小为69.7M,但在滑动页码来到15页后,这个数字来到了 <math xmlns="http://www.w3.org/1998/Math/MathML"> 400 M \color{red}{400M} </math>400M,按照一页十条的下来加载,即是150条数据的时的内存占用快照

点击分析下这个快照内存占用详细,发现内存占用最高的是JS Arrays这一项,对比两个快照,内存增速变化的最大因素也是这一项

到微信小程序官网看了下性能,针对长列表卡顿、闪退,这里我们主要看两个点,一个是渲染性能优化以及内存优化,总结就是如下:
合理使用setData
setData是视图更新的必要操作,也是我们使用频率非常高的api。这里先讲一下setData 的过程,大致可以分成几个阶段:
- 逻辑层虚拟 DOM 树的遍历和更新,触发组件生命周期和 observer 等;
- 将 data 从逻辑层传输到视图层,但由于小程序的逻辑层和视图层是两个独立的运行环境、分属不同的线程或进程,不能直接进行数据共享,需要进行数据的序列化、跨线程/进程的数据传输、数据的反序列化,因此数据传输过程是异步的、非实时的,并且数据传输的耗时与数据量的大小正相关。
- 视图层虚拟 DOM 树的更新、真实 DOM 元素的更新并触发页面渲染更新
通过这个过程不难得出,setData从数据到更新链路是比较长的,如果随着数据量增大、频率变快,这个将会带来性能瓶颈。
官方给到我们的建议是:
1、页面、组件在data只申明需要被视图渲染用到的变量,不被使用的可以在钩子里面往this上挂载,如下
js
onload() {
this.userData = {userId: 'xxx'} ;
}
2、页面或组件渲染间接相关的数据可以设置为「纯数据字段」,emm这个就见仁见智了,如果前期没规划的话,改起来来应该很麻烦,因为需要数据字段名特殊处理作区分,感兴趣自己看看
3、控制 setData 的频率
- 只变更发生变化的字段,详细到数据路径的形式改变数组中的某一项或对象的某个属性,如
js
this.setData({'array[2].message': 'newVal', 'a.b.c.d': 'newVal'})
- 对连续的 setData 调用尽可能的进行合并
适当监听页面或组件的 scroll 事件
简单来说就是在页面滚动的pagescroll回调、scroll-view、page-meta的scroll监听会造成 <math xmlns="http://www.w3.org/1998/Math/MathML"> 事件会以很高的频率从视图层发送到逻辑层 \color{red}事件会以很高的频率从视图层发送到逻辑层 </math>事件会以很高的频率从视图层发送到逻辑层,到存在一定的通信开销。
控制 WXML 节点数量和层级
一个太大的 WXML 节点树会增加内存的使用,样式重排时间也会更长,影响体验。官方给到的建议是一个页面 WXML 节点数量应少于 1000 个,节点树深度少于 30 层,子节点数不大于 60 个。
开始长列表优化
通过上面读官方文档并慌张分析后:
首先进行页面节点的优化
随着数据加载堆叠,页面的节点越来越多,内存急剧上升,这里我们采用虚拟列表,固定高度并且不变高的场景可以使用小程序原生组件recycle-view,这里就不做介绍了,这里我们的场景是不等高并且变高的场景。
首先进行列表数据结构改造,因为这种不等高并且高度变化的节点不适合使用传统的虚拟列表方案(通过滚动距离去截取需要加载的数据的方式),这里我们以页码为容器做存储容器, 顺便模拟下下拉加载,上代码:
index.wxml
<view class="container">
<view wx:for="{{groupList}}" id="{{'selection-'+groupIndex}}" wx:for-item="list" wx:for-index="groupIndex" wx:key="groupIndex" class="list-wrapper">
<view wx:if="styleMap[groupIndex].visible" class="">
<view wx:for="{{list}}" wx:for-item="item" id="item-{{item.id}}" wx:key="{{item.id}}" >
<view style="height:{{item.height}}rpx;background-color: {{ item.color }};" class="item">
第{{ groupIndex + 1 }}页
<br/>
高度{{ item.height }}rpx
</view>
</view>
</view>
<view wx:else style="height:{{styleMap[groupIndex].height}}px" class="selection--show">
<feed-card-skeleton wx:for="{{styleMap[groupIndex].skeletonNumber}}"
wx:for-item="skeIdx" wx:key="skeIdx" class="selection--show" />
</view>
</view>
</view>
index.js
const app = getApp()
const COLOR_MAP = [
'blue',
'green',
'grey',
'red',
'white',
'orange',
'#f9f9f9',
'#f2f1f1',
'#fbf1c7',
'#036aca'
]
Page({
data: {
//用来存储每个分页容器的状态
styleMap: [{
visible: true, //改分页数据是否可见
height: 0 //占位高度 用来隐藏的时候占位高度
}],
groupList: []
},
onLoad() {
//不在视图上的数据在onload钩子里面申明
this.current = 0
this.fetchData()
},
fetchData() {
let data = [];
for(let i = 0; i < 10; i++){
data.push({
height: Math.random().toFixed(3) * 1000,
id: Date.now(),
color: COLOR_MAP[Math.random().toFixed(1) * 10]
})
}
const key = `groupList[${this.current}]`
//这里合并修改 减少setdata频率
this.setData({
[key]: data
})
this.current++
},
onReachBottom() {
this.fetchData()
}
})
👌🏻,现在已经实现列表数据结果改造和下拉加载模拟了


接下来请我们的主人公IntersectionObserver对节点进行显隐判断,详情可以参数微信小程序官方文档,这里我选择了超出视口上下各两屏做边界,并在离开可视区域后将节点高度记录下来占位:
index.js
onLoad() {
...
this.observerList = [] //这里存储对每一个分页容器的监听
this.getDeviceHeight()
},
//获取设备高度
getDeviceHeight() {
wx.getSystemInfo({
success: res => {
this.deviceHeight = res.windowHeight
}
})
},
fetchData() {
let data = [];
for(let i = 0; i < 10; i++){
data.push({
height: Math.random().toFixed(3) * 1000,
id: Date.now(),
color: COLOR_MAP[Math.random().toFixed(1) * 10]
})
}
const key = `groupList[${this.current}]`
const styleKey = `styleMap[${this.data.styleMap.length}]`
this.setData({
[key]: data,
[styleKey]: {
visible: true,
height: 0
}
})
this.current++
wx.nextTick(() => {
this.observeElement(this.current - 1)
})
},
observeElement(index) {
const observer = wx.createIntersectionObserver(this, { initialRatio: 0 })
wx.nextTick(() => {
this.observeBorder(observer, index)
this.observerList.splice(index, 1, observer)
})
},
observeBorder(observer = null, index) {
const callback = (visible, height) => {
if (this.data.styleMap[index] && this.data.styleMap[index].visible !== visible) {
const key = `styleMap[${index}]`
this.setData({
[key]: {
visible: visible,
height: height //获取高度占位
}
})
}
}
//上下首尾各两屏为边界 超出的页列表容器隐藏 并获取高度占位
observer.relativeToViewport({
top: this.deviceHeight * 2,
bottom: this.deviceHeight * 2
}) .observe(`#selection-${index}`, ({ intersectionRatio, boundingClientRect }) => {
const visible = intersectionRatio !== 0;
console.log(visible ? '可见' : '不可见', `第${index+1}页`);
callback(visible, boundingClientRect.height)
})
}
让我们来看下初步结果,诶可行,节点被我们隐藏了,并且高度占位没有导致页面抖动,nice
将这个方案套在打蛋的小程序上,果然下拉加载速度快了很多,但在这个过程中随着页面的滑加导致监听越来越多,页面下来还是会越来越卡,这里不妨问了个问题,我们真的需要这么多监听吗🤔,举个🌰:在比如我当前可视区域在第20页,我第一页的监听是不是应该销毁呢,但如果销毁后我们回拉到第一页,第一页又怎么来显示呢?
接下来我们需要控制监听管理,即是对无效监听断开与建立有效监听。断开监听应该是不难的,这里我们只需要在节点触发边界变更后断开即可。
index.js
observeBorder(observer = null, index) {
const callback = (visible, height) => {
if (this.styleMap[index] && this.styleMap[index].visible !== visible) {
this.styleMap.splice(index, 1,
{ visible,
height
})
//断开监听连接
this.observerList[index].disconnect()
}
}
...
}
🤔那如何重新建立连接呢,这里想到了scroll监听回调,因为前面我们收集了每个分页容器的高度,我们可以根据节点的高度和滚动高度对比,来重新建立连接,这里我只做了上边界的检测, 然后对上下两页也进行监听
js
manualCheck(scrollTop) {
let height = 0,
showIndex = ''
//找到第一个需要显示的分页容器 这里只判断上边界
for (let index = 0; index < this.data.styleMap.length; index++) {
height += this.data.styleMap[index].height;
if (height < scrollTop + this.deviceHeight * 2) {
showIndex = index;
continue;
}
}
//对上下两页进行判断处理 简单处理 如果三页不够撑开的 可以在上面的判断中把下边界也加上即可
const showIndexs = [showIndex - 1, showIndex, showIndex + 1]
showIndexs.forEach(visibleIndex => {
this.data.styleMap[visibleIndex] && (this.data.styleMap[visibleIndex].visible || this.observeElement(visibleIndex, true))
})
},
onPageScroll(e){
//避免高频滚动一直触发链接
this.throttle(() => {
this.manualCheck(e.scrollTop);
}, 500)()
}
至此,再也不担心卡顿了😁,让我们来看看效果

虚拟列表下15页数据占用内存大小,直接从400M下降至110M,Nice😁:

总结
回顾一下整个过程
- 修改数据结果为二维数组并管理每一页容器的状态(styleMap)
- 使用IntersectionObserver对边界进行查询,离开可视区域后记录下当前分页的高度并销毁监听
- 在滚动监听里面根据滚动高度,遍历记录的高度找到展示的节点重新建立监听
剩余优化空间
如果回滚的时候有卡顿,可以排查节点的结构、使用骨架屏。但本质上还是要去优化节点的结构,嵌套过深、数据共享滥用会导致通讯、重排。