京喜小程序的高性能打造之路

本文閱讀時長約15分鐘。京喜小程序開發團隊核心成員傾力之做,都是乾貨,讀完必定會收穫滿滿,請你們耐心閱讀~javascript

背景

京喜小程序自去年雙十一上線微信購物一級入口後,時刻迎接着億級用戶量的挑戰,細微的體驗細節都有可能被無限放大,爲此,「極致的頁面性能」、「友好的產品體驗」 和 「穩定的系統服務」 成爲了咱們開發團隊的最基本執行原則。css

首頁做爲小程序的門戶,其性能表現和用戶留存率息息相關。所以,咱們對京喜首頁進行了一次全方位的升級改造,從加載、渲染和感知體驗幾大維度深挖小程序的性能可塑性。html

除此以外,京喜首頁在微信小程序、H五、APP 三端都有落地場景,爲了提升研發效率,咱們使用了 Taro 框架實現多端統一,所以下文中有部份內容是和 Taro 框架息息相關的。前端

怎麼定義高性能?

提起互聯網應用性能這個詞,不少人在腦海中的詞法解析就是,「是否足夠快?」,彷佛加載速度成爲衡量系統性能的惟一指標。但這實際上是不夠準確的,試想一下,若是一個小程序加載速度很是快,用戶花費很短期就能看到頁面的主體內容,但此時搜索框卻沒法輸入內容,功能沒法被流暢使用,用戶可能就不會關心頁面渲染有多快了。因此,咱們不該該單純考慮速度指標而忽略用戶的感知體驗,而應該全方位衡量用戶在使用過程當中能感知到的與應用加載相關的每一個節點。java

谷歌爲 Web 應用定義了以用戶爲中心的性能指標體系,每一個指標都與用戶體驗節點息息相關:git

體驗 指標
頁面可否正常訪問? 首次內容繪製 (First Contentful Paint, FCP)
頁面內容是否有用? 首次有效繪製 (First Meaningful Paint, FMP)
頁面功能是否可用? 可交互時間 (Time to Interactive, TTI)

其中,「是否有用?」 這個問題是很是主觀的,對於不一樣場景的系統可能會有徹底不同的回答,因此 FMP 是一個比較模糊的概念指標,不存在規範化的數值衡量。github

小程序做爲一個新的內容載體,衡量指標跟 Web 應用是很是相似的。對於大多數小程序而言,上述指標對應的含義爲:web

  • FCP:白屏加載結束;
  • FMP:首屏渲染完成;
  • TTI:全部內容加載完成;

綜上,咱們已基本肯定了高性能的概念指標,接下來就是如何利用數值指標來描繪性能表現。算法

小程序官方性能指標

小程序官方針對小程序性能表現制訂了權威的數值指標,主要圍繞 渲染表現setData 數據量元素節點數網絡請求延時 這幾個維度來給予定義(下面只列出部分關鍵指標):npm

  • 首屏時間不超過 5 秒;
  • 渲染時間不超過 500ms;
  • 每秒調用 setData 的次數不超過 20 次;
  • setData 的數據在 JSON.stringify 後不超過 256kb;
  • 頁面 WXML 節點少於 1000 個,節點樹深度少於 30 層,子節點數不大於 60 個;
  • 全部網絡請求都在 1 秒內返回結果;

詳見 小程序性能評分規則

咱們應該把這一系列的官方指標做爲小程序的性能及格線,不斷地打磨和提高小程序的總體體驗,下降用戶流失率。另外,這些指標會直接做爲小程序體驗評分工具的性能評分規則(體驗評分工具會根據這些規則的權重和求和公式計算出體驗得分)。

咱們團隊內部在官方性能指標的基礎上,進一步濃縮優化指標係數,旨在對產品體驗更高要求:

  • 首屏時間不超過 2.5 秒;
  • setData 的數據量不超過 100kb;
  • 全部網絡請求都在 1 秒內返回結果;
  • 組件滑動、長列表滾動無卡頓感;

體驗評分工具

小程序提供了 體驗評分工具(Audits 面板) 來測量上述的指標數據,其集成在開發者工具中,在小程序運行時實時檢查相關問題點,併爲開發者給出優化建議。

體驗評分面板

以上截圖均來自小程序官方文檔

體驗評分工具是目前檢測小程序性能問題最直接有效的途徑,咱們團隊已經把體驗評分做爲頁面/組件是否能達到精品門檻的重要考量手段之一。

小程序後臺性能分析

咱們知道,體驗評分工具是在本地運行小程序代碼時進行分析,但性能數據每每須要在真實環境和大數據量下才更有說服力。恰巧,小程序管理平臺小程序助手 爲開發者提供了大量的真實數據統計。其中,性能分析面板從 啓動性能運行性能網絡性能 這三個維度分析數據,開發者能夠根據客戶端系統、機型、網絡環境和訪問來源等條件作精細化分析,很是具備考量價值。

小程序助手性能分析

其中,啓動總耗時 = 小程序環境初始化 + 代碼包加載 + 代碼執行 + 渲染耗時

第三方測速系統

不少時候,宏觀的耗時統計對於性能瓶頸點分析每每是杯水車薪,做用甚少,咱們須要更細緻地針對某個頁面某些關鍵節點做測速統計,排查出暴露性能問題的代碼區塊,才能更有效地針對性優化。京喜小程序使用的是內部自研的測速系統,支持對地區、運營商、網絡、客戶端系統等多條件篩選,同時也支持數據可視化、同比分析數據等能力。京喜首頁主要圍繞 頁面 onLoadonReady數據加載完成首屏渲染完成各業務組件首次渲染完成 等幾個關鍵節點統計測速上報,旨在全鏈路監控性能表現。

內部測速系統

另外,微信爲開發者提供了 測速系統,也支持針對客戶端系統、網絡類型、用戶地區等維度統計數據,有興趣的能夠嘗試。

瞭解小程序底層架構

爲了更好地爲小程序制訂性能優化措施,咱們有必要先了解小程序的底層架構,以及與 web 瀏覽器的差別性。

微信小程序是大前端跨平臺技術的其中一種產物,與當下其餘熱門的技術 React Native、Weex、Flutter 等不一樣,小程序的最終渲染載體依然是瀏覽器內核,而不是原生客戶端。

而對於傳統的網頁來講,UI 渲染和 JS 腳本是在同一個線程中執行,因此常常會出現 「阻塞」 行爲。微信小程序基於性能的考慮,啓用了雙線程模型

  • 視圖層:也就是 webview 線程,負責啓用不一樣的 webview 來渲染不一樣的小程序頁面;
  • 邏輯層:一個單獨的線程執行 JS 代碼,能夠控制視圖層的邏輯;

雙線程模型圖

上圖來自小程序官方開發指南

然而,任何線程間的數據傳輸都是有延時的,這意味着邏輯層和視圖層間通訊是異步行爲。除此以外,微信爲小程序提供了不少客戶端原生能力,在調用客戶端原生能力的過程當中,微信主線程和小程序雙線程之間也會發生通訊,這也是一種異步行爲。這種異步延時的特性會使運行環境複雜化,稍不注意,就會產出效率低下的編碼。

做爲小程序開發者,咱們經常會被下面幾個問題所困擾:

  • 小程序啓動慢;
  • 白屏時間長;
  • 頁面渲染慢;
  • 運行內存不足;

接下來,咱們會結合小程序的底層架構分析出這些問題的根本緣由,並針對性地給出解決方案。

小程序啓動太慢?

小程序啓動階段,也就是以下圖所示的展現加載界面的階段。

小程序加載界面

在這個階段中(包括啓動先後的時機),微信會默默完成下面幾項工做:

1. 準備運行環境:

在小程序啓動前,微信會先啓動雙線程環境,並在線程中完成小程序基礎庫的初始化和預執行。

小程序基礎庫包括 WebView 基礎庫和 AppService 基礎庫,前者注入到視圖層中,後者注入到邏輯層中,分別爲所在層級提供其運行所需的基礎框架能力。

2. 下載小程序代碼包:

在小程序初次啓動時,須要下載編譯後的代碼包到本地。若是啓動了小程序分包,則只有主包的內容會被下載。另外,代碼包會保留在緩存中,後續啓動會優先讀取緩存。

3. 加載小程序代碼包:

小程序代碼包下載好以後,會被加載到適當的線程中執行,基礎庫會完成全部頁面的註冊。

在此階段,主包內的全部頁面 JS 文件及其依賴文件都會被自動執行。

在頁面註冊過程當中,基礎庫會調用頁面 JS 文件的 Page 構造器方法,來記錄頁面的基礎信息(包括初始數據、方法等)。

4. 初始化小程序首頁:

在小程序代碼包加載完以後,基礎庫會根據啓動路徑找到首頁,根據首頁的基礎信息初始化一個頁面實例,並把信息傳遞給視圖層,視圖層會結合 WXML 結構、WXSS 樣式和初始數據來渲染界面。

綜合考慮,爲了節省小程序的「點點點」時間(小程序的啓動動畫是三個圓點循環跑馬燈),除了給每位用戶發一臺高配 5G 手機並順帶提供千兆寬帶網絡以外,還能夠儘可能 控制代碼包大小,縮小代碼包的下載時間。

無用文件、函數、樣式剔除

通過屢次業務迭代,無可避免的會存在一些棄用的組件/頁面,以及不被調用的函數、樣式規則,這些冗餘代碼會白白佔據寶貴的代碼包空間。並且,目前小程序的打包會將工程下全部文件都打入代碼包內,並無作依賴分析。

所以,咱們須要及時地剔除再也不使用的模塊,以保證代碼包空間利用率保持在較高水平。經過一些工具化手段能夠有效地輔助完成這一工做。

  • 文件依賴分析

在小程序中,全部頁面的路徑都須要在小程序代碼根目錄 app.json 中被聲明,相似地,自定義組件也須要在頁面配置文件 page.json 中被聲明。另外,WXML、WXSS 和 JS 的模塊化都須要特定的關鍵字來聲明依賴引用關係。

WXML 中的 importinclude

<!-- A.wxml -->
<template name='A'> <text>{{text}}</text> </template>

<!-- B.wxml -->
<import src="A.wxml"/>
<template is="A" data="{{text: 'B'}}"/>
複製代碼
<!-- A.wxml -->
<text> A </text>

<!-- B.wxml -->
<include src="A.wxml"/> <text> B </text> 複製代碼

WXSS 中的 @import

@import './A.wxss'
複製代碼

JS 中的 require/import

const A = require('./A')
複製代碼

因此,能夠說小程序裏的全部依賴模塊都是有跡可循的,咱們只須要利用這些關鍵字信息遞歸查找,遍歷出文件依賴樹,而後把沒用的模塊剔除掉。

  • JS、CSS Tree-Shaking

JS Tree-Shaking 的原理就是藉助 Babel 把代碼編譯成抽象語法樹(AST),經過 AST 獲取到函數的調用關係,從而把未被調用的函數方法剔除掉。不過這須要依賴 ES module,而小程序最開始是遵循 CommonJS 規範的,這意味着是時候來一波「痛並快樂着」的改造了。

而 CSS 的 Tree-Shaking 能夠利用 PurifyCSS 插件來完成。關於這兩項技術,有興趣的能夠「谷歌一下」,這裏就不鋪開細講了。

題外,京東的小程序團隊已經把這一系列工程化能力集成在一套 CLI 工具中,有興趣的能夠看看這篇分享:小程序工程化探索

減小代碼包中的靜態資源文件

小程序代碼包最終會通過 GZIP 壓縮放在 CDN 上,但 GZIP 壓縮對於圖片資源來講效果很是低。如 JPGPNG 等格式文件,自己已經被壓縮過了,再使用 GZIP 壓縮有可能體積更大,得不償失。因此,除了部分用於容錯的圖片必須放在代碼包(譬如網絡異常提示)以外,建議開發者把圖片、視頻等靜態資源都放在 CDN 上。

須要注意,Base64 格式本質上是長字符串,和 CDN 地址比起來也會更佔空間。

邏輯後移,精簡業務邏輯

這是一個 「痛並快樂着」 的優化措施。「痛」 是由於須要給後臺同窗提改造需求,分分鐘被打;「快樂」 則是由於享受刪代碼的過程,並且萬一出 Bug 也不用背鍋了...(開個玩笑)

經過讓後臺承擔更多的業務邏輯,能夠節省小程序前端代碼量,同時線上問題還支持緊急修復,不須要經歷小程序的提審、發佈上線等繁瑣過程。

總結得出,通常不涉及前端計算的展現類邏輯,均可以適當作後移。譬如京喜首頁中的幕簾彈窗(以下圖)邏輯,這裏共有 10+ 種彈窗類型,之前的作法是前端從接口拉取 10+ 個不一樣字段,根據優先級和 「是否已展現」(該狀態存儲在本地緩存) 來決定展現哪種,最後代碼大概是這樣的:

// 檢查每種彈窗類型是否已展現
Promise.all([
  check(popup_1),
  check(popup_2),
  // ...
  check(popup_n)
]).then(result => {
  // 優先級排序
  const queue = [{
    show: result.popup_1
    data: data.popup_1
  }, {
    show: result.popup_2
    data: data.popup_2
  }, 
  // ...
  {
    show: result.popup_n
    data: data.popup_n
  }]
})
複製代碼

邏輯後移以後,前端只需負責拿幕簾字段作展現就能夠了,代碼變成這樣:

this.setData({
  popup: data.popup
})
複製代碼

首頁幕簾彈窗

複用模板插件

京喜首頁做爲電商系統的門戶,須要應對各種頻繁的營銷活動、升級改版等,同時也要知足不一樣用戶屬性的界面個性化需求(俗稱 「千人千面」)。如何既能減小爲應對多樣化場景而產生的代碼量,又能夠提高研發效率,成爲燃眉之急。

相似於組件複用的理念,咱們須要提供更豐富的可配置能力,實現更高的代碼複用度。參考小時候很喜歡玩的 「樂高」 積木玩具,咱們把首頁模塊的模板元素做顆粒度更細的劃分,根據樣式和功能抽象出一塊塊「積木」原料(稱爲插件元素)。當首頁模塊在處理接口數據時,會啓動插件引擎逐個裝載插件,最終輸出個性化的模板樣式,整個流程就比如堆積木。當後續產品/運營須要新增模板時,只要在插件庫中挑選插件排列組合便可,不須要額外新增/修改組件內容,也更不會產生難以維護的 if / else 邏輯,so easy~

固然,要完成這樣的插件化改造免不了幾個先決條件:

  • 用戶體驗設計的統一。若是設計風格老是天差地別的,強行插件化只會成爲累贅。
  • 服務端接口的統一。同上,若是得浪費大量的精力來兼容不一樣模塊間的接口字段差別,將會很是蛋疼。

下面爲你們提供部分例程來輔助理解。其中,use 方法會接受各種處理鉤子最終拼接出一個 Function,在對應模塊處理數據時會被調用。

// bi.helper.js

/** * 插件引擎 * @param {function} options.formatName 標題處理鉤子 * @param {function} options.validList 數據校驗器鉤子 */ 
const use = options => data => format(data)

/** * 預置插件庫 */ 
nameHelpers = {
  text: data => data.text,
  icon: data => data.icon
}
listHelpers = {
  single: list => list.slice(0, 1),
  double: list => list.slice(0, 2)
}

/** * 「堆積木」 */
export default {
  1000: use({
    formatName: nameHelpers.text,
    validList: listHelpers.single
  }),

  1001: use({
    formatName: nameHelpers.icon,
    validList: listHelpers.double
  })
}
複製代碼
<!-- bi.wxml -->
<!-- 各模板節點實現 -->
<template name="renderName">
  <view wx:if="{{type === 'text'}}"> text </view>
  <view wx:elif="{{type === 'icon'}}"> icon </view>
</template>

<view class="bi__name">
  <template is="renderName" data="{{...data.name}"/>
</view>
複製代碼
// bi.js
Component({
  ready() {
    // 根據 tpl 值選擇解析函數
    const formatData = helper[data.tpl]
    this.setData({
      data: formatData(data)
    })
  }
})
複製代碼

分包加載

小程序啓動時只會下載主包/獨立分包,啓用分包能夠有效減小下載時間。(獨立)分包須要遵循一些原則,詳細的能夠看官方文檔:

部分頁面 h5 化

小程序提供了 web-view 組件,支持在小程序環境內訪問網頁。當實在沒法在小程序代碼包中騰出多餘空間時,能夠考慮降級方案 —— 把部分頁面 h5 化。

小程序和 h5 的通訊能夠經過 JSSDK 或 postMessage 通道來實現,詳見 小程序開發文檔

白屏時間過長?

白屏階段,是指小程序代碼包下載完(也就是啓動界面結束)以後,頁面完成首屏渲染的這一階段,也就是 FMP (首次有效繪製)。

FMP 無法用標準化的指標定義,但對於大部分小程序來講,頁面首屏展現的內容都須要依賴服務端的接口數據,那麼影響白屏加載時間的主要由這兩個元素構成:

  • 網絡資源加載時間
  • 渲染時間

啓用本地緩存

小程序提供了讀寫本地緩存的接口,數據存儲在設備硬盤上。因爲本地 I/O 讀寫(毫秒級)會比網絡請求(秒級)要快不少,因此在用戶訪問頁面時,能夠優先從緩存中取上一次接口調用成功的數據來渲染視圖,待網絡請求成功後再覆蓋最新數據從新渲染。除此以外,緩存數據還能夠做爲兜底數據,避免出現接口請求失敗時頁面空窗,一石二鳥。

但並不是全部場景都適合緩存策略,譬如對數據即時性要求很是高的場景(如搶購入口)來講,展現老數據可能會引起一些問題。

小程序默認會按照 不一樣小程序不一樣微信用戶 這兩個維度對緩存空間進行隔離。諸如京喜小程序首頁也採用了緩存策略,會進一步按照 數據版本號用戶屬性 來對緩存進行再隔離,避免信息誤展現。

數據預拉取

小程序官方爲開發者提供了一個在小程序冷啓動時提早拉取第三方接口的能力:數據預拉取

關於冷啓動和熱啓動的定義能夠看 這裏

數據預拉取的原理其實很簡單,就是在小程序啓動時,微信服務器代理小程序客戶端發起一個 HTTP 請求到第三方服務器來獲取數據,而且把響應數據存儲在本地客戶端供小程序前端調取。當小程序加載完成後,只需調用微信提供的 API wx.getBackgroundFetchData 從本地緩存獲取數據便可。這種作法能夠充分利用小程序啓動和初始化階段的等待時間,使更快地完成頁面渲染。

京喜小程序首頁已經在生產環境實踐過這個能力,從每日千萬級的數據分析得出,預拉取使冷啓動時獲取到接口數據的時間節點從 2.5s 加速到 1s(提速了 60%)。雖然提高效果很是明顯,但這個能力依然存在一些不成熟的地方:

  • 預拉取的數據會被強緩存

    因爲預拉取的請求最終是由微信的服務器發起的,也許是出於服務器資源限制的考慮,預拉取的數據會緩存在微信本地一段時間,緩存失效後纔會從新發起請求。通過真機實測,在微信購物入口冷啓動京喜小程序的場景下,預拉取緩存存活了 30 分鐘以上,這對於數據實時性要求比較高的系統來講是很是致命的。

  • 請求體和響應體都沒法被攔截

    因爲請求第三方服務器是從微信的服務器發起的,而不是從小程序客戶端發起的,因此本地代理沒法攔截到這一次真實請求,這會致使開發者沒法經過攔截請求的方式來區分獲取線上環境和開發環境的數據,給開發調試帶來麻煩。

    小程序內部接口的響應體類型都是 application/octet-stream,即數據格式未知,使本地代理沒法正確解析。

  • 微信服務器發起的請求沒有提供區分線上版和開發版的參數,且沒有提供用戶 IP 等信息

若是這幾個問題點都不會影響到你的場景,那麼能夠嘗試開啓預拉取能力,這對於小程序首屏渲染速度是質的提高。

跳轉時預拉取

爲了儘快獲取到服務端數據,比較常見的作法是在頁面 onLoad 鉤子被觸發時發起網絡請求,但其實這並非最快的方式。從發起頁面跳轉,到下一個頁面 onLoad 的過程當中,小程序須要完成一些環境初始化及頁面實例化的工做,耗時大概爲 300 ~ 400 毫秒。

實際上,咱們能夠在發起跳轉前(如 wx.navigateTo 調用前),提早請求下一個頁面的主接口並存儲在全局 Promise 對象中,待下個頁面加載完成後從 Promise 對象中讀取數據便可。

這也是雙線程模型所帶來的優點之一,不一樣於多頁面 web 應用在頁面跳轉/刷新時就銷燬掉 window 對象。

分包預下載

若是開啓了分包加載能力,在用戶訪問到分包內某個頁面時,小程序纔會開始下載對應的分包。當處於分包下載階段時,頁面會維持在 「白屏」 的啓動態,這用戶體驗是比較糟糕的。

幸虧,小程序提供了 分包預下載 能力,開發者能夠配置進入某個頁面時預下載可能會用到的分包,避免在頁面切換時僵持在 「白屏」 態。

非關鍵渲染數據延遲請求

這是關鍵渲染路徑優化的其中一個思路,從縮短網絡請求時延的角度加快首屏渲染完成時間。

關鍵渲染路徑(Critical Rendering Path) 是指在完成首屏渲染的過程當中必須發生的事件。

以京喜小程序如此龐大的小程序項目爲例,每一個模塊背後均可能有着海量的後臺服務做支撐,而這些後臺服務間的通訊和數據交互都會存在必定的時延。咱們根據京喜首頁的頁面結構,把全部模塊劃分紅兩類:主體模塊(導航、商品輪播、商品豆腐塊等)和 非主體模塊(幕簾彈窗、右側掛件等)。

在初始化首頁時,小程序會發起一個聚合接口請求來獲取主體模塊的數據,而非主體模塊的數據則從另外一個接口獲取,經過拆分的手段來下降主接口的調用時延,同時減小響應體的數據量,縮減網絡傳輸時間。

京喜首頁浮層模塊

分屏渲染

這也是關鍵渲染路徑優化思路之一,經過延遲非關鍵元素的渲染時機,爲關鍵渲染路徑騰出資源。

