小程序长列表渲染初探
一、背景
在前端的业务开发中,列表的渲染是很常见的场景。不管在 Web 端还是小程序,当 DOM 节点越多,每一次重绘对性能的影响也越大。如果一个小程序页面渲染过多的 DOM 节点时,很容易造成页面卡顿、数据渲染较慢甚至白屏问题,当内存占用过多时,页面也会被回收。
假设有一个场景需要渲染一个拥有上千商品的商品列表,且商品数据较为复杂,我们该如何保持其性能呢?
Web 端处理此类问题常见的处理方式有两种:
- 进行分页操作
- 虚拟列表
分页操作就是我们常见的点击上一页、下一页跳转方案,通过设置每一页的列表数量实现定量加载,替换原先的列表数据,实现列表的更新,保证一次的加载数量固定。
在小程序侧,由于分页操作方案需要用户参与点击操作,在用户体验上并不友好,并且上下页跳转的按钮大小等交互问题也会很大程度影响用户的体验。更多的用户还是喜欢能够无限滚动的长列表,只需要向下滚动就能看到新的列表内容。特别是在移动端,相信大部分人在逛淘宝、刷微博的时候只喜欢向下滚动,而不是通过点击下一页查看新内容。
本篇文章将会从一个普通长列表入手一步步改造实现结合懒加载和虚拟列表思想的长列表渲染以及如何在现有项目中引入此方案进行优化。
二、普通长列表渲染
普通的长列表我们直接利用 wx:for 对列表进行遍历渲染,这里我们模拟构建了长度为 3000 的列表,每个列表项只含有 title 和 image 属性(真实业务场景下可能会更多更复杂)。
list.wxml 文件:
1 | <view wx:for="{{listData}}" wx:for-index="index" wx:key="index"> |
list.ts 文件:
1 | Page({ |
效果展示:
同时利用开发者工具中的 Audits 面板对页面进行体验评分,得到以下体验报告:
我们可以看出我们的列表渲染了太多 WXML 节点,且渲染界面存在耗时过长的情况,最长耗时达到 1495ms。
另外如果将列表项增加至 4000 以上的话,页面会因为内存问题直接被回收不进行渲染。此处模拟的每个列表项还只是简单的一张图和名称,真实场景下,列表项可能更为复杂,性能也会更差。
三、应用虚拟列表思想
1. 虚拟列表概念
了解虚拟列表前,我想引入懒渲染这概念。懒渲染就是当组件要在视图展示才渲染的一种方案。常见的组件有 Modal 组件,当用户触发了展示事件才会展示,通常由一个字段属性比如 visible 属性去控制展示与否。小程序中的 wx:if 就是惰性的,如果初始渲染条件为false
,框架什么都不会做,在条件第一次变为true
时才会开始局部渲染,这也是我们实现列表渲染优化的核心。
什么是虚拟列表?
虚拟列表并没有官方的解释,其实只是一个概念名词,不同人有不同解读。可以将其理解为一种对长列表渲染的优化方案,它不会一次性渲染全部的长列表,而是按需渲染,让用户无感知使用了长列表,且达到无限滚动的效果。虚拟列表更像是一种懒渲染的特殊场景。
2. 虚拟列表原理
虚拟列表是只对可视区域的元素进行渲染,而对离开可视区域的元素进行卸载,从而达到按需渲染的一种列表数据展示方案。
从下图可见,虚拟列表一般由四部分组成:
- 真实列表区:列表滚动区,含全部列表可滚动内容
- 可视区:用户可见区域
- 渲染区:需要渲染的区域
- 缓冲区:为了优化列表,减少闪动而提前渲染的区域
可视区和缓冲区共同组成的渲染区都将被渲染,渲染区以外的真实列表区都将不被渲染,像 Item1 这类已被渲染过的节点也会被卸载。
3. 节点卸载实现
普通长列表是利用wx:for对列表项直接进行加载渲染。
1 | <view wx:for="{{listData}}" wx:for-index="index" wx:key="index"> |
为了能够实现节点卸载,首先更改 wxml 的结构,在用 wx:for 进行列表渲染的同时添加wx:if="{{item.isDisplay}}"
来对不在显示区域的元素的进行卸载。为了保证滚动过程中滚动位置的准确需要在列表上方添加元素并设置高度等于卸载列表项的高度和,保持整体列表的高度不变,以达到卸载元素没有被移除的效果。
1 | <view class="list-container"> |
然后设置列表元素的高度和列表项数,在页面的 onLoad 函数中计算得到显示区域的高度和显示区域中的最后一项的 index,在初次构建 listData 时将此 index 以后的列表项的 isDisplay 属性设置为 false 阻止其在页面渲染。
计算容器内的列表项数:
1 | const containerItem = |
添加 onPageScroll 监听页面滚动:
- 列表向下滚动时保证当前显示区域内的最后一项处于显示,且判断显示区域的第一项是否更新,如果更新(说明原列表第一项从上方移除),则将其卸载
- 向上滚动时保证当前显示区域内的第一项处于显示,且判断显示区域的最后一项是否更新,如果更新(说明原列表最后一项从下方移除),则将其卸载
- 更新 firstIndex、lastIndex、aboveHideNum、belowHideNum、oldScrollTop 值
list.ts 文件
慢滑效果展示:
快滑效果展示:
由上体验报告可以看出,通过卸载显示区域以外的节点可以很大程度上优化我们的小程序性能。既避免了使用过大的 WXML 节点数目,又避免了渲染界面耗时过长的情况。与此同时我们又遇到了一个性能问题:setData 的调用过于频繁。
从页面体验来看,在滚动过程中也会有短暂的白屏情况。此外如果滚动速度很快的时候,会存在有节点不能成功卸载导致列表展示出现问题。
4. 性能优化
setData 优化
微信官方文档对 setData 优化有很详细的说明。
setData 应只用来进行渲染相关的数据更新。用 setData 的方式更新渲染无关的字段,会触发额外的渲染流程,或者增加传输的数据量,影响渲染耗时。需要将页面或组件渲染无关的数据移入非 data 的字段下
因为在滚动过程中会涉及到对 listData 中的一组数据的改变,在循环遍历时将会频繁调用 setData 很大程度影响性能。这时候应当将改变的数据统一收集然后一次性处理。
For example:
1 | const newData: AnyObject = {}; |
节流
由于我们是在滚动事件中进行监听并执行 setData,所以随着滚动事件的发生,将会不断触发 setData 执行。因此,这里我们可以对 onPageScroll 引入节流。
节流函数:
1 | function throttle(fn) { |
1 | Page({ |
效果展示:
从体验评分上来看,我们的方案在性能和体验方面满足了要求。然而当屏幕快速滚动时由于节流的缘故,没法保证 curFIndex 与 firstIndex 以及 curLIndex 与 lastIndex 之间的差值为 1,所以只对首末项进行操作容易使我们的列表出现遗漏的情况。
Tips:节流不能把间隔设置太长,毕竟太长了也会导致卡顿。
5. 列表遗漏问题处理
由于是 curFIndex 与 firstIndex 以及 curLIndex 与 lastIndex 之间的差值不能保证不超过 1 的缘故,我们需要将此之间的值进行统一处理,以达到快速滚动时改变能覆盖到所有涉及列表项。修改 onPageScroll 中的滚动事件:
1 | if (scrollTop - oldScrollTop > 0) { |
一开始想当然用了两个简单的循环渲染,滚动慢的话没有问题,可一旦滚动过快导致一次更新中 curFIndex > lastIndex 时,本该隐藏的列表项又被展示了,同理向上滑也是一样。因此,为了能让列表统一,计算获得容器内的列表项的项数,以此来对列表下方项数进行操作。同时也要保证显示视图中的最后一项始终保持展示。
1 | // 获取显示视图的最大展示列表项数 |
效果展示:
至此,我们的列表已经可以完整的展示,但是还需要解决白屏闪动问题。
6. 白屏闪动优化
由于我们现在对列表的处理都是依照显示区域的第一项进行操作,当列表项数很大时快速上下滚动很容易产生短暂的白屏。要解决此问题引入的方案就是添加“缓冲区”。也就是在可视区域的上下部分额外添加渲染的节点,来防止白屏的出现。通过bufferNum字段控制缓冲区要展示的列表项数。
1 | onPageScroll: throttle(function (e) { |
对于 bufferNum 值的设置,这里采取微信 recycle-view 的方案,渲染当前屏幕前后两个屏幕的内容。
效果展示:
至此,一个虚拟列表的实现已经完成,可以支持我们继续增加列表的长度或者增加列表项的复杂度。
7. 添加 showListConfig 配置
原先的实现是通过改变列表数据,为列表的每一项数据额外添加 isDisplay 字段去控制列表是否展示。这样会污染原数据,并且在添加新数据时会引起新旧两个列表的不一致。改进后:额外用 showListConfig 数组来控制列表项的展示与否,同时监听列表数据,如果有改变则对新增的列表项进行设置。
Component: virtual-list.ts
1 | observers = { |
virtual-list.wxml
1 | <view |
8. 懒加载
我们的初始的 3000 条数据是在页面 onLoad 中模拟生成的,真实场景下我们的列表数据都是从接口获得的,我们没法保证一个接口在获取 3000 条甚至更多数据时还能有良好的性能,一旦接口过慢,也会影响我们的首屏展示时间。且处理过长 showListConfig 配置也会有时间消耗,因此引入懒加载的概念。
什么是懒加载?
懒加载是一种浏览海量信息的方式,基本功能是当用户划过已加载的内容后,更多的内容可以被加载。
懒加载 && 分页
在 Web 端,我们可以设置一个列表的最多展示列表项,并通过翻页按钮重新获取数据并替换列表中的值。同样利用此思想,在小程序端,我们可以通过触底事件调取接口数据,且每次只调取部分数据加入到列表底端实现性能的优化。现阶段商品列表的展示就是采用这种方案避免首次渲染过慢的情况发生。
两者都是每次调取接口获取一小部分列表数据,不同的是:懒加载不对已有数据进行覆盖更新,每次获取数据后添加至列表底部;分页中获取的数据会替换原来数据更新列表进行展示。
懒加载的问题:同样需要卸载节点!!否则当数据增加后依然会导致节点数量过多。
利用懒加载的思想对原先列表进行改造,模拟滚动到底部调取新数据加入到列表,且首次不再加载那么多列表项。
1 | onReachBottom() { |
Tips:真实场景中也可以增加 loading 来表示数据的加载过程,这里不做展示。
此时我们完成了懒加载和虚拟列表思想的结合。
9. 结合 scroll-view 实现
上述实现是直接基于页面滚动事件对列表进行操作,在使用时并不是十分方便,且在真实场景中更多情况下列表的可视区域并不是整个页面。下面就实现了将虚拟列表封装在 scroll-view 中,并且将滚动监听移入 scroll-view 的 bindscroll 方法中。
应用懒加载后已经支持列表划到底部增加新的数据,但是新数据暂时还是在组件内部写死,需要将事件透传出去在外部统一调取新数据,而不是在内部对列表数据进行操作。在组件内的 onScrollEnd 方法内调用 triggerEvent 方法this.triggerEvent('scrollToEnd');
同时绑定 scrollToEnd 方法bindscrollToEnd="onScrollEnd"
支持用户在 onScrollEnd 中自定义对列表数据进行处理。
virtual-list.wxml 文件
1 | <view class="list-container"> |
virtual-list.ts 文件
10. 支持调用传入列表项样式
更改使用 slot 支持调用方传入列表项的样式。
组件样式:
1 | <scroll-view |
调用:
1 | <t-virtual-list |
11. 前后对比
如果想要知道 setData 引发界面更新的开销,可以使用更新性能统计信息接口setUpdatePerformanceListener记录滚动过程中渲染更新最大耗时。下面是我对普通长列表渲染和使用上述方案渲染长列表的 5 次首次渲染耗时记录对比:
更新前 | 更新后 |
---|---|
1128 | 76 |
1075 | 67 |
1003 | 63 |
1101 | 66 |
1029 | 73 |
由于普通长列表是一次性全部渲染所以耗时相对特别长,改用懒渲染加节点卸载后,首次渲染耗时能够大幅度降低,且能够避免 WXML 节点超过最大要求而引起的性能降低。
四、IntersectionObserver 方式实现
上述的两种方案都是在监听滚动事件的基础上完成对列表项的卸载。下面将尝试将监听封装在列表项内部用 IntersectionObserver Api 实现组件的卸载。
IntersectionObserver 对象,用于推断某些节点是否可以被用户看见、有多大比例可以被用户看见。
在组件节点中添加特有 id 辨识id="list-item-{{rowIndex}}"
同时添加 IntersectionObserver 对其进行监听。为了让其能够像上述两方案一样支持缓冲区列表项加载,在 relativeToViewport 参数中设置 top 和 bottom 的属性。
virtual-observer.wxml:
1 | <view id="list-item-{{rowIndex}}" style="height: {{itemHeight}}rpx"> |
virtual-observer.ts:
1 | ready() { |
调用:
1 | <t-virtual-observer |
效果展示:
此方案由于为了给每一个 list-item 组件都添加对应的 IntersectionObserver,所以每个 item 节点都需要存在,卸载的是内部的内容,所以如果列表很长的话节点数变多不可避免。其次尽管未显示的列表项我们不进行内部渲染,但由于根节点还是需要存在,所以可能会影响初次加载性能。
考虑的优化方案:
- 卸载完整 list-item 组件,而不是只卸载内部内容
- 引入懒加载思想
方案一:卸载完整 list-item 组件
因为 IntersectionObserver Api 是对节点的监听,所以无法做到被监听的节点卸载,方案不可行。
方案二:懒加载
同之前方法一样,当页面触发了 onReachBottom 方法时,添加新的列表项与列表最后。
修改初次加载列表项为 30 个,每次触底加载 10 个后:
效果展示:
由于无法将监听节点卸载,当列表项数少时可以达到预期效果。但当列表项过多时,最大子节点数与子节点数会超过预期,影响性能。
五、实际应用
现阶段的项目中的商品列表页中使用的是懒加载的方式,每次调取接口获取 10 项列表数据添加到商品列表底部,同时也并没有对页面进行节点卸载。利用小程序开发工具的 Audits 进行评分发现只加载了 30 个列表项性能就出了“使用了过大的 WXML 节点数目”问题。
IntersectionObserver 监听
利用上述的思想,将商品的一行展示视为列表的一项,对其进行监听,当其离开视图可见区域则对其内部节点进行卸载。
原来的.wxml 文件
1 | <view wx:for="{{ dataList.length / 2 }}" wx:key="*this" wx:for-item="rowIndex"> |
修改后的.wxml 文件
1 | <list-item |
调整后商品列表页性能:
然而随着商品加载越来越多,最大子节点数超过了标准:
从上图可见,应用了此方案解决了最大节点数超出预期的问题,在列表项小于 60 个的时候能够很大程度上改进商品列表的整体体验评分,也进一步优化了性能;但如果列表项超过 60 个时,性能也会因为子节点数过多而降低。
滚动监听
将列表项用 scroll-view 组件包装,并进行滚动监听。唯一不同的是因为列表项是两行并排展示,所以列表的行数需要统一除以 2,包括配置数组 showListConfig 的长度也对应需要除以 2,得到 Audits 体验评分如下:
六、总结
1. 虚拟长列表 Vs 懒加载
虚拟长列表:
- 优点:可以尽可能减少渲染在页面上的节点数
- 缺点:轻微闪动的情况还是会存在,增加了 buffer 缓冲区还是没法完全避免在非常快速滚动的情况下不闪动。
懒加载:
- 优点:能够防止因为用户快速滚动出现的闪动,通过添加触底的 loading 加载效果能进一步优化交互体验(我们项目商品列表的实现方式)
- 缺点:随着用户不断的滚动,将会有越来越多的节点渲染到页面,如果滑到底部将会渲染整个列表
如果项目严格要求节点数量且能够接受闪动,那么虚拟列表可以满足条件。如果不能接受闪动,而可以接受最终大量节点数量,那么可以优先选择懒加载。
如果已经使用虚拟列表且希望提高首屏渲染速度,可以考虑虚拟长列表结合懒加载的方式。
2. Scroll 滚动监听 Vs IntersectionObserver
两者都是监听的方式
滚动监听事件中会有大量计算,就算添加了节流优化滚动事件还是会被大量调用,且需要记录 scrollTop、元素高度等滚动相关数据。
利用 IntersectionObserver 可以避免大量计算,但是只能做到内部节点卸载,监听的根节点还是需要存在,依旧会引发子节点数过多问题。此方案用来卸载节点还是有不足,但是可以应用于一些固定的模块,比如监听列表加载更多组件的位置,如果进入视图则调取接口获取新数据等。
3. 最后
本文是对长列表的初步探索和实现,希望这篇文章对你能有所帮助。当然还有一定的局限比如列表项的高度需要固定、如何抽成组件复用等,这也是后续要改进的方向,也欢迎交流讨论。
七、参考资料
在写这篇文章的过程中,笔者查看了很多开源项目,也参考了很多文章,相对给我启发较大的有以下: