小程序長列表渲染優化另一種解決方案

blank

小程序長列表渲染優化另一種解決方案

前言

有些需求需要展示長列表,無限下拉都會一直顯示出更多的數據。但是當一個頁面展示的DOM節點過多的時候,會造成小程序頁面的卡頓嚴重的會直接白屏

原因有以下幾點:

  • 列表數據很大,不斷獲取下一屏的數據,setData的數據越來越多的時候耗時高
  • 渲染DOM 結構多,每次setData 都需要創建新的虛擬- 樹、和舊樹diff 操作耗時都比較高
  • DOM 結構多,佔用的內存高,造成頁面被系統回收的概率變大,會白屏

針對這個場景,小程序官方已經有一個解決方案recycle-view:但是使用之後,我發現了很多問題,比如下一頁的頁面渲染不完整,或者拉取下一頁的數據會閃屏。

blank
blank

這些問題都已經反饋給相關開發,但是還沒有得到回复,所以我也不確定是不是我沒有用對,萬一等了半個月最後得到的結論是,官方組件不能滿足我們的場景,那就GG了。所以我只能暫時先追求另外一種解決方案了。

通過查看官方文檔跟組件代碼,可以看到他們的實現思路是這樣的:

blank

由此我猜想,為什麼會出現渲染不完的情況,應該是由於它需要靠著用戶提供的item的高度來算哪些item需要渲染,然後來計算應該渲染出來的屏幕高度。那如果這個計算渲染屏幕高度偏少,就會有渲染不完的情況,計算的高度偏多,又會有渲染出空白的情況。當然我沒有去調試它的代碼,畢竟看別人的代碼是痛苦的,只是瞎猜一通而已。

既然我猜他是因為高度的問題,才出現那麼多問題,同時依賴用戶提供高度我覺得總是不靠譜,萬一他給錯了,就會使得渲染有問題。可不可以不要知道item的高度也可以知道哪些元素被渲染出來呢?

答案是可以的。

我們可以以一屏為一個單位,而不是以一個item為一個單位,這樣我無需開發者給我提供他的高度。我自己去記錄每一屏的高度,然後onscroll的時候,根據scollTop來計算當前應該渲染哪一屏,我把它首尾兩屏的元素也一起加起來算是我總的需要渲染元素。

這一套方案我已經用在了自己的項目:公眾號視頻頻道上。有興趣的小伙伴可以體驗一下,入口是:微信->訂閱號助手->常讀的訂閱號第一個入口點入即可。 (不過該功能還在灰度中,灰度到的朋友歡迎反饋~沒有被灰度到的朋友再等等)

blank

有人可能會說:如果我是一屏拉取所有的數據而不是分屏,那你這個方法就不可行了。確實是這樣的,但是我覺得應該沒有什麼場景需要一次性拉取所有的數據。原因如下:
1、一屏你拉取所有節點,用戶根本看不完,所以意義不大
2、數據太大,造成網絡傳輸慢
3、setState也慢造成首屏慢我感覺沒啥好處,壞處倒是挺多的。所以不太建議一屏直接返回所有數據。

實現思路:

這裡我們通過改造一個通過分屏無限下拉長列表來說一下整個的實現思路。 developers.weixin.qq.com

blank

這是一個長列表,它一屏獲取20個數據,由於元素都長的一樣,所以我用文案標明當前渲染的第幾個元素。特別說明一下:由於我每一個item只渲染一個元素,並且渲染的數據也很簡單,所以不會有我說的那些卡頓,白屏的問題。但是後面的實踐可以用戶複雜的項目中

我的實現方案分為下面兩步:
1、將渲染列表的數組list改成二維數組。
2、只渲染當然可視區域的那一屏以及它前後一屏的元素。其他用空白div佔位要做到第二步,我們還需要分成三小步

  • 需要知道每一屏的高度,這樣我們才能給這個佔位的空白div元素設置高度。
  • 渲染下一屏last的數據,除了保留last,以及last-1那一屏的渲染,其他節點應該為空。
  • 如果是獲取非最後一屏幕的,通過監聽onscrollTop 獲取到scrollTop的值,算取當前應該渲染哪一屏,在重新組裝數據setState

第一步

1、將渲染列表的數組list改成二維數組

為什麼要改成二維數組:

  • 因為我們是以一屏的數據為單位的,所以用二維數組來裝每一屏的數據,方便以屏為單位渲染元素。
  • 性能優化

原來我們是不停的獲取到新一屏的數據就不斷的concat進來,再去setState。

blank

這樣的話,到後期這個finalArr會越來越大,由於setData的調用會把數據從邏輯層傳到渲染層,數據太大會增加通信時間。在性能不好的機型上,setState就會佔用很長的時間,從而造成頁面卡頓。

而我們通過改成二維數組,二維數組裡的每一個子數組都用來裝一屏的數據,然後每次只setState當前的下一屏幕的數據,就可以減少這個通信時間。

說了那麼多,來看看具體如何操作。

首先需要給每一屏一個索引值index來來表示當前是第幾屏,這裡我們用wholePageIndex來表示。

onLoad:function(){this.wholePageIndex=0;this.index=0;constarr=[{idx:this.index++},{idx:this.index++},{idx:this.index++}]this.setData({['list['+this.wholePageIndex+']']:arr})},

然後setData的時候,只是將當前這一屏幕的數據賦值。同理,下拉刷新之後獲取渲染下一屏的數據也一樣,只是需要給wholePageIndex加1表示是下一屏,在賦值即可。

this.wholePageIndex=this.wholePageIndex+1;this.setData({['list['+this.wholePageIndex+']']:arr})

完成後的代碼如下: developers.weixin.qq.com
這樣我們就可以看到,整個渲染是以屏為單位來渲染,比如當前渲染了三屏,那麼dom的結構如下:

blank

第二步

2、只渲染當然可視區域的那一屏以及它前後一屏的元素。其他用空白div佔位來省去渲染過多DOM節點造成的白屏問題

這裡我做了第一屏,或者最後一屏,渲染兩屏的數據即可。因為首屏沒有上面一屏,最後一屏沒有下一屏。

前面說了要做到這一步,我們還得拆分成好幾步:

  • 需要知道每一屏的高度,這樣我們才能給這個佔位的空白div元素設置高度。
  • 渲染下一屏last的數據,除了保留last,以及last-1那一屏的渲染,其他節點應該為空。
  • 如果是獲取非最後一屏幕的,通過監聽onscrollTop 獲取到scrollTop的值,算取當前應該渲染哪一屏,在重新組裝數據setState。

在開始工作之前,先定義幾個變量一會使用。

wholeVideoList:用来装所有屏的数据currentRenderIndex:当前正在渲染哪一屏pageHeightArr:用来装每一屏的高度windowHeight:当前屏幕的高度

在onLoad裡定義:

onLoad:function(){this.index=0;this.wholePageIndex=0;this.wholeVideoList=[];//用來裝所有屏的數據this.currentRenderIndex=0;//當前正在渲染哪一屏this.pageHeightArr=[];//用來裝每一屏的高度this.windowHeight=0;//當前屏幕的高度constarr=[{idx:this.index++},{idx:this.index++},{idx:this.index++},{idx:this.index++}]this.setData({['list['+this.wholePageIndex+']']:arr})}

同時為了區分好每一屏,我們給每一屏的最外層的div都加一個id: wrp_{{pageIndex}}

<view class="page"> <view wx:for="{{ list }}" id="wrp_{{pageIndex}}" wx:for-index="pageIndex" wx:for-item="listSingleItem" wx:key="index"> <view wx:if="{{ listSingleItem.length > 0 }}"> <view class="wrp" wx:for="{{ listSingleItem }}" wx:for-index="index" wx:for-item="listItem" wx:key="index">当前是第{{ listItem.idx }}个元素,为第{{ pageIndex }} 屏数据</view> </view> <view wx:else style="height: {{ listSingleItem.height}}px"> </view> </view> </view>

完成之後,效果如下:

blank

這樣,每一屏的最外層都有一個帶id的元素包裹,我們將要利用它幫助我們去獲取這一屏的高度。

做完預備工作之後,接下來就可以完成我們的第一步驟:獲取每一屏幕的高度,在這裡我把獲取高度放進一個setHeight的函數來完成。

注意:這一步一定要在setState之後,也就是頁面渲染完成之後完成,這樣才能獲取元素的高度。

在setHeight裡,我們通過wx.createSelectorQuery去獲取當前最新渲染這一屏的高度,將其賦值給用於記錄每一屏高度的數組:pageHeightArr。

獲取首屏的高度,需要在onload裡做,而獲取非首屏的高度需要在獲取下一屏數據的函數getVideoInfoData做。

onLoad:function(){// ...this.setData({['list['+this.wholePageIndex+']']:arr},()=>{this.setHeight();//獲取首屏高度})},setHeight:function(){constthat=this;constwholePageIndex=this.wholePageIndex;this.query=wx.createSelectorQuery();this.query.select(`#wrp_${wholePageIndex}`).boundingClientRect()this.query.exec(function(res){that.pageHeightArr[wholePageIndex]=res[0]&&res[0].height;console.log('that.pageHeightArr'+that.pageHeightArr)})},onReachBottom:function(){this.getVideoInfoData();},getVideoInfoData:function(){constarr=[{idx:this.index++},{idx:this.index++},{idx:this.index++}]this.wholePageIndex=this.wholePageIndex+1;this.setData({['list['+this.wholePageIndex+']']:arr},()=>{this.setHeight();//獲取第二屏的高度})},

經過這樣操作之後,我們就知道當前每一屏的高度為多少了,這裡我把這個pageHeightArr打印出來:

blank

完成第一小步之後,接著來看第二小步:渲染下一屏last的數據,除了保留last,以及last-1那一屏的渲染,其他節點用一個空白div佔位。

這裡有個難點,如何做到只渲染後兩屏的數據,其他用空白div佔位又不會造成閃屏呢?其實也很簡單,我們前面已經記錄了每一屏的高度,所以設置空白div的高度為這個高度佔位即可。二維數組裡的子元素我們要渲染的屏幕還是用數組來表示,不渲染的屏幕我們直接用一個對象{height: xXX}來表示。這樣在wxml裡就可以根據元素是否有length屬性來判斷是要渲染真的節點還是空節點。

下面來實現一下:

getVideoInfoData:function(){constarr=[{idx:this.index++},{idx:this.index++},{idx:this.index++}]this.wholePageIndex=this.wholePageIndex+1;constwholePageIndex=this.wholePageIndex;//新增代碼this.currentRenderIndex=wholePageIndex;this.wholeVideoList[wholePageIndex]=arr;letdatas={};lettempList=newArray(wholePageIndex+1).fill(0);if(wholePageIndex>2){tempList.forEach((item,index)=>{if(index<tempList.length-2){tempList[index]={height:this.pageHeightArr[index]};}else{tempList[index]=this.wholeVideoList[index];}})datas.list=tempList;}else{datas['list['+wholePageIndex+']']=arr;}this.setData(datas,()=>{this.setHeight();})},

首先我先把最新渲染的index賦值給表示當前正在渲染的屏幕的currentRenderIndex,同時將最新的數據賦值給裝有所有屏幕數據的wholeVideoList。 currentRenderIndex在第三步要使用,而wholeVideoList一會就會用到。

然後,我先同樣定義了一個數組tempList,當wholePageIndex > 2時,也就是渲染到第四屏的時候就有選擇的渲染,這個不是固定的,你可以根據你的需求改。接著我們用一個循環,判斷當前是否是後兩屏,如果是,才是有數據的數組,這裡的數據就從我們剛剛賦值的wholeVideoList獲取,否則子元素直接為一個對象{ height: this.pageHeightArr[ index]}。

給tempList賦值完之後,setState即可,

完成到這裡還不夠,還需要對wxml改造一下:

<viewclass="page"><viewwx:for="{{ list }}"id="wrp_{{pageIndex}}"wx:for-index="pageIndex"wx:for-item="listSingleItem"wx:key="index"><viewwx:if="{{ listSingleItem.length > 0 }}"><viewclass="wrp"wx:for="{{ listSingleItem }}"wx:for-index="index"wx:for-item="listItem"wx:key="index">當前是第{{ listItem.idx }}個元素,為第{{ pageIndex }} 屏數據</view></view><viewwx:elsestyle="height: {{ listSingleItem.height}}px"></view></view></view>

這裡可以看到wx:if="{{ listSingleItem.length > 0 }}"才去渲染數據,否則只是渲染一個空節點

<viewwx:elsestyle="height: {{ listSingleItem.height}}px"></view>

到這里當我們滾動到第四屏的時候,就可以看到效果了,除了後面兩屏真的渲染了數據,前面的都為一個div佔位,高度為我們之前記錄的高度。

blank

到這裡第二小步完成。

接著第三小步:如果是獲取非最後一屏幕的,通過監聽onscrollTop 獲取到scrollTop的值,算取當前應該渲染哪一屏,在重新組裝數據setState。

這一塊的思路是這樣的,在onPageScroll的回調函數里,通過e.scrollTop獲取到當前的滾動距離。
注意:這裡考慮到性能問題,對onPageScroll做一個節流。

根據這個滾動距離以及每一屏的高度,來計算當前應該渲染哪一屏的數據。這個需要怎麼去算?其實很簡單,循環去遍歷pageHeightArr,用第一屏的高度tempScrollTop與滾動高度realScrollTop +當前屏幕的高度this.windowHeight做比較,如果tempScrollTop比較小,則用tempScrollTop累加第二屏的高度,一直到tempScrollTop > realScrollTop + this.windowHeight就是當前應該渲染的屏幕。

上述文字翻譯成代碼如下:

onPageScroll:throttle(function(e){constrealScrollTop=e.scrollTop;constthat=this;//滾動的時候需要實時去計算當然應該在哪一屏幕lettempScrollTop=0;constwholePageIndex=this.wholePageIndex;for(vari=0;i<this.pageHeightArr.length;i++){tempScrollTop=tempScrollTop+this.pageHeightArr[i];if(tempScrollTop>realScrollTop+this.windowHeight){console.log('set this.computedCurrentIndex'+i);this.computedCurrentIndex=i;break;}}},500),

計算出當前應該渲染的屏幕之後,接著我們就可以需要去對比,當前正在渲染的屏幕index: currentRenderIndex 是不是跟我們計算出來的computedCurrentIndex一樣,如果不同,就說明我們需要調整渲染的屏幕數據,調整的方式跟第二步很像,唯一的區別就是,在這裡我們渲染的是computedCurrentIndex,以及computedCurrentIndex +-1(即前後兩屏)的數據而不是後兩屏的數據。最後將computedCurrentIndex 賦值給currentRenderIndex即可。

onPageScroll:throttle(function(e){constrealScrollTop=e.scrollTop;constthat=this;//滾動的時候需要實時去計算當然應該在哪一屏幕lettempScrollTop=0;constwholePageIndex=this.wholePageIndex;for(vari=0;i<this.pageHeightArr.length;i++){tempScrollTop=tempScrollTop+this.pageHeightArr[i];if(tempScrollTop>realScrollTop+this.windowHeight-30){console.log('set this.computedCurrentIndex'+i);this.computedCurrentIndex=i;break;}}constcurrentRenderIndex=this.currentRenderIndex;if(this.computedCurrentIndex!==currentRenderIndex){//這裡給不渲染的元素佔位lettempList=newArray(wholePageIndex+1).fill(0);tempList.forEach((item,index)=>{if(this.computedCurrentIndex-1<=index&&index<=this.computedCurrentIndex+1){tempList[index]=that.wholeVideoList[index];}else{tempList[index]={height:that.pageHeightArr[index]};}})this.currentRenderIndex=this.computedCurrentIndex;this.setData({list:tempList},()=>{this.setHeight();})}},500),

完整代碼如下:
developers.weixin.qq.com

到這,我們就完成了,一起來看看效果。

https://www.zhihu.com/video/1253480128120266752

可以看到,除了收尾只渲染兩屏,中間都是渲染三屏,其他用的是空白div佔位。

總結

可以看到整個過程的思路也是比較簡單,就是以屏為單位,只渲染屏幕前後兩屏的數據,不渲染的屏數用空白div佔位即可。

但是!當我寫完這篇文章,我想起來一個問題,為啥要通過監聽scroll 的判斷哪一屏來渲染, 直接用observeAPI 就好了啊,把這些scroll裡亂七八糟的計算去掉。這里大家可以自己試試,答案在這一篇

What do you think?

Written by marketer

blank

深入理解Vue3 Reactivity API

blank

Vue3 Compiler 優化細節,如何手寫高性能渲染函數