WePY 在小程序性能調優上做出的探究

原文鏈接:

https://cloud.tencent.com/developer/article/1005017

導語

性能調優是一個亙古不變的話題,無論是在傳統H5上還是小程序中。因爲實現機制不同,可能導致傳統H5中的某些優化方式在小程序上並不適用。因此必須另開闢蹊徑找出適合小程序的調估方式。

預先加載

這一節的內容主要是基於 anniexliu 的文章進行的研究:《小程序性能優化——提高頁面加載速度》

原理

傳統H5中也可以通過預加載來提升用戶體驗,但在小程序中做到這一點實際上是可以更簡單方便卻又更容易被忽視的。

傳統H5在啓動時,page1.html 只會加載 page1.html 的頁面與邏輯代碼,當page1.html 跳轉至 page2.html 時,page1 所有的 Javascript 數據將會從內存中消失。page1 與 page2 之間的數據通信只能通過 URL 參數傳遞或者瀏覽器的 cookie,localStorge 存儲處理。

小程序在啓動時,會直接加載所有頁面邏輯代碼進內存,即便 page2 可能都不會被使用。在 page1 跳轉至 page2 時,page1 的邏輯代碼 Javascript 數據也不會從內存中消失。page2 甚至可以直接訪問 page1 中的數據。

最簡單的驗證方式就是在 page1 中加入一個setInterval(function () {console.log('exist')}, 1000)。傳統H5中跳轉後定時器會自動消失,小程序中跳轉後定時器仍然工作。

小程序的這種機制差異正好可以更好的實現預加載。通常情況下,我們習慣將數據拉取寫在 onLoad 事件中。但是小程序的 page1 跳轉到 page2,到 page2 的 onLoad 是存在一個 300ms ~ 400ms 的延時的。如下圖:

因爲小程序的特性,完全可以在 page1 中預先拿取數據,然後在 page2 中直接使用數據,這樣就可以避開 redirecting 的 300ms ~ 400ms了。如下圖:

試驗

在官方demo中加入兩個頁面:page1,page2

// page1.js 點擊事件中記錄開始時間
bindTap: function () {
  wx.startTime = +new Date();
  wx.navigateTo({
    url: '../page2/page2'
  });
}


// page2.js 中假設從服務器拉取數據需要500ms
fetchData: function (cb) {
  setTimeout(function () {
    cb({a:1});
  }, 500);
},
onLoad: function () {
  wx.endTime = +new Date();
  this.fetchData(function () {
    wx.endFetch = +new Date();
    console.log('page1 redirect start -> page2 onload invoke -> fetch data complete: ' + (wx.endTime - wx.startTime) + 'ms - ' + (wx.endFetch - wx.endTime) + 'ms');
  });
}

重試10次,得到的結果如下:

優化

對於上述問題,WePY 中封裝了兩種概念去解決:

  • 預加載數據

用於 page1 主動傳遞數據給 page2,比如 page2 需要加載一份耗時很長的數據。我可以在 page1 閒時先加載好,進入 page2 時直接就可以使用。

  • 預查詢數據

用於避免於 redirecting 延時,在跳轉時調用 page2 預查詢。

擴展了生命週期,添加了onPrefetch事件,會在 redirect 之時被主動調用。同時給onLoad事件添加了一個參數,用於接收預加載或者是預查詢的數據:

// params
// data.from: 來源頁面,page1
// data.prefetch: 預查詢數據
// data.preload: 預加載數據
onLoad (params, data) {}

預加載數據示例:

// page1.wpy 預先加載 page2 需要的數據。

methods: {
  tap () {
    this.$redirect('./page2');
  }
},
onLoad () {
  setTimeout(() => {
    this.$preload('list', api.getBigList())
  }, 3000)
}

// page2.wpy 直接從參數中拿到 page1 中預先加載的數據
onLoad (params, data) {
  data.preload.list.then((list) => render(list));
}

預查詢數據示例:

// page1.wpy 使用封裝的 redirect 方法跳轉時,會調用 page2 的 onPrefetch 方法
methods: {
  tap () {
    this.$redirect('./page2');
  }
}

// page2.wpy 直接從參數中拿到 onPrefetch 中返回的數據
onPrefetch () {
  return api.getBigList();
}
onLoad (params, data) {
  data.prefetch.then((list) => render(list));
}
c

數據綁定

原理

在針對數據綁定做優化時,需要先了解小程序的運行機制。因爲視圖層與邏輯層的完全分離,所以二者之間的通信全都依賴於 WeixinJSBridge 實現。如:

  • 開發者工具中是基於window.postMessage
  • IOS中基於 window.webkit.messageHandlers.invokeHandler.postMessage
  • Android中基於WeixinJSCore.invokeHandler

因此數據綁定方法this.setData也如此,頻繁的數據綁定就增加了通信的成本。再來看看this.setData究竟做了哪些事情。基於開發者工具的代碼,單步調試大致還原出完整的流程,以下是還原後的代碼:

/*
setData 主流程精簡還原,並非完整主流程,內有註釋
*/
function setData (obj) {
    if (typeof(obj) !== 'Object') {
        console.log('類型錯誤'); // 並沒有預期中的return;
    }
    let type = 'appDataChange';

    // u.default.emit(e, this.__wxWebviewId__) 代碼還原
    let e = [type, {
                data: {data: list}, 
                options: {timestamp: +new Date()}
            },
            [0] // this.__wxWebviewId__
    }];

    // WeixinJSBridge.publish.apply(WeixinJSBridge, e); 代碼還原
    var datalength = JSON.stringify(e.data).length;  // 第一次 JSON.stringify
    if (datalength > AppserviceMaxDataSize) { // AppserviceMaxDataSize === 1048576
        console.error('已經超過最大長度');
        return;
    }

    if (type === 'appDataChange' || type === 'pageInitData' || type === '__updateAppData') {

        // sendMsgToNW({appData: __wxAppData, sdkName: "send_app_data"}) 代碼還原
        __wxAppData = {
            'pages/page1/page1': alldata
        }
        e = { appData: __wxAppData, sdkName: "send_app_data" }

        var postdata = JSON.parse(JSON.stringify(e)); // 第二次 JSON.stringify 第一次 JSON.parse
        window.postMessage({
            postdata
        }, "*");
    }


    // sendMsgToNW({appData: __wxAppData, sdkName: "send_app_data"}) 代碼還原
    e = {
        eventName: type,
        data: e[1],
        webviewIds: [0],
        sdkName: 'publish'
    };

    var postdata = JSON.parse(JSON.stringify(e));  // 第三次 JSON.stringify 第二次 JSON.parse
    window.postMessage({
        postdata
    }, "*");
}

setData 運行的流程如下:

從上面代碼以及流程圖中可以看出,在一次setData({a: 1})作時,會進行三次 JSON.stringify,二次JSON.parse以及兩次window.postMessage操作。並且在第一次window.postMessage時,並不是單單隻處理傳遞的{a:1},而是處理當前頁面的所有 data 數據。因此可想而知每次setData操作的開銷是非常大的,只能通過減少數據量,以及減少setData操作來規避。

setData 相近的是 React 的 setState 方法,同樣是使用 setState 去更新視圖的,可以通過源碼 React:L199 看到 setState 的關鍵代碼如下:

function enqueueUpdate(component) {
  ensureInjected();
  if (!batchingStrategy.isBatchingUpdates) {
    batchingStrategy.batchedUpdates(enqueueUpdate, component);
    return;
  }
  dirtyComponents.push(component);
}

setState的工作流程如下:

可以看出,setState 加入了一個緩衝列隊,在同一執行流程中進行多次 setState 之後也不會重複渲染視圖,這就是一種很好的優化方式。

實驗

爲了證實setData的性能問題,可以寫簡單的測試例子去測試:

動態綁定1000條數據的列表進行性能測試,這裏測試了三種情況:

  • 最優綁定: 在內存中添加完畢後最後執行setData操作。
  • 最差綁定: 在添加一條記錄執行一次setData操作。
  • 最智能綁定:不管中間進行了什麼操作,在運行結束時執行一次髒檢查,對需要設置的數據進行setData操作。

參考代碼如下:

// page1.wxml
<view bindtap="worse">
  <text class="user-motto">worse數據綁定測試</text>
</view>
<view bindtap="best">
  <text class="user-motto">best數據綁定測試</text>
</view>
<view bindtap="digest">
  <text class="user-motto">digest數據綁定測試</text>
</view>

<view class="list">
  <view wx:for="{{list}}" wx:for-index="index"wx:for-item="item">
      <text>{{item.id}}</text>---<text>{{item.name}}</text>
  </view>
</view>


// page1.js
worse: function () {
   var start = +new Date();
   for (var i = 0; i < 1000; i++) {
     this.data.list.push({id: i, name: Math.random()});
     this.setData({list: this.data.list});
   }
   var end = +new Date();
   console.log(end - start);
},
best: function () {
  var start = +new Date();
  for (var i = 0; i < 1000; i++) {
    this.data.list.push({id: i, name: Math.random()});
  }
  this.setData({list: this.data.list});
  var end = +new Date();
  console.log(end - start);
},
digest: function () {
  var start = +new Date();
  for (var i = 0; i < 1000; i++) {
    this.data.list.push({id: i, name: Math.random()});
  }
  var data = this.data;
  var $data = this.$data;
  var readyToSet = {};
  for (k in data)  {
    if (!util.$isEqual(data[k], $data[k])) {
      readyToSet[k] = data[k];
      $data[k] = util.$copy(data[k], true);
    }
  }
  if (Object.keys(readyToSet).length) {
    this.setData(readyToSet);
  }
  var end = +new Date();
  console.log(end - start);
},
onLoad: function () {
  this.$data = util.$copy(this.data, true);
}

在經過十次刷新運行測試後得出以下結果:

實現同樣的邏輯,性能數據卻相差40倍左右。由此可以看出,在開發過程中,一定要避免同一流程內多次 setData 操作。

優化

在開發時,避免在同一流程內多次使用setData當然是最佳實踐。採取人工維護肯定是能夠實現的,就好比能用原生 js 能寫出比衆多框架更高效的性能一樣。但當頁面邏輯負責起來之後,花很大的精力去維護都不一定能保證每個流程只存在一次setData,而且可維護性也不高。因此,WePY選擇使用髒檢查去做數據綁定優化。用戶不用再擔心在我的流程裏,數據被修改了多少次,只會在流程最後做一次髒檢查,並且按需執行setData。

髒檢測機制借鑑自AngularJS,多數人一聽到髒檢查都會覺得是低效率的一種作法,認爲使用 Vue.js 中的 getter,setter更高效。其實不然,兩種機制都是對同一件事的不同實現方式。各有優劣,取決於使用的人在使用過程中是否正好放大了機制中的劣勢面。

WePY 中的 setData 就好比是一個 setter,在每次調用時都會去渲染視圖。因此如果再封裝一層 getter、setter 就完全沒有意義,沒有任何優化可言。這也就是爲什麼一個類 Vue.js 的小程序框架卻選擇了與之相反的另外一種數據綁定方式。

再回來看髒檢查的問題在哪裏,從上面實驗的代碼可以看出,髒檢查的性能問題在於每次進行髒檢查時,需要遍歷所以數據並且作值的深比較,性能取決於遍歷以及比較數據的大小。WePY 中深比較是使用的 underscore 的 isEqual 方法。爲了驗證效率問題,使用不同的比較方法對一個 16.7 KB 的複雜 JSON 數據進行深比較,測試用例請看這裏:deep-compare-test-case

得到的結果如下:

從結果來看,對於一個 16.7 KB 的數據深比較是完全不足以產生性能問題的。那 AngularJS 1.x 髒檢查的性能問題是怎麼出現的呢?

AngularJS 1.x 中沒有組件的概念,頁面數據就位於 controller 的 \$scope 當中。每一次髒檢查都是從 \$rootScope 開始,隨後遍歷至所有子 \$scope。參考這裏 angular.js:L1081。對於一個大型的單頁應用來說,所有 \$scope 中的數據可能達到了上百甚至上千個都有可能。那時,髒檢查的每次遍歷就可能真的會成爲了性能的瓶頸了。

反觀 WePY,使用類似於 Vue.js 的組件化開發,在拋開父子組件雙向綁定通信的情況下,組件的髒檢查僅針對組件本身的數據進行,一個組件的數據通常不會太多,數據太多時可以細化組件劃分的粒度。因此在這種情況下,髒檢查並不會導致性能問題。

其實,在很多情況下,框架封裝的解決方案都不是性能優化的最優解決方案,使用原生肯定能優化出更快的代碼。但它們之所以存在並且有價值,那都是因爲它們是在性能、開發效率、可維護性上尋找到一個平衡點,這也是爲什麼 WePY 選擇使用髒檢查作爲數據綁定的優化。

其它優化

除了以上兩點是基於性能上做出的優化以外,WePY 也作出了一系列開發效率上的優化。因爲在我之前的文章裏都有詳細說明,所以在這裏就簡單列舉一下,不做深入探討。詳情可以參看 WePY 文檔。

組件化開發

支持組件循環、嵌套,支持組件 Props 傳值,組件事件通信等等。

parent.wpy
<child :item.sync="myitem" />

<repeat for="{{list}}" item="item" index="index">
   <item :item="item" />
</repeat>

支持豐富的編譯器

js 可以選擇用 Babel 或者 TypeScript 編譯。

wxml 可以選擇使用 Pug(原Jade)。

wxss 可以選擇使用 Less、Sass、Styus。

支持豐富的插件處理

可以通過配置插件對生成的js進行壓縮混淆,壓縮圖片,壓縮 wxml 和 json 已節省空間等等。

支持 ESLint 語法檢查

添加一行配置就可以支持 ESLint 語法檢查,可以避免低級語法錯誤以及統一項目代碼的風格。

生命週期優化

添加了 onRoute 的生命週期。用於頁面跳轉後觸發。 因爲並不存在一個頁面跳轉事件(onShow 事件可以用作頁面跳轉事件,但同時也存在負作用,比如按 HOME 鍵後切回來,或者拉起支付後取消,拉起分享後取消都會觸發 onShow 事件)。

支持 Mixin 混合

可以靈活的進行不同組件之間的相同功能的複用。參考 Vue.js 官方文檔: 混合

優化事件,支持自定義事件

bindtap="tap"簡寫爲 @tap="tap",catchtap="tap"簡寫爲@tap.stop="tap"。

對於組件還提供組件自定義事件

<child @myevent.user="someevent" />

優化事件傳參

官方版本如下:

<view data-alpha-beta="1" data-alphaBeta="2" bindtap="bindViewTap"> DataSet Test </view>
Page({
  bindViewTap:function(event){
    event.target.dataset.alphaBeta === 1 // - 會轉爲駝峯寫法
    event.target.dataset.alphabeta === 2 // 大寫會轉爲小寫
  }
})

優化後:

<view @tap="bindViewTap("1", "2")"> DataSet Test </view>

methods: {
  bindViewTap(p1, p2, event) {
    p1 === "1";
    p2 === "2";
  }
}

結束語

小程序還存在很多值得開發者去探索優化的地方,歡迎大家與我探討交流開發心得。若本文存在不準確的地方,歡迎批評指正。

原創聲明,本文系作者授權雲+社區發表,未經許可,不得轉載。

如有侵權,請聯繫 [email protected] 刪除。

編輯於 2017-06-19

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章