小程序長(zhǎng)列表優(yōu)化實(shí)踐
一 前言
在一些電商的小程序項(xiàng)目中,長(zhǎng)列表是一個(gè)很普遍的場(chǎng)景,在加載大量的列表數(shù)據(jù)的過(guò)程中,可能會(huì)遇到手機(jī)卡頓,白屏等問(wèn)題。也許數(shù)據(jù)進(jìn)行分頁(yè)處理可以防止一次性加載數(shù)據(jù)帶來(lái)的性能影響,但是隨著數(shù)據(jù)量越來(lái)越大,還是會(huì)讓小程序應(yīng)用越來(lái)越卡頓,響應(yīng)速度越來(lái)越慢。這種問(wèn)題不僅僅在小程序上,在移動(dòng)端 h5 項(xiàng)目中同樣存在。
這個(gè)時(shí)候就需要優(yōu)化長(zhǎng)列表,今天將一起討論一下,長(zhǎng)列表的優(yōu)化方案及其實(shí)踐。
二 小程序長(zhǎng)列表性能瓶頸
影響小程序長(zhǎng)列表性能的因素有很多。我們先分析一下小程序長(zhǎng)列表的性能卡點(diǎn)是什么?
元素節(jié)點(diǎn)限制:一個(gè)太大的 WXML 節(jié)點(diǎn)樹(shù)會(huì)增加內(nèi)存的使用,樣式重排時(shí)間也會(huì)更長(zhǎng),影響體驗(yàn)。微信小程序官方建議一個(gè)頁(yè)面 WXML 節(jié)點(diǎn)數(shù)量應(yīng)少于 1000 個(gè),節(jié)點(diǎn)樹(shù)深度少于 30 層,子節(jié)點(diǎn)數(shù)不大于 60 個(gè)。這種現(xiàn)象的本質(zhì)原因是每次 setData 都需要?jiǎng)?chuàng)建新的虛擬樹(shù)、和舊樹(shù) diff 操作耗時(shí)都比較高。
圖片性能和內(nèi)存的影響:長(zhǎng)列表的情況一般會(huì)有大量的圖片,內(nèi)存占用增長(zhǎng),長(zhǎng)列表中的大量圖片會(huì)導(dǎo)致內(nèi)存占用急劇上升,內(nèi)存增長(zhǎng)如果超過(guò)了限制,也會(huì)導(dǎo)致小程序出現(xiàn)白屏或黑屏,甚至整個(gè)小程序發(fā)生閃退。
setData 頻率以及數(shù)據(jù)量的影響,長(zhǎng)列表的情況下,會(huì)有很多列表單元項(xiàng),如果每個(gè) item 內(nèi)部會(huì)觸發(fā) setData,會(huì)造成 setData 的頻率急速上升;并且在向每一個(gè) item 注入數(shù)據(jù)的時(shí)候,會(huì)造成數(shù)據(jù)量傳輸過(guò)大,這也是一種性能的開(kāi)銷。而且如果在首次渲染過(guò)程中,加載大量的數(shù)據(jù),就會(huì)造成,首次 setData 的時(shí)候耗時(shí)高。
那么上述有一個(gè)問(wèn)題,為什么向 item 注入數(shù)據(jù)會(huì)影響性能呢?這些主要是因?yàn)樾〕绦虻脑O(shè)計(jì)。
整個(gè)小程序框架系統(tǒng)分為兩部分:邏輯層(App Service)和 視圖層(View)。小程序提供了自己的視圖層描述語(yǔ)言 WXML 和 WXSS,以及基于 JavaScript 的邏輯層框架,并在視圖層與邏輯層間提供了數(shù)據(jù)傳輸和事件系統(tǒng),讓開(kāi)發(fā)者能夠?qū)W⒂跀?shù)據(jù)與邏輯。

但是當(dāng)外層向每一個(gè) Item 注入數(shù)據(jù)的時(shí)候,本質(zhì)上是外層邏輯層注入數(shù)據(jù)到視圖層,再?gòu)囊晥D層傳輸?shù)?item 組件的邏輯層。如下圖所示。

解決方案:
那么如上是造成長(zhǎng)列表性能瓶頸的原因,那么解決手段是什么呢?
無(wú)論是上述哪種性能卡點(diǎn),本質(zhì)上原因就是 Item 的數(shù)量過(guò)多導(dǎo)致的,在小程序和移動(dòng)端 h5 上,由于滑動(dòng)加載,會(huì)導(dǎo)致數(shù)據(jù)量越來(lái)越大,item 越來(lái)越多,所以 控制 item 數(shù)量 是解決問(wèn)題的關(guān)鍵。
三 傳統(tǒng)優(yōu)化方案
通過(guò)上面我們知道了,解決長(zhǎng)列表的手段本身就是控制 item 的數(shù)量,原理就是當(dāng)數(shù)據(jù)填充的時(shí)候,理論上數(shù)據(jù)是越來(lái)越多的,但是可以通過(guò)手段,讓視圖上的 item 渲染,而不在視圖范圍內(nèi)的數(shù)據(jù)不需要渲染,那就不去渲染,這樣的好處有:
由于只渲染視圖部分,那么非視圖部分,不需要渲染,或者只放一個(gè) skeleton 骨架元素展位就可以了,首先這大大減少了元素的數(shù)量,也減少了圖片的數(shù)量,直接減少了應(yīng)用占用的內(nèi)存量,減少了白屏的情況發(fā)生。
由于 item 數(shù)量減少了,減少 diff 對(duì)比的數(shù)量,提升了對(duì)比的效率。
如果 item 里面還有 setData 的操作,那么有間接性減少 setData 的數(shù)量,以及數(shù)據(jù)的傳輸量。
明白了基本原理之后,接下來(lái)看一下具體的實(shí)現(xiàn)方案。
1 基于 scroll-view 計(jì)算
讓視圖區(qū)域的 item 真實(shí)的渲染,這是長(zhǎng)列表優(yōu)化的主要手段,那么第一個(gè)問(wèn)題就是如何知道哪些 item 在可視區(qū)域內(nèi)? 正常情況下,當(dāng)在移動(dòng)端滑動(dòng)設(shè)備的時(shí)候,只有手機(jī)屏幕內(nèi)可視區(qū)域是真正需要渲染的部分,如下所示:
那就首先就要知道哪些 item 在屏幕區(qū)域內(nèi),一般情況下,這種長(zhǎng)列表都是基于 scroll-view 實(shí)現(xiàn)的,scroll-view 提供了很多回調(diào)函數(shù)可以處理滾動(dòng)期間發(fā)生的事件。比如 scroll,scrolltoupper,scrolltolower 等。
在 scroll 滑動(dòng)過(guò)程中,可以通過(guò) srollTop 和 scroll-view 的高度,以及每一個(gè) item 的高度,來(lái)計(jì)算哪些 item 是在視圖范圍內(nèi)的。
下面我們來(lái)簡(jiǎn)單的計(jì)算一下,在視圖區(qū)域內(nèi)的 item 的索引:
startIndex:為在視圖區(qū)域內(nèi)的起始索引。 endIndex:為在視圖區(qū)域內(nèi)的末尾索引。
那么計(jì)算流程如下所示:

通過(guò) scrollTop 和每個(gè) item 的高度 itemHeight 計(jì)算起始索引 startIndex。 再通過(guò) startIndex 和容器高度 height 計(jì)算出末尾索引 endIndex。
那么處于 startIndex 和 endIndex 的 item 就是在視圖區(qū)域的 item 。那么通過(guò) slice 截取到列表就是需要渲染的內(nèi)容。
this.setData({
renderList:this.data.dataList.slice(startIndex,endIndex)
})
但是如果只讓視圖中的 item 進(jìn)行渲染,那么其他 item 的地方如何處理呢,因?yàn)槲覀冃枰?scroll-view 構(gòu)造出真實(shí)滑動(dòng)到當(dāng)前位置的效果。這個(gè)時(shí)候?yàn)榱藙?chuàng)建出 scroll-view 真實(shí)的滑動(dòng)效果,不需要渲染數(shù)據(jù)的地方可以用一個(gè)空的元素占位。
緩沖區(qū):但是正常情況下,不會(huì)直接將 startIndex 和 endIndex 作為真正渲染的內(nèi)容。因?yàn)榛瑒?dòng)的速度是快速的,以豎直方向上的滑動(dòng)為例子,如果快速上滑或者下滑過(guò)程中,需要觸發(fā) setData 改變渲染的內(nèi)容,那么更新不及時(shí)的情況下,不會(huì)讓用戶看到真實(shí)的列表內(nèi)容,這樣就會(huì)造成一個(gè)極差的用戶體驗(yàn)。
為了解決這個(gè)問(wèn)題,引出了一個(gè)上下緩沖的概念,就是在渲染真實(shí)的列表 item 的時(shí)候,在滑動(dòng)的兩個(gè)邊界加上一定的緩沖區(qū),在緩沖區(qū)的 item 也會(huì)正常渲染。
還是以上下滑動(dòng)為例子,我們來(lái)看一下,緩沖區(qū)是如何定義的。
比如在視圖區(qū)域的 item 的起始索引為 startIndex,那么如果留一定緩沖區(qū)的話,那么起始索引就變成了,startIndex - bufferCount(這里直接認(rèn)為 startIndex > bufferCount 的前提下)。 同理視圖區(qū)域 item 的末尾索引為 endIndex,那么需要在 endIndex 基礎(chǔ)上加上緩沖區(qū),所以就變成了 endIndex + bufferCount 。
所以 [ startIndex - bufferCount , endIndex + bufferCount ] 為真正的渲染區(qū)間,在這個(gè)區(qū)間內(nèi)部的 item 都會(huì)真實(shí)的渲染。
如下圖,我們來(lái)看一下在滑動(dòng)過(guò)程中,渲染區(qū)間的變化情況:

對(duì)于 bufferCount ,總結(jié)好處有以下二點(diǎn):
緩沖區(qū)從本質(zhì)上防止在快速上滑或者下滑過(guò)程中,setData 更新數(shù)據(jù)不及時(shí),帶來(lái)不友好的視覺(jué)效果。 有了 bufferCount ,可以讓滑動(dòng)到達(dá)一定長(zhǎng)度再進(jìn)行重新計(jì)算渲染邊界,這樣有效的減少了滑動(dòng)過(guò)程中 setData 的頻率。bufferCount 緩沖數(shù)量越大,那么 setData 頻率就會(huì)越小,但是如果 bufferCount 過(guò)大,就違背了虛擬列表的初衷—減少元素?cái)?shù)量,所以開(kāi)發(fā)者需要合理的控制 bufferCount 的大小,正常情況下,屏幕的一屏或者兩屏為宜。
2 基于 observer 處理
小程序提供了 createIntersectionObserver 接口,可以創(chuàng)建 IntersectionObserver 對(duì)象來(lái)判斷元素是否在可視區(qū)域內(nèi)。
這個(gè) api 一般用于判斷曝光埋點(diǎn),微信官方建議使用節(jié)點(diǎn)布局相交狀態(tài)監(jiān)聽(tīng) IntersectionObserver 推斷某些節(jié)點(diǎn)是否可見(jiàn)、有多大比例可見(jiàn);
先來(lái)看一下這個(gè)方法的基礎(chǔ)方法:createIntersectionObserver 創(chuàng)建并返回一個(gè) IntersectionObserver 對(duì)象實(shí)例。在自定義組件或包含自定義組件的頁(yè)面中,應(yīng)使用 this.createIntersectionObserver([options]) 來(lái)代替。接下來(lái)看一下 IntersectionObserver 對(duì)象上有哪些方法。
IntersectionObserver.relativeTo(string selector, Object margins) 使用選擇器指定一個(gè)節(jié)點(diǎn),作為參照區(qū)域之一。
IntersectionObserver.relativeToViewport(Object margins) 指定頁(yè)面顯示區(qū)域作為參照區(qū)域之一。
IntersectionObserver.observe(string targetSelector, IntersectionObserver.observeCallback callback) 指定目標(biāo)節(jié)點(diǎn)并開(kāi)始監(jiān)聽(tīng)相交狀態(tài)變化情況。
IntersectionObserver.disconnect() 停止監(jiān)聽(tīng)?;卣{(diào)函數(shù)將不再觸發(fā)。
基礎(chǔ)使用:
wxml中:
<!--index.wxml-->
<view class="container">
<view class="usermotto" />
<view id="currentView" >觀察元素節(jié)點(diǎn)</view>
</view>
js中:
Page({
onLoad() {
const Observer = wx.createIntersectionObserver(this)
Observer.relativeToViewport({
top: 0, // 當(dāng)元素到達(dá)頂部觸發(fā)回調(diào)事件
bottom:0, // 當(dāng)元素到達(dá)底部觸發(fā)回調(diào)事件
}).observe('#currentView',(res)=>{
console.log('元素是否在可視范圍內(nèi)',res.intersectionRatio <= 0 ? '否' : '是' )
})
},
})
如上通過(guò) IntersectionObserver 對(duì)象來(lái)監(jiān)聽(tīng)元素的位置,然后可以通過(guò) res.intersectionRatio 判斷元素是否在指定的區(qū)域內(nèi)部。

實(shí)現(xiàn)原理:
那么這個(gè)方法,既然能判斷元素的曝光,那么也可以用來(lái)做長(zhǎng)列表優(yōu)化使用。它的實(shí)現(xiàn)原理如下所示:

這種方式可以把數(shù)據(jù)進(jìn)行分組,然后每組創(chuàng)建一個(gè) IntersectionObserver ,當(dāng)分組處于視圖區(qū)域內(nèi)的時(shí)候,才渲染本分組的數(shù)據(jù),那么其他分組沒(méi)有在視圖范圍內(nèi),所以不需要渲染真實(shí)的元素,只需要渲染占位元素或者是骨架節(jié)點(diǎn)就可以了。
緩沖距離:
這種實(shí)現(xiàn)方案也會(huì)存在相同的問(wèn)題,就是在快速滑動(dòng)過(guò)程中,如果只選擇上下邊界 top:0 和 bottom:0 ,那么也會(huì)造成滑動(dòng)時(shí)候,渲染不及時(shí)導(dǎo)致無(wú)法看到正常的列表元素的情況發(fā)生。
為了解決這個(gè)問(wèn)題,那么也會(huì)設(shè)置一定的緩沖距離,這個(gè)一般會(huì)在邊界處入手。比如我們可以設(shè)置當(dāng)列表分組在距離屏幕上邊界和下邊界一屏距離的時(shí)候就觸發(fā)事件,渲染真實(shí)的元素。
wx.getSystemInfo({
success: (res) => {
const { windowHeight } = res
const Observer = wx.createIntersectionObserver(this)
Observer.relativeToViewport({
top: windowHeight, // 距離屏幕頂部一屏距離
bottom:windowHeight,// 距離屏幕底部一屏距離
}).observe('#xxx',(res)=>{
//...
this.setData({
// ...選擇渲染的列表
})
})
},
});
如上面所示,通過(guò) getSystemInfo 獲取屏幕高度 windowHeight,然后設(shè)置 top ,bottom 為屏幕高度,這樣當(dāng)列表分組處于距離屏幕頂部一屏距離和屏幕底部一屏距離都會(huì)觸發(fā)事件,然后就可以通過(guò) intersectionRatio 判斷當(dāng)前列表分組是消失在視圖區(qū)域,還是進(jìn)入到視圖區(qū)域。
實(shí)現(xiàn)的原理圖如下:

3 經(jīng)典落地方案
長(zhǎng)列表解決方案目前已經(jīng)非常成熟了,有很多解決思路,但是本質(zhì)上都是大相徑庭的。這里介紹兩種能夠在實(shí)際開(kāi)發(fā)中落地的技術(shù)方案。
微信小程序官方 recycle-view 方案。 Taro 的虛擬列表方案。
微信 recycle-view 方案
對(duì)于長(zhǎng)列表方案,微信官方有一套自己的解決方案,就是 recycle-view 。
核心的思路就是只渲染顯示在屏幕的數(shù)據(jù),基本實(shí)現(xiàn)就是監(jiān)聽(tīng) scroll 事件,并且重新計(jì)算需要渲染的數(shù)據(jù),不需要渲染的數(shù)據(jù)留一個(gè)空的 div 占位元素。
滾動(dòng)過(guò)程中,重新渲染數(shù)據(jù)的同時(shí),需要設(shè)置當(dāng)前數(shù)據(jù)的前后的 div 占位元素高度,同時(shí)是指在同一個(gè)渲染周期內(nèi)。。
在滾動(dòng)過(guò)程中,為了避免頻繁出現(xiàn)白屏,會(huì)多渲染當(dāng)前屏幕的前后2個(gè)屏幕的內(nèi)容。
長(zhǎng)列表組件由2個(gè)自定義組件 recycle-view、recycle-item 和一組 API 組成,對(duì)應(yīng)的代碼結(jié)構(gòu)如下
├── miniprogram-recycle-view/
└── recycle-view 組件
└── recycle-item 組件
└── index.js
包結(jié)構(gòu)詳細(xì)描述如下:
| 目錄/文件 | 描述 |
|---|---|
| recycle-view 組件 | 長(zhǎng)列表組件 |
| recycle-item 組件 | 長(zhǎng)列表每一項(xiàng) item 組件 |
| index.js | 提供操作長(zhǎng)列表數(shù)據(jù)的API |
來(lái)看一下具體的使用:
安裝組件
npm install --save miniprogram-recycle-view
{
"usingComponents": {
"recycle-view": "miniprogram-recycle-view/recycle-view",
"recycle-item": "miniprogram-recycle-view/recycle-item"
}
}在頁(yè)面的 json 配置文件中添加 recycle-view 和 recycle-item 自定義組件的配置 <recycle-view batch="{{batchSetRecycleData}}" id="recycleId">
<view slot="before">長(zhǎng)列表前面的內(nèi)容</view>
<recycle-item wx:for="{{recycleList}}" wx:key="id">
<view>
<image style='width:80px;height:80px;float:left;' src="{{item.image_url}}"></image>
{{item.idx+1}}. {{item.title}}
</view>
</recycle-item>
<view slot="after">長(zhǎng)列表后面的內(nèi)容</view>
</recycle-view>WXML 文件中引用 recycle-view const createRecycleContext = require('miniprogram-recycle-view')
Page({
onReady: function() {
var ctx = createRecycleContext({
id: 'recycleId',
dataKey: 'recycleList',
page: this,
itemSize: { // 這個(gè)參數(shù)也可以直接傳下面定義的this.itemSizeFunc函數(shù)
width: 162,
height: 182
}
})
ctx.append(newList)
// ctx.update(beginIndex, list)
// ctx.destroy()
},
itemSizeFunc: function (item, idx) {
return {
width: 162,
height: 182
}
}
})頁(yè)面 JS 管理 recycle-view 的數(shù)據(jù)
通過(guò)這種的優(yōu)缺點(diǎn)是顯而易見(jiàn)的。首先對(duì)于 view 和 item 的結(jié)構(gòu)是清晰的,但是對(duì)于數(shù)據(jù)需要手動(dòng)通過(guò) ctx.append 進(jìn)行追加,而且對(duì)于整個(gè) recycle-view 和 recycle-item 的處理邏輯是要和業(yè)務(wù)層耦合在一起的,這種方式對(duì)于小程序的開(kāi)發(fā)者有一定技術(shù)熟練度的要求。
當(dāng)然 recycle-view 是基于微信原生小程序?qū)崿F(xiàn)的,所以可以適用于原生小程序,以及基于原生小程序衍變的其他平臺(tái)小程序,比如支付寶小程序,美團(tuán)小程序等。即便是 api 層面不兼容,也可以通過(guò)下載改造的方式,來(lái)應(yīng)用到我們的項(xiàng)目中。
Taro 虛擬列表方案
Taro 是多端統(tǒng)一開(kāi)發(fā)的解決方案,可以一套代碼運(yùn)行到移動(dòng) web 端,小程序端,React Native 端,Taro 的實(shí)現(xiàn)原理也如出一轍,比起全量渲染數(shù)據(jù)生成的視圖,Taro 只渲染當(dāng)前可視區(qū)域(visible viewport)的視圖,非可視區(qū)域的視圖在用戶滾動(dòng)到可視區(qū)域再渲染:

如上圖就是大致的實(shí)現(xiàn)原理。
基本使用:
使用 React/Nerv 我們可以直接從 @tarojs/components/virtual-list 引入虛擬列表(VirtualList)組件:
import VirtualList from '@tarojs/components/virtual-list'
以 Taro React 為例子,接下來(lái)看一下 VirtualList 的具體使用:
function buildData (offset = 0) {
return Array(100).fill(0).map((_, i) => i + offset);
}
const Row = React.memo(({ id, index, style, data }) => {
return (
<View id={id} className={index % 2 ? 'ListItemOdd' : 'ListItemEven'} style={style}>
Row {index} : {data[index]}
</View>
);
})
export default class Index extends Component {
state = {
data: buildData(0),
}
render() {
const { data } = this.state
const dataLen = data.length
return (
<VirtualList
height={500} /* 列表的高度 */
width='100%' /* 列表的寬度 */
itemData={data} /* 渲染列表的數(shù)據(jù) */
itemCount={dataLen} /* 渲染列表的長(zhǎng)度 */
itemSize={100} /* 列表單項(xiàng)的高度 */
>
{Row} /* 列表單項(xiàng)組件,這里只能傳入一個(gè)組件 */
</VirtualList>
);
}
}
VirtualList 的五個(gè)屬性都是必填項(xiàng)。VirtualList 的數(shù)據(jù)處理,數(shù)據(jù)截取,空白填充都是內(nèi)部實(shí)現(xiàn)的,開(kāi)發(fā)者只需要關(guān)注將 data 數(shù)據(jù)注入到 VirtualList 就可以了。這樣讓虛擬列表使用成本大大降低,也降低了和業(yè)務(wù)的耦合度。
VirtualList 這種方式是基于 Taro 平臺(tái)開(kāi)發(fā)的,所以它的使用場(chǎng)景就有一定局限性,開(kāi)發(fā)者只能通過(guò) Taro 中使用,比如一些原生小程序,就很不適用了,即便是想要通過(guò)改造源碼的方式來(lái)讓 VirtualList 兼容原生小程序或者其他平臺(tái)的小程序,成本也是巨大的,無(wú)異于重構(gòu)一下項(xiàng)目。
四 改進(jìn)版優(yōu)化方案
接下來(lái)我們實(shí)現(xiàn)一個(gè)長(zhǎng)列表組件,選用的是第二種基于 IntersectionObserver 這種方式,我們實(shí)現(xiàn)的這個(gè)長(zhǎng)列表遵循一下原則:
和業(yè)務(wù)低耦合,業(yè)務(wù)只負(fù)責(zé)往長(zhǎng)列表綁定列表數(shù)據(jù)就可以了。列表數(shù)據(jù)是可以追加的。 長(zhǎng)列表組件提供了兩個(gè)抽象節(jié)點(diǎn),一個(gè)是真實(shí)渲染的 item ,一個(gè)是占位的 skeleton。 除此之外,因?yàn)榱斜硪话銜?huì)有頭部和尾部,所以提供兩個(gè)插槽用于占位使用。分別為列表前的 before,和列表后的 after。
如何使用
業(yè)務(wù)組件使用:在正式講解之前,先來(lái)看一下長(zhǎng)列表組件是如何使用的:
業(yè)務(wù)組件 wxml 文件:
<long-list-view
list="{{list}}"
generic:item="list-item"
generic:skeleton="list-skeleton"
/>
可以看到 long-list-view 的 props 只有三個(gè)。
list 為輸入給 long-list-view 的數(shù)據(jù)源。 generic:item 為抽象節(jié)點(diǎn),指向了 list-item。 generic:skeleton 也是抽象節(jié)點(diǎn),指向了 list-skeleton。
我們看一下業(yè)務(wù)組件的 json 文件:
"usingComponents": {
"list-item":"...", // 業(yè)務(wù)組件每個(gè)卡片組件 item
"list-skeleton":"...", // 當(dāng)業(yè)務(wù)組件不渲染時(shí),占位的組件
"long-list-view":"..." // 長(zhǎng)列表組件
}
這里引入了一個(gè)新的概念—抽象節(jié)點(diǎn)。那么我們先來(lái)看看什么是抽象節(jié)點(diǎn)。
抽象節(jié)點(diǎn)
有時(shí),自定義組件模板中的一些節(jié)點(diǎn),其對(duì)應(yīng)的自定義組件不是由自定義組件本身確定的,而是自定義組件的調(diào)用者確定的。這時(shí)可以把這個(gè)節(jié)點(diǎn)聲明為“抽象節(jié)點(diǎn)”。
例如,我們現(xiàn)在來(lái)實(shí)現(xiàn)一個(gè)“選框組”(selectable-group)組件,它其中可以放置單選框(custom-radio)或者復(fù)選框(custom-checkbox)。這個(gè)組件的 wxml 可以這樣編寫(xiě):
<!-- selectable-group.wxml -->
<view wx:for="{{labels}}">
<label>
<selectable disabled="{{false}}"></selectable>
{{item}}
</label>
</view>
其中,“selectable”不是任何在 json 文件的 usingComponents 字段中聲明的組件,而是一個(gè)抽象節(jié)點(diǎn)。它需要在 componentGenerics 字段中聲明:
{
"componentGenerics": {
"selectable": true
}
}
通過(guò)上面明白了抽象節(jié)點(diǎn)的使用。那么為什么要用抽象節(jié)點(diǎn)呢?
正常情況下,我們長(zhǎng)列表組件是作為公共組件使用的,這時(shí)就存在一個(gè)問(wèn)題,我們的 item 組件如果不用抽象節(jié)點(diǎn)的情況下,組件 item 是需要注冊(cè)到長(zhǎng)列表組件中去的,那么也就是長(zhǎng)列表本身只能服務(wù)于這一種場(chǎng)景,和業(yè)務(wù)強(qiáng)關(guān)聯(lián)到了一起。 這里可能有的同學(xué)會(huì)想到用 slot 插槽解決,但是 slot 作為 item的話,我們是無(wú)法去循環(huán) slot 插槽的,但是也并不是不能解決,就像微信官方 recycle-view 一樣,可以通過(guò)內(nèi)外層 recycle-view 和 recycle-item 嵌套,然后通過(guò) relations 建立起關(guān)聯(lián),這樣本質(zhì)上需要維護(hù)兩個(gè)公共組件。
recycle-item
relations: {
'./recycle-view': {
type: 'parent', // 關(guān)聯(lián)的目標(biāo)節(jié)點(diǎn)應(yīng)為子節(jié)點(diǎn)
linked() {}
}
},
recycle-view
relations: {
'../recycle-item/recycle-item': {
type: 'child', // 關(guān)聯(lián)的目標(biāo)節(jié)點(diǎn)應(yīng)為子節(jié)點(diǎn)
}
}
言歸正傳,我們主要是通過(guò)抽象節(jié)點(diǎn)實(shí)現(xiàn)的,抽象節(jié)點(diǎn)的注冊(cè)是在業(yè)務(wù)組件中的,但是使用是在公共組件中的,這樣就大大降低和業(yè)務(wù)組件的耦合程度。
說(shuō)明白了抽象節(jié)點(diǎn),然后我們來(lái)看一下長(zhǎng)列表組件結(jié)構(gòu)。
├── long-list-view/
└── index.js
└── index.wxml
└── index.json
└── index.wxss
首先要在 index.json 聲明當(dāng)前組件和抽象節(jié)點(diǎn)。
index.json
{
"component": true,
"componentGenerics": {
"item": true, //抽象節(jié)點(diǎn) item
"skeleton": true //抽象節(jié)點(diǎn) skeleton
}
}
接下來(lái)就是重點(diǎn)看一下 index.js 。從上面我們知道傳入長(zhǎng)列表組件中的數(shù)據(jù) list ,list 是隨著加載數(shù)據(jù)增多,會(huì)越來(lái)越多;同時(shí)還有有一種情況發(fā)生,就是如果 list 變化特別頻繁,那么會(huì)讓長(zhǎng)列表一直觸發(fā) setData 來(lái)執(zhí)行渲染任務(wù),這樣也會(huì)造成卡頓的,那么我們長(zhǎng)列表需要做的事情是:
把 list 規(guī)范化,也就是只處理新增的列表數(shù)據(jù),將它們按照 observer 分組處理。這樣當(dāng)視圖容器滾動(dòng)的時(shí)候,只渲染目標(biāo)范圍內(nèi)的分組數(shù)據(jù)。 第二點(diǎn)就是對(duì)于渲染任務(wù),需要做時(shí)間切片處理,防止 list 變化特別頻繁,造成一直處于 setData 更新,而使得用戶響應(yīng)比較慢。
時(shí)間切片
時(shí)間切片的手段通常可以采用 setTimeout 來(lái)模擬實(shí)現(xiàn),比如一次性有 50 個(gè) item 需要渲染,那么 list 每次追加 10 個(gè)item ,這個(gè)時(shí)候就會(huì)讓 setData 在短時(shí)間內(nèi)執(zhí)行 5 次,并且還要有視圖上的響應(yīng),這樣就會(huì)造成性能上和用戶體驗(yàn)上的問(wèn)題。
那么時(shí)間切片是如何解決這個(gè)問(wèn)題的呢?首先每一次渲染會(huì)創(chuàng)建一個(gè)渲染任務(wù) task,但是并不會(huì)立即執(zhí)行 task,而是把 task 放進(jìn)一個(gè)待渲染的隊(duì)列 renderPendingQueue 中,然后每次執(zhí)行隊(duì)列中的一個(gè)任務(wù),當(dāng)任務(wù)執(zhí)行完畢后,通過(guò) setTimeout 來(lái)在下一次的宏任務(wù)中再次執(zhí)行下個(gè)更新任務(wù)。其原理圖如下所示:

下面我們來(lái)實(shí)現(xiàn)這個(gè)功能:
Component({
properties:{
list: {
type: Array,
value: [],
/* 通過(guò) observer 來(lái)監(jiān)聽(tīng) list 的變化 */
observer(val, preVal) {
if (val.length) {
const cloneVal = val.slice();
/* 找到增量傳入的 list */
cloneVal.splice(0, preVal.length);
/* 創(chuàng)建一個(gè)渲染任務(wù) */
const task = () => {
/* setList 為真正需要執(zhí)行的渲染列表的方法 */
this.setList(cloneVal);
};
/* 把渲染任務(wù)放入到待渲染隊(duì)列中 */
this.renderPendingQueue.push(task);
/* 開(kāi)始執(zhí)行渲染任務(wù) */
this.runRenderTask();
}
},
},
},
methods:{
/* 更新待渲染對(duì)列 */
runRenderTask() {
/* 如果沒(méi)有渲染任務(wù)了,或者渲染任務(wù)已經(jīng)開(kāi)始,那么直接返回 */
if (this.renderPendingQueue.length === 0 || this.isRenderTask) return;
/* 取出第一個(gè)渲染任務(wù) */
const current = this.renderPendingQueue.shift();
/* 開(kāi)始渲染 */
this.isRenderTask = true;
/* 執(zhí)行渲染任務(wù) */
typeof current === 'function' && current();
},
},
})
這個(gè)的處理邏輯是:
首先通過(guò)對(duì) props 中的 list 屬性進(jìn)行監(jiān)聽(tīng),找到增量傳入的 list ,然后創(chuàng)建一個(gè)渲染任務(wù),把渲染任務(wù)放入到待渲染隊(duì)列中 ,接下來(lái)開(kāi)始執(zhí)行渲染任務(wù)。 在執(zhí)行更新任務(wù)的方法中,如果沒(méi)有渲染任務(wù)了,或者渲染任務(wù)已經(jīng)開(kāi)始,那么直接返回;反之取出第一個(gè)渲染任務(wù),開(kāi)始執(zhí)行。
從上面可以知道,setList 為真正需要執(zhí)行的渲染列表的方法,我們接著往下看:
Component({
//...
data:{
/* 待渲染的視圖列表 */
viewList: [],
/* 手機(jī)屏幕高度 */
winHeight: 0,
/* 分組索引 */
groupIndex:0
},
lifetimes: {
ready() {
wx.getSystemInfo({
success: (res) => {
this.setData({
/* 獲取屏幕的高度 */
winHeight: res.windowHeight,
});
},
});
/* 保存所有數(shù)據(jù) */
this.wholeList = []
/* 記錄分組高度 */
this.groupHeightArr = []
}
},
methods:{
/* 裝載數(shù)據(jù) */
setList(val) {
const { groupIndex } = this.data;
const newList = this.data.viewList;
/* 把數(shù)據(jù)保存到 wholeList 中 */
this.wholeList[groupIndex] = val;
/* 插入到視圖列表中 */
newList[groupIndex] = val;
this.data.groupIndex++;
/* 直接渲染最新加入的數(shù)據(jù) */
this.setData(
{
viewList: newList,
},
() => {
/* 記錄渲染后的視圖高度 */
this.setHeight(groupIndex);
}
);
},
},
})
setList 做的事情很簡(jiǎn)單,將增量的列表數(shù)據(jù)渲染出來(lái),然后開(kāi)始通過(guò) setHeight 將渲染后的數(shù)據(jù)高度記錄下來(lái),這里有的同學(xué)會(huì)問(wèn),為什么要記錄渲染后的數(shù)據(jù)高度,原因很簡(jiǎn)單,如果列表單元項(xiàng) item 在規(guī)定的渲染區(qū)域之外,那么需要 skeleton 占位,但是需要設(shè)置 skeleton 高度,所以需要記錄分組的高度作為占位節(jié)點(diǎn)的高度。
Component({
//...
methods:{
//...
···/* 設(shè)置高度 */
setHeight(groupIndex) {
const query = wx.createSelectorQuery().in(this);
query && query
.select(`#wrp_${groupIndex}`)
.boundingClientRect((res) => {
/* 記錄分組的高度 */
this.groupHeightArr[groupIndex] = res && res.height;
})
.exec();
/* 開(kāi)始監(jiān)聽(tīng)分組變化 */
this.observePage(groupIndex);
},
},
})
通過(guò) setHeight 來(lái)記錄高度之后。那么接下來(lái)就需要給當(dāng)前分組創(chuàng)建一個(gè) IntersectionObserver 來(lái)判斷:
如果當(dāng)前分組,在規(guī)定視圖范圍內(nèi),那么渲染真實(shí)的 item 元素。 如果當(dāng)前分組,不在規(guī)定的視圖范圍內(nèi),那么渲染 skeleton 占位節(jié)點(diǎn)。
Component({
//...
methods:{
//...
/* 觀察頁(yè)面變化 */
observePage(groupIndex) {
wx.createIntersectionObserver(this)
.relativeToViewport({
/* 這里規(guī)定的有效區(qū)域?yàn)閮蓚€(gè)屏幕 */
top: 2 * this.data.winHeight,
bottom: 2 * this.data.winHeight,
})
.observe(`#wrp_${groupIndex}`, (res) => {
const newList = this.data.viewList;
const nowWholeList = this.wholeList[groupIndex];
if (res.intersectionRatio <= 0) {
console.log('當(dāng)前分組:',groupIndex,'虛擬節(jié)點(diǎn)占位' )
/* 如果不在有效的視圖范圍內(nèi),那么不需要渲染真實(shí)的數(shù)據(jù),只需要計(jì)算高度,進(jìn)行占位就可以了。 */
const listViewHeightArr = [];
const listViewItemHeight = this.groupHeightArr[groupIndex] / nowWholeList.length;
for (let i = 0; i < nowWholeList.length; i++) {
listViewHeightArr.push({ listViewItemHeight });
}
newList[groupIndex] = listViewHeightArr;
} else {
console.log('當(dāng)前分組:',groupIndex,'顯示' )
/* 如果在有效的區(qū)域內(nèi),那么直接渲染真實(shí)的數(shù)據(jù)就可以了 */
newList[groupIndex] = this.wholeList[groupIndex];
}
this.setData({
viewList: newList,
}, () => {
this.isRenderTask = false;
/* 渲染下一個(gè)更新任務(wù) */
this.runRenderTask();
});
});
},
},
})
看一下 observePage 做了哪些事情?
首先通過(guò) IntersectionObserver 創(chuàng)建一個(gè)觀察者對(duì)象,這里規(guī)定的有效區(qū)域?yàn)閮蓚€(gè)屏幕。 接下來(lái)當(dāng)滑動(dòng)屏幕的時(shí)候,如果不在有效的視圖范圍內(nèi),那么不需要渲染真實(shí)的數(shù)據(jù),只需要計(jì)算高度,進(jìn)行占位就可以了。 如果在有效的區(qū)域內(nèi),那么直接渲染真實(shí)的數(shù)據(jù)就可以了。
接下來(lái)看一下 wxml 如何處理的:
<view class="list-view">
<!-- 列表前自定義插槽 -->
<slot name="before" />
<view
wx:for="{{ viewList }}"
id="wrp_{{ groupIndex }}"
wx:for-index="groupIndex"
wx:for-item="listItem"
wx:key="index"
>
<view
wx:for="{{ listItem }}"
wx:for-item="listItem"
wx:key="index"
>
<block wx:if="{{ listItem.listViewItemHeight }}">
<!-- 不在可視范圍內(nèi) -->
<view style="height: {{listItem.listViewItemHeight}}px;overflow: hidden">
<skeleton/>
</view>·
</block>
<block wx:else>
<!-- 在可視范圍內(nèi) -->
<item listItem="{{listItem}}"/>
</block>
</view>
</view>
<!-- 列表后自定義插槽 -->
<slot name="after" />
</view>
模擬使用:
接下來(lái)我們做一下模擬:
<scroll-view
scroll-y="{{ true }}"
bindscrolltolower="handleScrollLower"
style="height:{{winHeight}}px;"
lower-threshold="200"
>
<long-list-view
list="{{list}}"
generic:item="item"
generic:skeleton="skeleton"
/>
</scroll-view>
數(shù)據(jù)是模擬的,接下來(lái)看一下整體的效果:

五 總結(jié)
本章節(jié)介紹了在小程序端長(zhǎng)列表的性能瓶頸,介紹了常用的解決方案,感興趣的同學(xué)可以試著實(shí)現(xiàn)一下長(zhǎng)列表,也希望做小程序列表優(yōu)化的同學(xué)看到,能夠有一個(gè)啟發(fā)。
微信小程序運(yùn)行時(shí)性能
Taro:長(zhǎng)列表渲染(虛擬列表)
recycle-view
