WePY 在小程式效能調優上做出的探究
效能調優是一個亙古不變的話題,無論是在傳統H5上還是小程式中。因為實現機制不同,可能導致傳統H5中的某些優化方式在小程式上並不適用。因此必須另開闢蹊徑找出適合小程式的調估方式。
預先載入
原理
傳統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));
}
資料繫結
原理
在針對資料繫結做優化時,需要先了解小程式的執行機制。因為檢視層與邏輯層的完全分離,所以二者之間的通訊全都依賴於 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);
}
在經過十次重新整理執行測試後得出以下結果:
worse(ms) | best(ms) | digest(ms) |
---|---|---|
8540 | 24 | 23 |
7784 | 22 | 25 |
7884 | 23 | 25 |
8317 | 22 | 25 |
7968 | 28 | 26 |
7939 | 21 | 23 |
7853 | 22 | 23 |
8053 | 25 | 23 |
8007 | 24 | 29 |
8168 | 25 | 24 |
實現同樣的邏輯,效能資料卻相差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 選擇使用髒檢查作為資料繫結的優化。