Taro性能優(yōu)化之復(fù)雜列表篇
一、背景
隨著項目的不斷迭代,規(guī)模日益增大,而基于Taro3的運行時弊端也日漸凸顯,尤其在復(fù)雜列表頁面上表現(xiàn)欠佳,極度影響用戶體驗。本文將以復(fù)雜列表的性能優(yōu)化為主旨,嘗試建立檢測指標(biāo),了解性能瓶頸,通過預(yù)加載、緩存、優(yōu)化組件層級、優(yōu)化數(shù)據(jù)結(jié)構(gòu)等多種方式,實驗后提供一些技術(shù)方案的建議,希望可以給大家?guī)硪恍┧悸贰?/p>
二、問題現(xiàn)狀及分析
我們以酒店某一多功能列表為例(下圖),設(shè)定檢測標(biāo)準(zhǔn)(setData次數(shù)及該setData的響應(yīng)時效作為指標(biāo)),檢測情況如下:
指標(biāo) | setData次數(shù) | 渲染耗時(ms) |
第一次進入列表頁 | 7 | 2404 |
下拉長列表更新 | 3 | 1903 |
多屏列表下 篩選項更新 | 2 | 1758 |
多屏列表下 列表項更新 | 2 | 748 |
由于歷史原因,該頁面的代碼,由微信的原生轉(zhuǎn)成的taro1,后續(xù)迭代至taro3。項目中存在小程序原生寫法可能忽略的問題。根據(jù)上面多次測出的指標(biāo)值,以及視覺體驗上來看,存在以下問題:
2.1 首次進入列表頁的加載時間過長,白屏?xí)r間久
- 列表頁請求的接口時間過長;
- 初始化列表也是setData數(shù)據(jù)量過大,且次數(shù)過多;
- 頁面節(jié)點數(shù)過多,導(dǎo)致渲染耗時較長;
2.2 頁面篩選項的更新卡頓,下拉動畫卡頓
- 篩選項中節(jié)點過多,更新時setData數(shù)據(jù)量大;
- 篩選項的組件更新會導(dǎo)致頁面跟著一起更新;
2.3 無限列表的更新卡頓,滑動過快會白屏
- 請求下一頁的時機過晚;
- setData時數(shù)據(jù)量大,響應(yīng)慢;
- 滑動過快時,沒有從白屏到渲染完成的過渡機制,體驗欠佳;
三、嘗試優(yōu)化的方案
3.1 跳轉(zhuǎn)預(yù)加載API:
通過觀察小程序的請求可以發(fā)現(xiàn),列表頁請求中,有兩個請求耗時較為長。
在Taro3的升級中,官方有提到預(yù)加載Preload,在小程序中,從調(diào)用 Taro.navigateTo 等路由跳轉(zhuǎn) API 后,到小程序頁面觸發(fā) onLoad 會有一定延時(約300ms,如果是分包新下載則跳轉(zhuǎn)時間更長),因此一些網(wǎng)絡(luò)請求可以提前到發(fā)起跳轉(zhuǎn)時一起去請求。于是我們在在跳轉(zhuǎn)前,使用Taro.preload預(yù)先加載復(fù)雜列表的請求:
// Page A
const query = new Query({
// ...
})
Taro.preload({
RequestPromise: requestPromiseA({data: query }),
})
// Page B
componentDidMount() {
// 在跳轉(zhuǎn)的過程中,發(fā)出請求,因為返回的是一個promise,所以需要在B頁面承接:
Taro.getCurrentInstance().preloadData?.RequestPromise?.then(res => {
this.setState(this.processResData(res.data))
})
}
用同樣的檢測方式反復(fù)測試后,使用preload的時,能提前300~400ms提前拿到酒店的列表數(shù)據(jù)。
左邊是沒使用preload的舊列表,右邊是預(yù)加載的列表,能明顯看出預(yù)加載后的列表會快一些。
然而在實際的使用中我們發(fā)現(xiàn)preload存在部分缺陷,對于承接頁面,如果接口較為復(fù)雜,會對業(yè)務(wù)流程的代碼有一定的入侵。究其本質(zhì),是前置了網(wǎng)絡(luò)請求,所以我們可以對網(wǎng)絡(luò)請求部分加入緩存策略,即可達到該效果,且接入成本會大大降低。
3.2 合理運用setData
setData 是小程序開發(fā)中使用最頻繁、也是最容易引發(fā)性能問題的API。setData 的過程,大致可以分成幾個階段:
- 邏輯層虛擬 DOM 樹的遍歷和更新,觸發(fā)組件生命周期和 observer 等;
- 將 data 從邏輯層傳輸?shù)揭晥D層;
- 視圖層虛擬 DOM 樹的更新、真實 DOM 元素的更新并觸發(fā)頁面渲染更新。
數(shù)據(jù)傳輸?shù)暮臅r與數(shù)據(jù)量的大小正相關(guān),舊的列表頁第一次加載的時候,一共請求了4個接口,setData短時間里有6次,數(shù)據(jù)量偏大的有兩次,我們嘗試的優(yōu)化方式為,將數(shù)據(jù)量大的兩次分開,另外五次發(fā)現(xiàn)都是一些零散的狀態(tài)和數(shù)據(jù),可以作為一次。
指標(biāo) | setData次數(shù) | setData耗時(ms) | 減少耗時百分比 |
第一次進入列表頁 | 3 | 2182 | 9.23% |
進行完這一步的操作,平均能減少200ms左右,效果較小,因為頁面的節(jié)點數(shù)沒變,setData主要的耗時還分布于渲染時間。
3.3 優(yōu)化頁面的節(jié)點數(shù)
根據(jù)微信官方文檔的說明,一個太大的節(jié)點樹會增加內(nèi)存使用的同時,樣式重排時間上也會更長。建議一個頁面節(jié)點數(shù)量應(yīng)少于 1000 個,節(jié)點樹深度少于 30 層,子節(jié)點數(shù)不大于 60 個。
在微信開發(fā)者工具中分析該頁面兩個模塊存在大量的節(jié)點數(shù)。一個是篩選項模塊,一個是長列表的模塊。因為這部分功能較多,且結(jié)構(gòu)復(fù)雜,我們采用了選擇性渲染。如在用戶瀏覽列表式,篩選項不生成具體節(jié)點。點擊展開篩選的時候再渲染出節(jié)點,對于頁面列表的體驗有一定程度的緩解。另一方面,對于整體布局的書寫上,有意識的避免嵌套過深的寫法,如RichText使用,部分選擇圖片代替等。
3.4 優(yōu)化篩選項相關(guān)
3.4.1 改變動畫方式
在重構(gòu)篩選項的過程中,發(fā)現(xiàn)在一些機型上,小程序的動畫效果不太理想,比如當(dāng)打開篩選項tab的時候,需要實現(xiàn)一個向下拉出的效果,早期在實現(xiàn)的時候,會出現(xiàn)兩個問題:
- 動畫會閃一下 然后再出現(xiàn)
- 篩選頁面節(jié)點過多時,點擊響應(yīng)過慢,用戶體驗差
舊的篩選項的動畫是通過keyframes方式實現(xiàn)了一個fadeIn的動畫,加在最外層,但是無論如何在動畫出現(xiàn)的那一幀,都會閃一下。分析下來,因為keyframes執(zhí)行動畫造成的卡頓:
.filter-wrap {
animation: .3s ease-in fadeIn;
}
@keyframes fadeIn {
0% {
transform: translateY(-100%)
}
100% {
transform: translateY(0)
}
}
于是,嘗試換了一種實現(xiàn)方式,通過transition來實現(xiàn)transfrom:
.filter-wrap {
transform: translateY(-100%);
transition: none;
&.active {
transform: translateY(0);
transition: transform .3s ease-in;
}
}
3.4.2 維護簡潔的state
操作篩選項的時候,每操作一次都需要根據(jù)唯一id從篩選項的數(shù)據(jù)結(jié)構(gòu)中循環(huán)遍歷,去找到對應(yīng)的item,改掉item的狀態(tài),然后將整個結(jié)構(gòu)重新setState。官方文檔中提到關(guān)于setState,應(yīng)該盡量避免處理過大的數(shù)據(jù),會影響頁面的更新性能。
針對這一問題,采取的辦法是:
- 預(yù)先將復(fù)雜的對象扁平化,示例如下:
{
"a": {
"subs": [{
"a1": {
"subs": [{
"id": 1
}]
}
}]
},
"b": {
"subs": [{
"id": 2
}]
},
// ...
}
扁平化后的篩選項數(shù)據(jù)結(jié)構(gòu):
{
"1": {
"id": 1,
"name": "漢庭",
"includes": [],
"excludes": [],
// ...
},
"2": {
// ...
},
// ...
}
- 不改變原有的數(shù)據(jù),利用扁平化后的數(shù)據(jù)結(jié)構(gòu)維護一個動態(tài)的選中列表:
const flattenFilters = data => {
// ...
return {
[id]: {
id: 2,
name: "全季",
includes: [],
excludes: []
// ...
},
// ...
}
}
const filters = [], filtersSelected = {}
const flatFilters = flattenFilters(filters)
const onClickFilterItem = item => {
// 所有的操作需要先拿到扁平化的item
const flatItem = flatFilters[item.id]
if (filtersSelected[flatItem.id]) {
// 已選中,需要取消選中
delete filtersSelected[flatItem.id]
}
else {
// 未選中,需要選中
filtersSelected[flatItem.id] = flatItem
// 取消選中排斥項
const idsSelected = Object.keys(filtersSelected)
const idsIntersection = intersection(idsSelected, flatItem.selfExcludes) // 交集
if (idsIntersection.length) {
idsIntersection.forEach(id => {
delete filtersSelected[id]
})
}
// 其他邏輯 (快篩,關(guān)鍵詞等)
}
this.setState({filtersSelected})
}
上面是一個簡單的實現(xiàn),前后對比,我們只需要維護一個很簡單的對象,對其屬性進行添加或者刪除,性能有細微的提高,且代碼更為簡單整潔。在業(yè)務(wù)代碼中,類似這種通過數(shù)據(jù)結(jié)構(gòu)轉(zhuǎn)換提升效率的地方有很多。
關(guān)于篩選項,可以對比下檢測的平均數(shù)據(jù),減少200ms~300ms,也會得到一些提升:
指標(biāo) | setData耗時舊 | setData耗時新 | 減少耗時百分比 |
長列表下篩選項展開 | 1023 | 967 | 5.47% |
長列表下點擊篩選項 | 1758 | 1443 | 17.92% |
3.5 長列表的優(yōu)化
早期酒店列表頁引入了虛擬列表,針對長列表渲染一定數(shù)目的酒店。核心的思路是只渲染顯示在屏幕的數(shù)據(jù),基本實現(xiàn)就是監(jiān)聽 scroll 事件,并且重新計算需要渲染的數(shù)據(jù),不需要渲染的數(shù)據(jù)留一個空的 div 占位元素。
- 加載下一頁有輕微的卡頓:
通過數(shù)據(jù)發(fā)現(xiàn),下拉更新列表平均耗時1900ms左右:
指標(biāo) | setData次數(shù) | setData耗時 |
下拉列表更新 | 3 | 1903 |
針對這個問題,解決方案是,提前加載下一頁的數(shù)據(jù),將下一頁存入內(nèi)存變量中。滾動加載的時候直接從內(nèi)存變量中去取,然后setData更新到數(shù)據(jù)中。
- 滑動速度過快會出現(xiàn)白屏(速度越快白屏?xí)r間越久,下方左圖):虛擬列表的原理就是利用空的View去占位,當(dāng)快速回滾的時候,渲染的時候當(dāng)節(jié)點過于復(fù)雜,特別是酒店帶有圖片,渲染就會變慢,導(dǎo)致白屏,我們進行了三種方案的嘗試:1) 使用動態(tài)的骨架圖代替原有的View占位 下方圖右:
2) CustomWrapper
為了提升性能,官方推薦了CusomWrapper,它可以將包裹的組件與頁面隔離,組件渲染時不會更新整個頁面,由page.setData變?yōu)閏omponent.setData。
自定義組件是基于Shadow DOM實現(xiàn)的,對組件中的DOM和CSS進行了封裝,使得組件內(nèi)部與主頁面的DOM保持了分離。圖片中的#shadow-root是根節(jié)點,成為影子根,和主文檔分開渲染。#shadow-root可以嵌套形成節(jié)點樹(Shadow Tree)
<custom-wrapper is="custom-wrapper">
#shadow-root
<view class="list"></view>
</custom-wrapper>
包裹的組件被隔離,這樣內(nèi)部的數(shù)據(jù)的更新不會影響到整個頁面,可以簡單看下低性能客戶端下的表現(xiàn)。效果還是明顯的,同一時間點擊,右側(cè)彈窗出現(xiàn)的耗時平均會快200ms ~ 300ms (同一機型同一環(huán)境下測出),機型越低端越明顯。
(右側(cè)是CustomWrapper下的)
3) 使用小程序原生組件
用小程序的原生組件去實現(xiàn)這個列表Item。原生組件繞過Taro3的運行時,也就是說,在用戶對頁面操作的時候,如果是taro3的組件,需要進行前后數(shù)據(jù)的diff計算,然后生產(chǎn)新的虛擬dom所需要的節(jié)點數(shù)據(jù),進而調(diào)用小程序的api去對節(jié)點進行操作。原生組件繞過了這一些列的操作,直接是是底層小程序?qū)?shù)據(jù)的更新。所以,縮短了一些時間??梢钥匆幌聦崿F(xiàn)后的效果:
指標(biāo) | setData次數(shù)(舊) | setData次數(shù)(新) |
下拉列表更新 | 3 | 1 |
setData耗時(舊) | setData耗時(新) | 減少耗時百分比 |
1903 | 836 | 56.07% |
可以看出原生性能提升很大,平均更新列表縮短1s左右,但是使用原生也有缺點,主要表現(xiàn)為以下兩個方面:
- 組件包含的所有樣式 需要按照小程序的規(guī)范寫一遍,且與taro的樣式相互隔離;
- 在原生組件中無法使用taro的API,比如createSelectorQuery這種;
對比三種方案,性能提升逐步加強。考慮到使用Taro原本的意義在于跨端,如果使用原生,就沒辦法達到這個目的,不過我們在嘗試是否可以通過插件,在編譯時生成對應(yīng)原生小程序的組件代碼,以此解決這一問題,最終達到最優(yōu)效果。
3.6 React.memo
當(dāng)復(fù)雜頁面子組件過多時,父組件的渲染會導(dǎo)致子組件跟著渲染,React.memo可以做淺層的比較防止不必要的渲染:
const MyComponent = React.memo(function MyComponent(props) {
/* 使用 props 渲染 */
})
React.memo為高階組件。它與React.PureComponent非常相似,但它適用于函數(shù)組件,但不適用于 class 組件。
如果你的函數(shù)組件在給定相同props的情況下渲染相同的結(jié)果,那么你可以通過將其包裝在React.memo中調(diào)用,以此通過記憶組件渲染結(jié)果的方式來提高組件的性能表現(xiàn)。這意味著在這種情況下,React 將跳過渲染組件的操作并直接復(fù)用最近一次渲染的結(jié)果。
默認(rèn)情況下其只會對復(fù)雜對象做淺層對比,如果你想要控制對比過程,那么請將自定義的比較函數(shù)通過第二個參數(shù)傳入來實現(xiàn)。
function MyComponent(props) {
/* 使用 props 渲染 */
}
function areEqual(prevProps, nextProps) {
/*
如果把 nextProps 傳入 render 方法的返回結(jié)果與
將 prevProps 傳入 render 方法的返回結(jié)果一致則返回 true,
否則返回 false
*/
}
export default React.memo(MyComponent, areEqual);
四、總結(jié)
本次復(fù)雜列表的性能優(yōu)化我們前后經(jīng)歷較久,嘗試了各種可能的優(yōu)化點。從列表頁的預(yù)加載,篩選項數(shù)據(jù)結(jié)構(gòu)和動畫實現(xiàn)的改變,到長列表的體驗優(yōu)化和原生的結(jié)合,提升了頁面的更新和渲染效率,目前仍密切關(guān)注,繼續(xù)保持探索。
以下為最終效果對比(右側(cè)為優(yōu)化后):






