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透過延遲執行改善效能的痕跡。

[2013-07-22更新] 流浪小風補充了一個很棒的做法,先透過ko.utils.unwrapObservable(vm.items)取得ko.observableArray底層的陣列,針對其塞入元素(不會觸發重算)後再呼叫items.valueHasMutated()強制重算,如此沒有多餘重算,又不會因throttle犠牲即時性,是更好的做法。感謝小風補充!! 線上範例

var vm = new myViewModel();
ko.applyBindings(vm);
 
var list = ko.utils.unwrapObservable(vm.items);
for (var i = 1; i < 10000; i++) {            
    list.push(i);
}
vm.items.valueHasMutated();

【結論】在設計ko.computed()時,記得評估其被呼叫次數與時機,避免短時間反覆大量執行,必要時可使用throttle擴充方法直接對observableArray底層陣列塞入元素後再呼叫.valueHasMutated()重算的方式改善,才不會寫出吃光CPU的怪獸網頁。

[KO系列]

http://www.darkthread.net/kolab/labs/default.aspx?m=post

Comments

# by 流浪小風

分享一個做法, 使用valueHasMutated 取出非observable的array進行push, 處理完畢再呼叫valueHasMutated, 一次計算computed值 http://jsbin.com/inusip/2/edit

# by Jeffrey

to 流浪小風,個人覺得這個做法更好,效果相同卻免除了因throttle犠牲的時效性! 已加入本文,感謝分享。

Post a comment