Taro性能優化之複雜列表篇

語言: CN / TW / HK

作者 | Kenny,攜程高級前端開發工程師。2021年加入攜程,從事小程序/H5相關研發工作。

一、背景

隨着項目的不斷迭代,規模日益增大,而基於Taro3的運行時弊端也日漸凸顯,尤其在複雜列表頁面上表現欠佳,極度影響用户體驗。本文將以複雜列表的性能優化為主旨,嘗試建立檢測指標,瞭解性能瓶頸,通過預加載、緩存、優化組件層級、優化數據結構等多種方式,實驗後提供一些技術方案的建議,希望可以給大家帶來一些思路。

二、問題現狀及分析

我們以酒店某一多功能列表為例(下圖),設定檢測標準(setData次數及該setData的響應時效作為指標),檢測情況如下:

指標

setData次數

渲染耗時(ms)

第一次進入列表頁

7

2404

下拉長列表更新

3

1903

多屏列表下 篩選項更新

2

1758

多屏列表下 列表項更新

2

748

由於歷史原因,該頁面的代碼,由微信的原生轉成的taro1,後續迭代至taro3。項目中存在小程序原生寫法可能忽略的問題。根據上面多次測出的指標值,以及視覺體驗上來看,存在以下問題:

2.1  首次進入列表頁的加載時間過長,白屏時間久

  • 列表頁請求的接口時間過長;
  • 初始化列表也是setData數據量過大,且次數過多;
  • 頁面節點數過多,導致渲染耗時較長;

2.2  頁面篩選項的更新卡頓,下拉動畫卡頓

  • 篩選項中節點過多,更新時setData數據量大;
  • 篩選項的組件更新會導致頁面跟着一起更新;

2.3  無限列表的更新卡頓,滑動過快會白屏

  • 請求下一頁的時機過晚;
  • setData時數據量大,響應慢;
  • 滑動過快時,沒有從白屏到渲染完成的過渡機制,體驗欠佳;

三、嘗試優化的方案

3.1  跳轉預加載API:

通過觀察小程序的請求可以發現,列表頁請求中,有兩個請求耗時較為長。

在Taro3的升級中,官方有提到預加載Preload,在小程序中,從調用 Taro.navigateTo 等路由跳轉 API 後,到小程序頁面觸發 onLoad 會有一定延時(約300ms,如果是分包新下載則跳轉時間更長),因此一些網絡請求可以提前到發起跳轉時一起去請求。於是我們在在跳轉前,使用Taro.preload預先加載複雜列表的請求:

// Page A
  const query = new Query({
    // ...
  })


  Taro.preload({
    RequestPromise: requestPromiseA({data: query }),
  })
// Page B
  componentDidMount() {
    // 在跳轉的過程中,發出請求,因為返回的是一個promise,所以需要在B頁面承接:
    Taro.getCurrentInstance().preloadData?.RequestPromise?.then(res => {
      this.setState(this.processResData(res.data))
    })
  }

用同樣的檢測方式反覆測試後,使用preload的時,能提前300~400ms提前拿到酒店的列表數據。

左邊是沒使用preload的舊列表,右邊是預加載的列表,能明顯看出預加載後的列表會快一些。

然而在實際的使用中我們發現preload存在部分缺陷,對於承接頁面,如果接口較為複雜,會對業務流程的代碼有一定的入侵。究其本質,是前置了網絡請求,所以我們可以對網絡請求部分加入緩存策略,即可達到該效果,且接入成本會大大降低。

3.2  合理運用setData

setData 是小程序開發中使用最頻繁、也是最容易引發性能問題的API。setData 的過程,大致可以分成幾個階段:

  • 邏輯層虛擬 DOM 樹的遍歷和更新,觸發組件生命週期和 observer 等;
  • 將 data 從邏輯層傳輸到視圖層;
  • 視圖層虛擬 DOM 樹的更新、真實 DOM 元素的更新並觸發頁面渲染更新。

數據傳輸的耗時與數據量的大小正相關,舊的列表頁第一次加載的時候,一共請求了4個接口,setData短時間裏有6次,數據量偏大的有兩次,我們嘗試的優化方式為,將數據量大的兩次分開,另外五次發現都是一些零散的狀態和數據,可以作為一次。

指標

setData次數

setData耗時(ms)

減少耗時百分比

第一次進入列表頁

3

2182

9.23%

進行完這一步的操作,平均能減少200ms左右,效果較小,因為頁面的節點數沒變,setData主要的耗時還分佈於渲染時間。

3.3  優化頁面的節點數

根據微信官方文檔的説明,一個太大的節點樹會增加內存使用的同時,樣式重排時間上也會更長。建議一個頁面節點數量應少於 1000 個,節點樹深度少於 30 層,子節點數不大於 60 個。

在微信開發者工具中分析該頁面兩個模塊存在大量的節點數。一個是篩選項模塊,一個是長列表的模塊。因為這部分功能較多,且結構複雜,我們採用了選擇性渲染。如在用户瀏覽列表式,篩選項不生成具體節點。點擊展開篩選的時候再渲染出節點,對於頁面列表的體驗有一定程度的緩解。另一方面,對於整體佈局的書寫上,有意識的避免嵌套過深的寫法,如RichText使用,部分選擇圖片代替等。

3.4  優化篩選項相關

3.4.1  改變動畫方式

在重構篩選項的過程中,發現在一些機型上,小程序的動畫效果不太理想,比如當打開篩選項tab的時候,需要實現一個向下拉出的效果,早期在實現的時候,會出現兩個問題:

  • 動畫會閃一下 然後再出現
  • 篩選頁面節點過多時,點擊響應過慢,用户體驗差

舊的篩選項的動畫是通過keyframes方式實現了一個fadeIn的動畫,加在最外層,但是無論如何在動畫出現的那一幀,都會閃一下。分析下來,因為keyframes執行動畫造成的卡頓:

.filter-wrap {
  animation: .3s ease-in fadeIn;
}


@keyframes fadeIn {
  0% {
    transform: translateY(-100%)
  }
  100% {
    transform: translateY(0)
  }
}

於是,嘗試換了一種實現方式,通過transition來實現transfrom:

.filter-wrap {
     transform: translateY(-100%); 
     transition: none;
     &.active { 
       transform: translateY(0); 
       transition: transform .3s ease-in; 
    }
}

3.4.2  維護簡潔的state

操作篩選項的時候,每操作一次都需要根據唯一id從篩選項的數據結構中循環遍歷,去找到對應的item,改掉item的狀態,然後將整個結構重新setState。官方文檔中提到關於setState,應該儘量避免處理過大的數據,會影響頁面的更新性能。

針對這一問題,採取的辦法是:

  • 預先將複雜的對象扁平化,示例如下:
{
    "a": {
      "subs": [{
        "a1": {
          "subs": [{
            "id": 1
          }]
        }
      }]
    },
    "b": {
      "subs": [{
        "id": 2
      }]
    },


    // ...
  }

扁平化後的篩選項數據結構:

{
    "1": {
      "id": 1,
      "name": "漢庭",
      "includes": [],
      "excludes": [],
      // ...
    },
    "2": {
      // ...
    },


    // ...
  }
  • 不改變原有的數據,利用扁平化後的數據結構維護一個動態的選中列表:
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]
      })
    }


    // 其他邏輯 (快篩,關鍵詞等)
  }


  this.setState({filtersSelected})
}

上面是一個簡單的實現,前後對比,我們只需要維護一個很簡單的對象,對其屬性進行添加或者刪除,性能有細微的提高,且代碼更為簡單整潔。在業務代碼中,類似這種通過數據結構轉換提升效率的地方有很多。

關於篩選項,可以對比下檢測的平均數據,減少200ms~300ms,也會得到一些提升:

指標

setData耗時舊

setData耗時新

減少耗時百分比

長列表下篩選項展開

1023

967

5.47%

長列表下點擊篩選項

1758

1443

17.92%

3.5  長列表的優化

早期酒店列表頁引入了虛擬列表,針對長列表渲染一定數目的酒店。核心的思路是隻渲染顯示在屏幕的數據,基本實現就是監聽 scroll 事件,並且重新計算需要渲染的數據,不需要渲染的數據留一個空的 div 佔位元素。

  • 加載下一頁有輕微的卡頓:

通過數據發現,下拉更新列表平均耗時1900ms左右:

指標

setData次數

setData耗時

下拉列表更新

3

1903

針對這個問題,解決方案是,提前加載下一頁的數據,將下一頁存入內存變量中。滾動加載的時候直接從內存變量中去取,然後setData更新到數據中。

  • 滑動速度過快會出現白屏(速度越快白屏時間越久,下方左圖):虛擬列表的原理就是利用空的View去佔位,當快速回滾的時候,渲染的時候當節點過於複雜,特別是酒店帶有圖片,渲染就會變慢,導致白屏,我們進行了三種方案的嘗試:1)  使用動態的骨架圖代替原有的View佔位 下方圖右:

2)  CustomWrapper

為了提升性能,官方推薦了CusomWrapper,它可以將包裹的組件與頁面隔離,組件渲染時不會更新整個頁面,由page.setData變為component.setData。

自定義組件是基於Shadow DOM實現的,對組件中的DOM和CSS進行了封裝,使得組件內部與主頁面的DOM保持了分離。圖片中的#shadow-root是根節點,成為影子根,和主文檔分開渲染。#shadow-root可以嵌套形成節點樹(Shadow Tree)

<custom-wrapper is="custom-wrapper">
    #shadow-root
      <view class="list"></view>
  </custom-wrapper>

包裹的組件被隔離,這樣內部的數據的更新不會影響到整個頁面,可以簡單看下低性能客户端下的表現。效果還是明顯的,同一時間點擊,右側彈窗出現的耗時平均會快200ms ~ 300ms (同一機型同一環境下測出),機型越低端越明顯。

(右側是CustomWrapper下的)

3)  使用小程序原生組件

用小程序的原生組件去實現這個列表Item。原生組件繞過Taro3的運行時,也就是説,在用户對頁面操作的時候,如果是taro3的組件,需要進行前後數據的diff計算,然後生產新的虛擬dom所需要的節點數據,進而調用小程序的api去對節點進行操作。原生組件繞過了這一些列的操作,直接是是底層小程序對數據的更新。所以,縮短了一些時間。可以看一下實現後的效果:

指標

setData次數(舊)

setData次數(新)

下拉列表更新

3

1

setData耗時(舊)

setData耗時(新)

減少耗時百分比

1903

836

56.07%

可以看出原生性能提升很大,平均更新列表縮短1s左右,但是使用原生也有缺點,主要表現為以下兩個方面:

  • 組件包含的所有樣式 需要按照小程序的規範寫一遍,且與taro的樣式相互隔離;
  • 在原生組件中無法使用taro的API,比如createSelectorQuery這種;

對比三種方案,性能提升逐步加強。考慮到使用Taro原本的意義在於跨端,如果使用原生,就沒辦法達到這個目的,不過我們在嘗試是否可以通過插件,在編譯時生成對應原生小程序的組件代碼,以此解決這一問題,最終達到最優效果。

3.6  React.memo

當複雜頁面子組件過多時,父組件的渲染會導致子組件跟着渲染,React.memo可以做淺層的比較防止不必要的渲染:

const MyComponent = React.memo(function MyComponent(props) {
  /* 使用 props 渲染 */
})

React.memo為高階組件。它與React.PureComponent非常相似,但它適用於函數組件,但不適用於 class 組件。

如果你的函數組件在給定相同props的情況下渲染相同的結果,那麼你可以通過將其包裝在React.memo中調用,以此通過記憶組件渲染結果的方式來提高組件的性能表現。這意味着在這種情況下,React 將跳過渲染組件的操作並直接複用最近一次渲染的結果。

默認情況下其只會對複雜對象做淺層對比,如果你想要控制對比過程,那麼請將自定義的比較函數通過第二個參數傳入來實現。

function MyComponent(props) {
  /* 使用 props 渲染 */
}


function areEqual(prevProps, nextProps) {
  /*
  如果把 nextProps 傳入 render 方法的返回結果與
  將 prevProps 傳入 render 方法的返回結果一致則返回 true,
  否則返回 false
  */
}


export default React.memo(MyComponent, areEqual);

四、總結

本次複雜列表的性能優化我們前後經歷較久,嘗試了各種可能的優化點。從列表頁的預加載,篩選項數據結構和動畫實現的改變,到長列表的體驗優化和原生的結合,提升了頁面的更新和渲染效率,目前仍密切關注,繼續保持探索。

以下為最終效果對比(右側為優化後):