本文转载自:杰森博客:微信小程序长列表性能优化实践
某天闲着无聊想练一下手速,去上拉一个小程序项目中一个有 1 万多条商品数据的列表。在数据加载到 1000 多条后,是列表居然出现了白屏。看了一下控制台:
控制台提示 “Dom limit exceeded”,也就是 DOM 数超出了限制,不知道微信是出于什么考虑,要限制页面的 DOM 数量。
一、小程序页面限制多少个 wxml 节点?
写了个小 demo 做了个测试。listData 的数据结构如下:
1 2 3 4 5 6 7 8 9 10 11 12
| listData:[{ isDisplay:true, itemList:[{ qus:'下面哪位是刘发财女朋友?', answerA:'刘亦菲', answerB:'迪丽热巴', answerC:'斋藤飞鸟', answerD:'花泽香菜', } ....... ] }]
|
页面渲染效果:
demo 1
1 2 3 4 5 6 7 8 9 10 11
| <view wx:for="{{listData}}" class="first-item" wx:for-index="i" wx:for-item="firstItem" wx:key="i" wx:if="{{firstItem.isDisplay}}"> <view class="item-list" wx:for="{{firstItem.itemList}}" wx:key="index"> <view>{{item.qus}}</view> <view class="answer-list"> <view>A. <text>{{item.answerA}}</text></view> <view>B. <text>{{item.answerB}}</text></view> <view>C. <text>{{item.answerC}}</text></view> <view>D. <text>{{item.answerD}}</text></view> </view> </view> </view>
|
dome 2,删除了不必要的 dom 嵌套
代码如下:
1 2 3 4 5 6 7 8 9 10 11
| <view wx:for="{{listData}}" class="first-item" wx:for-index="i" wx:for-item="firstItem" wx:key="i" wx:if="{{firstItem.isDisplay}}"> <view class="item-list" wx:for="{{firstItem.itemList}}" wx:key="index"> <view>{{item.qus}}</view> <view class="answer-list"> <view>A. {{item.answerA}}</view> <view>B. {{item.answerB}}</view> <view>C. {{item.answerC}}</view> <view>D. {{item.answerD}}</view> </view> </view> </view>
|
通过大致计算,一个小程序页面大概可以渲染 2 万个 wxml 节点 而小程序官方的性能测评得分条件为少于 1000 个 wxml 节点(官方链接)
二、列表页面优化
小程序列表页面优化,有以下几种方式:
1、减少不必要的标签嵌套
由上面的测试 demo 可知,在不影响代码运行和可读性的前提下,尽量减少标签的嵌套,可以大幅的增加页面数据的列表条数,毕竟公司不是按代码行数发工资的。如果你的列表数据量有限,可以用这种方法来增加列表渲染条数。如果数据量很大,再怎么精简也超过 2 万的节点,这个方法则不适用。
2、优化 setData
的使用
如上图所示,小程序 setData
的性能会受到 setData
数据量大小和调用频率限制。所以要围绕减少每一次 setData
数据量大小,降低 setData
调用频率进行优化。
- 删除冗余字段。后端的同事经常把数据从数据库中取出就直接返回给前端,不经过任何处理,所以会导致数据大量的冗余,很多字段根本用不到,我们需要把这些字段删除,减少
setData
的数据大小。
-
setData
的进阶用法。通常,我们对 data 中数据的增删改操作,是把原来的数据取出,处理,然后用 setData
整体去更新,比如我们列表中使用到的上拉加载更多,需要往 listData 尾部添加数据:
1 2 3 4
| newList = [ {...}, {...} ]; this.setData({ listData: [...this.data.listData, ...newList] })
|
这样会导致 setData
的数据量越来越大,页面也越来越卡。
3、setData
的正确使用姿势
比如我们要修改数组 listData 第一个元素的 isDisplay
属性,我们可以这样操作:
1 2 3 4
| let index=0; this.setData({ [`listData[${index}].isDisplay`]: false, })
|
如果我们想同时修改数组 listData 中下标从 0 到 9 的元素的 isDisplay
属性,那要如何处理呢?你可能会想到用 for
循环来执行 setData
:
1 2 3 4 5
| for (let index = 0; index < 10; index++){ this.setData({ [`listData[${index}].isDisplay`]: false, }) }
|
那么这样就会导致另外一个问题,那就是 listData 的调用过于频繁,也会导致性能问题,正确的处理方式是先把要修改的数据先收集起来,然后调用 setData
一次处理完成:
1 2 3 4 5
| let changeData={}; for (let index=0; index < 10; index++){ changeData[[`listData[${index}].isDisplay`]] = false; } this.setData(changeData);
|
这样我们就把数组 listData 中下标从 0 到 9 的元素的 isDisplay
属性改成了 false
。
如果只添加一条数据:
1 2 3 4
| let newData={...}; this.setData({ [`listData[${this.data.listData.length}]`]: newData })
|
如果是添加多条数据,像这样:
1 2 3 4 5 6 7 8
| let newData = [ {...},{...},{...},{...},{...},{...} ]; let changeData = {}; let index = this.data.listData.length newData.forEach((item) => { newData['listData[' + (index++) + ']'] = item; }) this.setData(changeData)
|
三、使用自定义组件
可以把列表的一行或者多行封装到自定义组件里,在列表页使用一个组件,只算一个节点,这样你的列表能渲染的数据可以成倍数的增加。组件内的节点数也是有限制的,但是你可以一层层嵌套组件实现列表的无限加载,如果你不怕麻烦的话。
四、使用虚拟列表
经过上面的一系列操作后,列表的性能会得到很大的提升,但是如果数据量实在太大,wxml 节点数也会超出限制,导致页面发生错误。我们的处理方法是使用虚拟列表,页面只渲染当前可视区域以及可视区域上下若干条数据的节点,通过 isDisplay
控制节点的渲染。
- 可视区域上方:
above
- 可视区域:
screen
- 可视区域下方:
below
1、listData数组的结构
使用二维数组,因为如果是一维数组,页面滚动需要用 setData
设置大量的元素 isDispaly
属性来控制列表的的渲染。而二维数组可以这可以一次调用 setData
控制 10 条、20 条甚至更多的数据的渲染。
1 2 3 4 5 6 7 8 9 10 11 12
| listData:[{ isDisplay:true, itemList:[{ qus:'下面哪位是刘发财女朋友?', answerA:'刘亦菲', answerB:'迪丽热巴', answerC:'斋藤飞鸟', answerD:'花泽香菜', } ....... ] }]
|
2、必要的参数
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
| data{ itemHeight: 4520, itemPxHeight: '', aboveShowIndex: 0, belowShowNum: 0, oldSrollTop: 0, prepareNum: 5, throttleTime: 200 }
|
3、wxml 的 dom 结构
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
| <view class="above-box" style="height:{{aboveShowIndex*itemHeight}}rpx"> </view>
<view wx:for="{{listData}}" class="first-item" wx:for-index="i" wx:for-item="firstItem" wx:key="i" wx:if="{{firstItem.isDisplay}}"> <view class="item-list" wx:for="{{firstItem.itemList}}" wx:key="index"> <view>{{item.qus}}</view> <view class="answer-list"> <view>A. {{item.answerA}}</view> <view>B. {{item.answerB}}</view> <view>C. {{item.answerC}}</view> <view>D. {{item.answerD}}</view> </view> </view> </view>
<view class="below-box" style="height:{{belowShowNum*itemHeight}}rpx"> </view>
|
4、获取列表第一层 dom 的 px 高度
1 2 3 4 5 6 7 8
| let query = wx.createSelectorQuery(); query.select('.content').boundingClientRect(rect => { let clientWidth = rect.width; let ratio = 750 / clientWidth; this.setData({ itemPxHeight:Math.floor(this.data.itemHeight / ratio), }) }).exec();
|
5、页面滚动时间节流
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| function throttle(fn){ let valid = true return function() { if (!valid) { return false }
valid = false setTimeout(() => { fn.call(this,arguments); valid = true; }, this.data.throttleTime) } }
|
6、页面滚动事件处理
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55
| onPageScroll: throttle(function(e) { let scrollTop = e[0].scrollTop; let itemNum = Math.floor(scrollTop / this.data.itemPxHeight); let clearindex = itemNum - this.data.prepareNum + 1; let oldSrollTop = this.data.oldSrollTop; let aboveShowIndex = this.data.aboveShowIndex; let listDataLen = this.data.listData.length; let changeData = {} if (scrollTop - oldSrollTop > 0) { if (clearindex > 0) { for (let i = aboveShowIndex; i < clearindex; i++) { changeData[[`listData[${i}].isDisplay`]] = false; let belowShowIndex = i + 2 * this.data.prepareNum; if (i + 2 * this.data.prepareNum < listDataLen) { changeData[[`listData[${belowShowIndex}].isDisplay`]] = true; } } } } else { if (clearindex >= 0) { let changeData = {} for (let i = aboveShowIndex - 1; i >= clearindex; i--) { let belowShowIndex = i + 2 * this.data.prepareNum if (i + 2 * this.data.prepareNum <= listDataLen - 1) { changeData[[`listData[${belowShowIndex}].isDisplay`]] = false; } changeData[[`listData[${i}].isDisplay`]] = true; } } else { if (aboveShowIndex > 0) { for (let i = 0; i < aboveShowIndex; i++) { this.setData({ [`listData[${i}].isDisplay`]: true, }) } } } } clearindex = clearindex > 0 ? clearindex : 0 if (clearindex >= 0 && !(clearindex > 0 && clearindex == this.data.aboveShowIndex)) { changeData.aboveShowIndex = clearindex; let belowShowNum = this.data.listData.length - (2 * this.data.prepareNum + clearindex) belowShowNum = belowShowNum > 0 ? belowShowNum : 0 if (belowShowNum >= 0) { changeData.belowShowNum = belowShowNum } this.setData(changeData) } this.setData({ oldSrollTop: scrollTop }) }),
|
经过上面的处理后,页面的 wxml 节点数量相对稳定,可能因为可视区域数据的 index 计算误差,页面渲染的数据有小幅度的浮动,但是已经完全不会超过小程序页面的节点数量的限制。理论上 100 万条数据的列表也不会有问题,只要你有耐心和精力一直划列表加载这么多数据。
7、待优化事项
- 列表每一行的高度需要固定,不然会导致可视区域数据的 index 的计算出现误差
- 渲染玩列表后往回来列表,如果手速过快,会导致
above
和 below
区域的数据渲染不过来,会出现短暂的白屏,白屏问题可以调整 prepareNum
和 throttleTime
两个参数改善,但是不能完全解决。
- 如果列表中有图片,
above
和 below
区域重新渲染时,图片虽然以经缓存在本地,不需要重新去服务器请求,但是重新渲染还是需要时间,尤其当你手速特别快时。可以根据上面的思路,isDisplay
时只销毁非 <image>
的节点,这样重新渲染就不需要渲染图片,但是这样节点数还是会增加,不过应该能满足大部分项目需求了,看自己项目怎么取舍。
五、使用自定义组件和虚拟列表的对比。
虽然不知道为什么,但是直觉告诉我使用自定义组件性能会相对差一点。为了对比两种方法的优劣,使用了 Trace 工具对一个 5000 条带图片数据进行了性能测试。
内存占用对比:
自定义组件内存占用情况:
虚拟列表内存占用情况:
对比可以看出,因为组件在上拉加载时,组件是没有销毁的,导致数据量逐渐增多。而虚拟列表在增加数据的同时,也会销毁相同数量的数据,所以内存占比会稳定在一个数量。具体到这个测试 demo,5000 条数据使用自定义组件,最后占用 2000MB 的内存,而虚拟列表稳定在 700MB。
setData
后重新渲染所用的时间对比:
自定义组件重新渲染耗时:
虚拟列表重新渲染耗时:
从测试结果可以看出,无论是耗时的次数分布,还是最大耗时,最小耗时,虚拟列表都优于自定义组件。
全文完,感谢阅读,希望对你在小程序开发中有所帮助。
扩展阅读
关于微信小程序开发,还有这些文章值得阅读: