ko.computed()能追蹤所依賴的ko.observable()或ko.observableArray(),在其變化時自動重算,開發時依直覺寫出關連邏輯,屬性間便會依預期變化。使用起來固然方便,但是當依賴對象連續變化時,要留意反覆重算的必要性以及對效能的衝擊。用一個範例來說明:
<!DOCTYPE html>
<html>
<head>
<script src="http://ajax.aspnetcdn.com/ajax/knockout/knockout-2.2.1.js">
</script>
<meta charset=utf-8 />
<title>KO範例26 - 利用throttle改善computed效率(改善前)</title>
</head>
<body>
<div>
Max: <span data-bind="text: max"></span>
Min: <span data-bind="text: min"></span>
Sum: <span data-bind="text: sum"></span>
Avg: <span data-bind="text: avg"></span>
</div>
<script>
function myViewModel() {
var self = this;
self.items = ko.observableArray();
self.max = ko.computed(function() {
var ary = self.items();
var max = null;
for (var i = 0; i < ary.length; i++) {
if (max == null || ary[i] > max) max = ary[i];
}
return max;
});
self.min = ko.computed(function() {
var ary = self.items();
var min = null;
for (var i = 0; i < ary.length; i++) {
if (min == null || ary[i] < min) min = ary[i];
}
return min;
});
self.sum = ko.computed(function() {
var ary = self.items();
var sum = 0;
for (var i = 0; i < ary.length; i++) {
sum += ary[i];
}
return sum
});
self.avg = ko.computed(function() {
var ary = self.items();
if (ary.length == 0) return 0;
return parseFloat(self.sum()) / ary.length;
});
}
var vm = new myViewModel();
ko.applyBindings(vm);
for (var i = 1; i < 10000; i++) {
vm.items.push(i);
}
</script>
</body>
</html>
在以上範例中,ViewMode用了一個observableArray存放數字陣列,另外有四個屬性: max, min, sum, avg分別用以統計該數字陣列的最大值、最小值、總和及平均值,最直覺的寫法是四個屬性用ko.computed各寫各的,跑迴圈取出陣列的每一個數字處理。但可以預期,只要陣列每次加入新元素,就會觸發max, min, sum各跑一次迴圈(avg直接由sum值除以陣列長度計算,不必跑迴圈)。當我們連續在陣列加入從1到9999,共9999個數字,猜猜ko.computed要執行幾次? 理論上會觸發9999次,再上三組computed跑迴圈,第一次跑1圈,第2次2圈,第3次3圈,...,第9999次跑9999圈,CPU耗用量不少,使用IE10實測,耗時3.814秒。
先撇開Knockout特性不談,程式邏輯面存在一些無效率,先對三組computed跑迴圈的部分開刀,其實只需要跑一次迴圈,就可以一次算出max, min, sum及avg,故可以將max、min、avg都改為純ko.observable(),只留下sum為ko.computed(),加總同時一併求出最大值、最小值及平均,再分別寫入max、min及avg。
function myViewModel() {
var self = this;
self.items = ko.observableArray();
self.max = ko.observable();
self.min = ko.observable();
self.avg = ko.observable();
self.sum = ko.computed(function() {
var ary = self.items();
var sum = 0;
var max = null;
var min = null;
for (var i = 0; i < ary.length; i++) {
if (min == null || ary[i] < min) min = ary[i];
if (max == null || ary[i] > max) max = ary[i];
sum += ary[i];
}
self.max(max);
self.min(min);
self.avg(ary.length == 0 ? 0 : parseFloat(self.sum()) / ary.length);
return sum;
});
}
var vm = new myViewModel();
ko.applyBindings(vm);
for (var i = 1; i < 10000; i++) {
vm.items.push(i);
}
改良後,發現原本會執行39,892次的evaluatePossibleAsync減少到9,955次,而耗時也由3.8秒降低到2.351秒。
還有改善空間嗎? 當然有! 我們關心的是最大值、最小值、加總、平均的最後計算結果,在將1-9999塞入陣列期間的變化過程一點也不重要,因此並不需要每塞一個數字就重新計算一次,等資料全部就緒再計算就好。先前在AJAX範例介紹過的throttle擴充方法是解決這個問題的好方法,只需在computed後方套上throttle,則observableArray的元素有變化時,不會立刻重算,會等待一小段時間,確認資料不再變化後才進行重算,如此便可抑制連續新增元素期間多餘的重算動作,有效改善效能。
function myViewModel() {
var self = this;
self.items = ko.observableArray();
self.max = ko.observable();
self.min = ko.observable();
self.avg = ko.observable();
self.sum = ko.computed(function() {
var ary = self.items();
var sum = 0;
var max = null;
var min = null;
for (var i = 0; i < ary.length; i++) {
if (min == null || ary[i] < min) min = ary[i];
if (max == null || ary[i] > max) max = ary[i];
sum += ary[i];
}
self.max(max);
self.min(min);
self.avg(ary.length == 0 ? 0 : parseFloat(self.sum()) / ary.length);
return sum;
}).extend({ throttle: 200 });
}
var vm = new myViewModel();
ko.applyBindings(vm);
for (var i = 1; i < 10000; i++) {
vm.items.push(i);
}
最終改良版果然沒讓我們失望,耗時由2.351秒一舉縮短到136ms,而樹狀節點中的setTimeout、clearTimeout,便是throttle透過延遲執行改善效能的痕跡。
【結論】在設計ko.computed()時,記得評估其被呼叫次數與時機,避免短時間反覆大量執行,必要時可使用throttle擴充方法改善,才不會寫出吃光CPU的怪獸網頁。
[KO系列]