相似上一條措施,繼續以京喜小程序首頁爲例,咱們在 主體模塊 的基礎上再度劃分出 首屏模塊(商品豆腐塊以上部分) 和 非首屏模塊(商品豆腐塊及如下部分)。當小程序獲取到主體模塊的數據後,會優先渲染首屏模塊,在全部首屏模塊都渲染完成後纔會渲染非首屏模塊和非主體模塊,以此確保首屏內容以最快速度呈現。

京喜首頁分屏渲染

爲了更好地呈現效果,上面 gif 作了降速處理

接口聚合,請求合併

在小程序中,發起網絡請求是經過 wx.request 這個 API。咱們知道,在 web 瀏覽器中,針對同一域名的 HTTP 併發請求數是有限制的;在小程序中也有相似的限制,但區別在於不是針對域名限制,而是針對 API 調用:

  • wx.request (HTTP 鏈接)的最大併發限制是 10 個;
  • wx.connectSocket (WebSocket 鏈接)的最大併發限制是 5 個;

超出併發限制數目的 HTTP 請求將會被阻塞,須要在隊列中等待前面的請求完成,從而必定程度上增長了請求時延。所以,對於職責相似的網絡請求,最好採用節流的方式,先在必定時間間隔內收集數據,再合併到一個請求體中發送給服務端。

圖片資源優化

圖片資源一直是移動端系統中搶佔大流量的部分,尤爲是對於電商系統。優化圖片資源的加載能夠有效地加快頁面響應時間,提高首屏渲染速度。

  • 使用 WebP 格式

WebP 是 Google 推出的一種支持有損/無損壓縮的圖片文件格式,得益於更優的圖像數據壓縮算法,其與 JPG、PNG 等格式相比,在肉眼無差異的圖片質量前提下具備更小的圖片體積(據官方說明,WebP 無損壓縮體積比 PNG 小 26%,有損壓縮體積比 JPEG 小 25-34%)。

小程序的 image 組件 支持 JPG、PNG、SVG、WEBP、GIF 等格式。

  • 圖片裁剪&降質

鑑於移動端設備的分辨率是有上限的,不少圖片的尺寸經常遠大於頁面元素尺寸,這很是浪費網絡資源(通常圖片尺寸 2 倍於頁面元素真實尺寸比較合適)。得益於京東內部強大的圖片處理服務,咱們能夠經過資源的命名規則和請求參數來獲取服務端優化後的圖片:

裁剪成 100x100 的圖片:https://{host}/s100x100_jfs/{file_path}

降質 70%:https://{href}!q70

  • 圖片懶加載、雪碧圖(CSS Sprite)優化

這二者都是比較老生常談的圖片優化技術,這裏就不打算細講了。

小程序的 image 組件 自帶 lazy-load 懶加載支持。雪碧圖技術(CSS Sprite)能夠參考 w3schools 的教程。

  • 降級加載大圖資源

在不得不使用大圖資源的場景下,咱們能夠適當使用 「體驗換速度」 的措施來提高渲染性能。

小程序會把已加載的靜態資源緩存在本地,當短期內再次發起請求時會直接從緩存中取資源(與瀏覽器行爲一致)。所以,對於大圖資源,咱們能夠先呈現高度壓縮的模糊圖片,同時利用一個隱藏的 <image> 節點來加載原圖,待原圖加載完成後再轉移到真實節點上渲染。整個流程,從視覺上會感知到圖片從模糊到高清的過程,但與對首屏渲染的提高效果相比,這點體驗落差是能夠接受的。

下面爲你們提供部分例程:

<!-- banner.wxml -->
<image src="{{url}}" />

<!-- 圖片加載器 -->
<image
  style="width:0;height:0;display:none"
  src="{{preloadUrl}}"
  bindload="onImgLoad"
  binderror="onErrorLoad"
/>
複製代碼
// banner.js
Component({
  ready() {
    this.originUrl = 'https://path/to/picture'  // 圖片源地址
    this.setData({
      url: compress(this.originUrl)             // 加載壓縮降質的圖片
      preloadUrl: this.originUrl                // 預加載原圖
    })
  },
  methods: {
    onImgLoad() {
      this.setData({
        url: this.originUrl                       // 加載原圖
      })
    }
  }
})
複製代碼

注意,具備 display: none 樣式的 <image> 標籤只會加載圖片資源,但不渲染。

京喜首頁的商品輪播模塊也採用了這種降級加載方案,在首屏渲染時只會加載第一幀降質圖片。以每幀原圖 20~50kb 的大小計算,這一措施能夠在初始化階段節省掉幾百 kb 的網絡資源請求。

Banner 大圖降級加載

爲了更好地呈現效果,上面 gif 作了降速處理

骨架屏

一方面,咱們能夠從下降網絡請求時延、減小關鍵渲染的節點數這兩個角度出發,縮短完成 FMP(首次有效繪製)的時間。另外一方面,咱們也須要從用戶感知的角度優化加載體驗。

「白屏」 的加載體驗對於首次訪問的用戶來講是難以接受的,咱們可使用尺寸穩定的骨架屏,來輔助實現真實模塊佔位和瞬間加載。

骨架屏目前在業界被普遍應用,京喜首頁選擇使用灰色豆腐塊做爲骨架屏的主元素,大體勾勒出各模塊主體內容的樣式佈局。因爲微信小程序不支持 SSR(服務端渲染),使動態渲染骨架屏的方案難以實現,所以京喜首頁的骨架屏是經過 WXSS 樣式靜態渲染的。

有趣的是,京喜首頁的骨架屏方案經歷了 「統一管理」「(組件)獨立管理」 兩個階段。出於避免對組件的侵入性考慮,最初的骨架屏是由一個完整的骨架屏組件統一管理的:

<!-- index.wxml -->
<skeleton wx:if="{{isLoading}}"></skeleton>
<block wx:else>
  頁面主體
</block>
複製代碼

但這種作法的維護成本比較高,每次頁面主體模塊更新迭代,都須要在骨架屏組件中的對應節點同步更新(譬如某個模塊的尺寸被調整)。除此以外,感官上從骨架屏到真實模塊的切換是跳躍式的,這是由於骨架屏組件和頁面主體節點之間的關係是總體條件互斥的,只有當頁面主體數據 Ready(或渲染完畢)時纔會把骨架屏組件銷燬,渲染(或展現)主體內容。

爲了使用戶感知體驗更加絲滑,咱們把骨架屏元素拆分放到各個業務組件中,骨架屏元素的顯示/隱藏邏輯由業務組件內部獨立管理,這就能夠輕鬆實現 「誰跑得快,誰先出來」 的並行加載效果。除此以外,骨架屏元素與業務組件共用一套 WXML 節點,且相關樣式由公共的 sass 模塊集中管理,業務組件只須要在適當的節點掛上 skeletonskeleton__block 樣式塊便可,極大地下降了維護成本。

<!-- banner.wxml -->
<view class="{{isLoading ? 'banner--skeleton' : ''}}"> <view class="banner_wrapper"></view> </view>
複製代碼
// banner.scss
.banner--skeleton {
  @include skeleton;
  .banner_wrapper {
    @include skeleton__block;
  }
}
複製代碼

京喜首頁骨架屏

上面的 gif 在壓縮過程有些小問題,你們能夠直接訪問【京喜】小程序體驗骨架屏效果。

如何提高渲染性能?

當調用 wx.navigateTo 打開一個新的小程序頁面時,小程序框架會完成這幾步工做:

1. 準備新的 webview 線程環境,包括基礎庫的初始化;

2. 從邏輯層到視圖層的初始數據通訊;

3. 視圖層根據邏輯層的數據,結合 WXML 片斷構建出節點樹(包括節點屬性、事件綁定等信息),最終與 WXSS 結合完成頁面渲染;

因爲微信會提早開始準備 webview 線程環境,因此小程序的渲染損耗主要在後二者 數據通訊節點樹建立/更新 的流程中。相對應的,比較有效的渲染性能優化方向就是:

  • 下降線程間通訊頻次;
  • 減小線程間通訊的數據量;
  • 減小 WXML 節點數量;

合併 setData 調用

儘量地把屢次 setData 調用合併成一次。

咱們除了要從編碼規範上踐行這個原則,還能夠經過一些技術手段下降 setData 的調用頻次。譬如,把同一個時間片(事件循環)內的 setData 調用合併在一塊兒,Taro 框架就使用了這個優化手段。

在 Taro 框架下,調用 setState 時提供的對象會被加入到一個數組中,當下一次事件循環執行的時候再把這些對象合併一塊兒,經過 setData 傳遞給原生小程序。

// 小程序裏的時間片 API
const nextTick = wx.nextTick ? wx.nextTick : setTimeout;
複製代碼

只把與界面渲染相關的數據放在 data

不可貴出,setData 傳輸的數據量越多,線程間通訊的耗時越長,渲染速度就越慢。根據微信官方測得的數據,傳輸時間和數據量大致上呈正相關關係:

數據傳輸時間與數據量關係圖

上圖來自小程序官方開發指南

因此,與視圖層渲染無關的數據儘可能不要放在 data 中,能夠放在頁面(組件)類的其餘字段下。

應用層的數據 diff

每當調用 setData 更新數據時,會引發視圖層的從新渲染,小程序會結合新的 data 數據和 WXML 片斷構建出新的節點樹,並與當前節點樹進行比較得出最終須要更新的節點(屬性)。

即便小程序在底層框架層面已經對節點樹更新進行了 diff,但咱們依舊能夠優化此次 diff 的性能。譬如,在調用 setData 時,提早確保傳遞的全部新數據都是有變化的,也就是針對 data 提早作一次 diff。

Taro 框架內部作了這一層優化。在每次調用原生小程序的 setData 以前,Taro 會把最新的 state 和當前頁面實例的 data 作一次 diff,篩選出有必要更新的數據再執行 setData

附 Taro 框架的 數據 diff 規則

去掉沒必要要的事件綁定

當用戶事件(如 ClickTouch 事件等)被觸發時,視圖層會把事件信息反饋給邏輯層,這也是一個線程間通訊的過程。但,若是沒有在邏輯層中綁定事件的回調函數,通訊將不會被觸發。

因此,儘可能減小沒必要要的事件綁定,尤爲是像 onPageScroll 這種會被頻繁觸發的用戶事件,會使通訊過程頻繁發生。

去掉沒必要要的節點屬性

組件節點支持附加自定義數據 dataset(見下面例子),當用戶事件被觸發時,視圖層會把事件 targetdataset 數據傳輸給邏輯層。那麼,當自定義數據量越大,事件通訊的耗時就會越長,因此應該避免在自定義數據中設置太多數據。

<!-- wxml -->
<view data-a='A' data-b='B' bindtap='bindViewTap' > Click Me! </view>
複製代碼
// js
Page({
  bindViewTap(e) {
    console.log(e.currentTarget.dataset)
  }
})
複製代碼

適當的組件顆粒度

小程序的組件模型與 Web Components 標準中的 ShadowDOM 很是相似,每一個組件都有獨立的節點樹,擁有各自獨立的邏輯空間(包括獨立的數據、setData 調用、createSelectorQuery 執行域等)。

不可貴出,若是自定義組件的顆粒度太粗,組件邏輯太重,會影響節點樹構建和新/舊節點樹 diff 的效率,從而影響到組件內 setData 的性能。另外,若是組件內使用了 createSelectorQuery 來查找節點,過於龐大的節點樹結構也會影響查找效率。

咱們來看一個場景,京喜首頁的 「京東秒殺」 模塊涉及到一個倒計時特性,是經過 setInterval 每秒調用 setData 來更新錶盤時間。咱們經過把倒計時抽離出一個基礎組件,能夠有效下降頻繁 setData 時的性能影響。

京東秒殺

適當的組件化,既能夠減少數據更新時的影響範圍,又能支持複用,何樂而不爲?誠然,並不是組件顆粒度越細越好,組件數量和小程序代碼包大小是正相關的。尤爲是對於使用編譯型框架(如 Taro)的項目,每一個組件編譯後都會產生額外的運行時代碼和環境 polyfill,so,爲了代碼包空間,請保持理智...

事件總線,替代組件間數據綁定的通訊方式

WXML 數據綁定是小程序中父組件向子組件傳遞動態數據的較爲常見的方式,以下面例程所示:Component A 組件中的變量 ab 經過組件屬性傳遞給 Component B 組件。在此過程當中,不可避免地須要經歷一次 Component A 組件的 setData 調用方可完成任務,這就會產生線程間的通訊。「合情合理」,但,若是傳遞給子組件的數據只有一部分是與視圖渲染有關呢?

<!-- Component A -->
<component-b prop-a="{{a}}" prop-b="{{b}}" /> 複製代碼
// Component B
Component({
  properties: {
    propA: String,
    propB: String,
  },
  methods: {
    onLoad: function() {
      this.data.propA
      this.data.propB
    }
  }
})
複製代碼

推薦一種特定場景下很是便捷的作法:經過事件總線(EventBus),也就是發佈/訂閱模式,來完成由父向子的數據傳遞。其構成很是簡單(例程只提供關鍵代碼...):

  • 一個全局的事件調度中心

    class EventBus {
      constructor() {
        this.events = {}
      }
    
      on(key, cb) { this.events[key].push(cb) }
    
      trigger(key, args) { 
        this.events[key].forEach(function (cb) {
          cb.call(this, ...args)
        })
      }
      
      remove() {}
    }
    
    const event = new EventBus()
    複製代碼
  • 事件訂閱者

    // 子組件
    Component({
      created() {
        event.on('data-ready', (data) => { this.setData({ data }) })
      }
    })
    複製代碼
  • 事件發佈者

    // Parent
    Component({
      ready() {
        event.trigger('data-ready', data)
      }
    })
    複製代碼

子組件被建立時事先監聽數據下發事件,當父組件獲取到數據後觸發事件把數據傳遞給子組件,這整個過程都是在小程序的邏輯層裏同步執行,比數據綁定的方式速度更快。

但並不是全部場景都適合這種作法。像京喜首頁這種具備 「數據單向傳遞」「展現型交互」 特性、且 一級子組件數量龐大 的場景,使用事件總線的效益將會很是高;但如果頻繁 「雙向數據流「 的場景,用這種方式會致使事件交錯難以維護。

題外話,Taro 框架在處理父子組件間數據傳遞時使用的是觀察者模式,經過 Object.defineProperty 綁定父子組件關係,當父組件數據發生變化時,會遞歸通知全部後代組件檢查並更新數據。這個通知的過程會同步觸發數據 diff 和一些校驗邏輯,每一個組件跑一遍大概須要 5 ~ 10 ms 的時間。因此,若是組件量級比較大,整個流程下來時間損耗仍是不小的,咱們依舊能夠嘗試事件總線的方案。

組件層面的 diff

咱們可能會遇到這樣的需求,多個組件之間位置不固定,支持隨時隨地靈活配置,京喜首頁也存在相似的訴求。

京喜首頁主體可被劃分爲若干個業務組件(如搜索框、導航欄、商品輪播等),這些業務組件的順序是不固定的,今天是搜索框在最頂部,明天有可能變成導航欄在頂部了(誇張了...)。咱們不可能針對多種順序可能性提供多套實現,這就須要用到小程序的自定義模板 <template>

實現一個支持調度全部業務組件的模板,根據後臺下發的模塊數組按序循環渲染模板,以下面例程所示。

<!-- index.wxml -->
<template name="render-component">
  <search-bar wx:if="{{compId === 'SearchBar'}}" floor-id="{{index}}" />
  <nav-bar wx:if="{{compId === 'NavBar'}}" floor-id="{{index}}" />
  <banner wx:if="{{compId === 'Banner'}}" floor-id="{{index}}" />
  <icon-nav wx:if="{{compId === 'IconNav'}}" floor-id="{{index}}" />
</template>

<view
  class="component-wrapper"
  wx:for="{{comps}}"
  wx:for-item="comp"
>
  <template is="render-component" data="{{...comp}}"/>
</view>
複製代碼
// search-bar.js
Component({
  properties: {
    floorId: Number,
  },
  created() {
    event.on('data-ready', (comps) => {
      const data = comps[this.data.floorId] // 根據樓層位置取數據
    })
  }
})
複製代碼

貌似很是輕鬆地完成需求,但值得思考的是:若是組件順序調整了,全部組件的生命週期會發生什麼變化?

假設,上一次渲染的組件順序是 ['search-bar','nav-bar','banner', 'icon-nav'],如今須要把 nav-bar 組件去掉,調整爲 ['search-bar','banner', 'icon-nav']。經實驗得出,當某個組件節點發生變化時,其前面的組件不受影響,其後面的組件都會被銷燬從新掛載。

原理很簡單,每一個組件都有各自隔離的節點樹(ShadowTree),頁面 body 也是一個節點樹。在調整組件順序時,小程序框架會遍歷比較新/舊節點樹的差別,因而發現新節點樹的 nav-bar 組件節點不見了,就認爲該(樹)分支下從 nav-bar 節點起發生了變化,日後節點都須要重渲染。

但實際上,這裏的組件順序是沒有變化的,丟失的組件按道理不該該影響到其餘組件的正常渲染。因此,咱們在 setData 前先進行了新舊組件列表 diff:若是 newList 裏面的組件是 oldList 的子集,且相對順序沒有發生變化,則全部組件不從新掛載。除此以外,咱們還要在接口數據的相應位置填充上空數據,把該組件隱藏掉,done。

經過組件 diff 的手段,能夠有效下降視圖層的渲染壓力,若是有相似場景的朋友,也能夠參考這種方案。

內存佔用太高?

想必沒有什麼會比小程序 Crash 更影響用戶體驗了。

當小程序佔用系統資源太高,就有可能會被系統銷燬或被微信客戶端主動回收。應對這種尷尬場景,除了提示用戶提高硬件性能以外(譬如來京東商城買新手機),還能夠經過一系列的優化手段下降小程序的內存損耗。

內存不足彈窗提示

內存預警

小程序提供了監聽內存不足告警事件的 API:wx.onMemoryWarning,旨在讓開發者收到告警時及時釋放內存資源避免小程序 Crash。然而對於小程序開發者來講,內存資源目前是沒法直接觸碰的,最多就是調用 wx.reLaunch 清理全部頁面棧,重載當前頁面,來下降內存負荷(此方案過於粗暴,別衝動,想一想就好...)。

不過內存告警的信息收集卻是有意義的,咱們能夠把內存告警信息(包括頁面路徑、客戶端版本、終端手機型號等)上報到日誌系統,分析出哪些頁面 Crash 率比較高,從而針對性地作優化,下降頁面複雜度等等。

回收後臺頁面計時器

根據雙線程模型,小程序每個頁面都會獨立一個 webview 線程,但邏輯層是單線程的,也就是全部的 webview 線程共享一個 JS 線程。以致於當頁面切換到後臺態時,仍然有可能搶佔到邏輯層的資源,譬如沒有銷燬的 setIntervalsetTimeout 定時器:

// Page A
Page({
  onLoad() {
    let i = 0
    setInterval(() => { i++ }, 100)
  }
})
複製代碼

即便如小程序的 <swiper> 組件,在頁面進入後臺態時依然是會持續輪播的。

正確的作法是,在頁面 onHide 的時候手動把定時器清理掉,有必要時再在 onShow 階段恢復定時器。坦白講,區區一個定時器回調函數的執行,對於系統的影響應該是微不足道的,但不容忽視的是回調函數裏的代碼邏輯,譬如在定時器回調裏持續 setData 大量數據,這就很是難受了...

避免頻發事件中的重度內存操做

咱們常常會遇到這樣的需求:廣告曝光、圖片懶加載、導航欄吸頂等等,這些都須要咱們在頁面滾動事件觸發時實時監聽元素位置或更新視圖。在瞭解小程序的雙線程模型以後不難發現,頁面滾動時 onPageScroll 被頻發觸發,會使邏輯層和視圖層發生持續通訊,若這時候再 「火上澆油」 調用 setData 傳輸大量數據,會致使內存使用率快速上升,使頁面卡頓甚至 「假死」。因此,針對頻發事件的監聽,咱們最好遵循如下原則:

  • onPageScroll 事件回調使用節流;
  • 避免 CPU 密集型操做,譬如複雜的計算;
  • 避免調用 setData,或減少 setData 的數據量;
  • 儘可能使用 IntersectionObserver 來替代 SelectorQuery,前者對性能影響更小;

大圖、長列表優化

小程序官方文檔 描述,大圖片和長列表圖片在 iOS 中會引發 WKWebView 的回收,致使小程序 Crash。

對於大圖片資源(譬如滿屏的 gif 圖)來講,咱們只能儘量對圖片進行降質或裁剪,固然不使用是最好的。

對於長列表,譬如瀑布流,這裏提供一種思路:咱們能夠利用 IntersectionObserver 監聽長列表內組件與視窗之間的相交狀態,當組件距離視窗大於某個臨界點時,銷燬該組件釋放內存空間,並用等尺寸的骨架圖佔坑;當距離小於臨界點時,再取緩存數據從新加載該組件。

然而無可避免地,當用戶快速滾動長列表時,被銷燬的組件可能來不及加載完,視覺上就會出現短暫的白屏。咱們能夠適當地調整銷燬閾值,或者優化骨架圖的樣式來儘量提高體驗感。

小程序官方提供了一個 長列表組件,能夠經過 npm 包的方式引入,有興趣的能夠嘗試。

總結

結合上述的種種方法論,京喜小程序首頁進行全方位升級改造以後給出了答卷:

1. Audits 審計工具的性能得分 86

2. 優化後的首屏渲染完成時間(FMP):

優化後的首屏渲染時間

3. 優化先後的測速數據對比:

優化先後的測速數據對比

然而,業務迭代在持續推動,多樣化的用戶場景徒增不減,性能優化將成爲咱們平常開發中揮之不去的原則和主題。本文以微信小程序開發中與性能相關的問題爲出發點,基於小程序的底層框架原理,探究小程序性能體驗提高的各類可能性,但願能爲各位小程序開發者帶來參考價值。

參考


歡迎關注凹凸實驗室博客:aotu.io

或者關注凹凸實驗室公衆號(AOTULabs),不定時推送文章:

image
相關文章
相關標籤/搜索