網友kcw問了一個好問題,提到計算型屬性函數出現會重覆執行兩次的現象!

一句話點醒我夢中人,嚇得我屁滾尿流失了魂~~~

花了點時間研究,才發現原來我學藝不精,一直沒搞通Angular的屬性相依運作原理,時常誤用KO概念思考。謝謝kcw的問題,讓我釐清一塊暗藏地雷的危險地帶。(註: 相依性追蹤往往是MVVM最複雜的一部分,稍有不慎就可能中箭還不知敵在何方,KO亦然)

先看一個Demo,我改寫NG筆記2-網頁MVVM基本架構的計算型屬性範例,拿掉{{ fullName() }},只留<span ng-bind="fullName()">,並在ViewModel的function fullName()中加入計數器:

      function myViewModel() {
        var self = this;
        self.firstName="Jeffrey";
        self.lastName="Lee";
        var c = 1;
        self.fullName = function() {
          window.console && console.log(
            "fullName() calculated(" + (c++) + "): " + 
             self.firstName + " " + self.lastName);
          return self.firstName + " " + self.lastName;
        };
      }

如圖所示,一開始fullName()執行了兩次,內容是初始值"Jeffrey Lee",而之後每輸入一個字元,都會呼叫fullName()兩次。如kcw所提的,每次更動就會觸發兩次重算!

要解釋這個現象,要從Angular相依性追蹤機制說起。NG很為人稱道的一點,是它能監測一般JavaScript元件及屬性變化,省去KO需明確宣告ko.observable()、ko.observableArray()的麻煩,其中魔法來自$watch()$digest()。當使用$watch()或透過ng-model、ng-bind建立繫結時,NG會建立一個Watcher,當每次呼叫$digest()時,所有的Watcher便會重新讀取或計算取值,若觀察對象的值發生變化,NG便會呼叫Watcher註冊的Listener( 即function(newValue, oldValue) { … }函式,可參考筆記8的animHide Directive )執行指定作業。由於Listener也可能更動$scope屬性,進一步連動其他Watcher及Listener,原Watcher可能需要再次重算以反應其他Listener的更新。為了滿足這種情境,NG的解決方案是再跑一次$digest()重算所有Watcher的觀察對象,直到所有計算值不再改變為止。當然,當出現循環參照時(A屬性改變影響B屬性,B的改變又會影響A),會產生無窮迴圈,所以NG訂了一個上限,最多重跑10次,若一直無法穩定就放棄。(原文: The watch listener may change the model, which may trigger other listeners to fire. This is achieved by rerunning the watchers until no changes are detected. The rerun iteration limit is 10 to prevent an infinite loop deadlock.)

如此就能解釋fullName()會跑兩次的原因,ng-bind="model.fullName()"建立了Watcher,當firstName或lastName變動時,Watcher觸發fullName()重算,得到結果後NG再跑一次$digest()以確定所有Watcher的值都不再變化,$digest()執行了ng-bind="model.fullName()" Watcher,於是fullName()跑了兩次。

接著做第二個實驗,除了ng-bind="model.fullName()",再多加一個{{ model.fullName() }},這樣就有兩個Watcher,猜猜會跑幾次? 答案是四次!

更狠一點,我們把計數器也變成ViewModel的屬性之一,並且用<span>{{model.c}}</span>顯示在網頁上(這代表會有增加一個Watcher觀察Counter變化),此時會發生什麼狀況? 是的,循環參考!model.fullName()改變了model.c,呼叫$digest()檢查時再次呼叫fullName()時再次改變model.c,$digest()蒐集的結果與前次不同,NG判斷需再重跑$digest(),fullName()再次執行,model.c又變了,$digest()結果不吻合又要重跑$digest()… 就這麼沒完沒了,最後突破10次重算上限被NG強制中止!

如果不是因為循環參照而是屬性一直在改變呢? 我們故意惡搞讓model.fullName()傳回結果包含亂數:

        self.fullName = function() {
          window.console && console.log(
            "fullName() calculated(" + (c++) + "): " + 
            self.firstName + " " + self.lastName);
          return self.firstName + " " + self.lastName + " " +
            Math.random();
        };

薑!薑!薑!薑~~ 一樣會因為重算超過10數爆掉!

再用一個實驗觀察NG重算的順序,在ViewModel中新增fullName$()及fullName$$()。

      function myViewModel() {
        var self = this;
        self.firstName="Jeffrey";
        self.lastName="Lee";
        var c = 0;
        self.fullName = function() {
          var fn = self.firstName + " " + self.lastName;
          window.console && console.log(
            "fullName() calculated(" + (c++) + "): " + fn);
          return fn;
        };
        self.fullName$ = function() {
          var fn = self.fullName() + "$";
          window.console && console.log(
            "fullName$() calculated(" + (c++) + "): " + fn);
          return fn;
        };
        self.fullName$$ = function() {
          var fn = self.fullName$() + "$";
          window.console && console.log(
            "fullName$$() calculated(" + (c++) + "): " + fn);
          return fn;
        };
      }

由Console輸出結果,推測NG的重算執行順序如下:

  1. ng-bind="model.fullName()" Watcher初始化觸發fullName()計算,對應到calculated(0)
  2. ng-bind="model.fullName$()" Watcher初始化觸發fullName$(),其中有var fn = self.fullName() + "$"; 呼叫fullName(),故出現fullName() calculated(1),fullName$() calculated(2)
  3. ng-bind="model.fullName$$()" Watcher初始化觸發fullName$$(),其中有var fn = self.fullName$(),self.fullName$()又呼叫了self.fullName(),故順序為fullName() calculated(3),fullName$() calculated(4),fullName$$() calculated(5)
  4. 所有Watcher執行完畢,NG要Rerun以確定所有值都不再變動,故1,2,3三個Watcher會再重新取一次值,故用同樣的順序重新執行calculated(6)到calculated(11)

調整<div ng-bind="…">順序:

    <div ng-bind="model.fullName$$()"></div>
    <div ng-bind="model.fullName$()"></div>
    <div ng-bind="model.fullName()"></div>

結果變成: fullName(), fullName$(), fullName$$(), fullName(), fullName$(), fullName(),驗證重算順序與ng-bind出現順序(亦等同Watcher註冊順序)有關。

最後,我們來觀察$digest()、$apply()、$eval()如何觸發重算?

在網頁新增六顆鈕,用jQuery click()事件分別執行$eval()、$eval("model.firstName")、$eval("model.fullName()")、$digest()、$apply(),第六顆鈕則加入ng-click事件但除了寫Log什麼都不做:

<body>
  <div ng-controller="mainCtrl">
    <input type="text" ng-model="model.firstName" />
    <input type="text" ng-model="model.lastName" />
    <div ng-bind="model.fullName()"></div>
    <div>
      <input type="button" value="$eval()" data-action="runEval" />
      <input type="button" value="$eval()-firstName" data-action="runEval,model.firstName" />
      <input type="button" value="$eval()-fullName" data-action="runEval,model.fullName()" />
      <input type="button" value="$digest" data-action="runDigest" />
      <input type="button" value="$apply"  data-action="runApply" />
      <input type="button" value="ng-click" ng-click="doNothing()"
    &lt;/div>
  </div>
  <script src="http://ajax.aspnetcdn.com/ajax/jQuery/jquery-1.9.1.js"></script>
  <script src="http://ajax.googleapis.com/ajax/libs/angularjs/1.2.14/angular.js"></script>
  <script>
    function log(m) {
      window.console && console.log(m);
    }
    var scope = null;
    
    angular.module("sampleApp", [])
    .controller("mainCtrl", function($scope) {
      function myViewModel() {
        var self = this;
        self.firstName="Jeffrey";
        self.lastName="Lee";
        var c = 0;
        self.fullName = function() {
          var fn = self.firstName + " " + self.lastName;
          log("fullName() calculated(" + (c++) + "): " + fn);
          return fn;
        };
      }
      $scope.doNothing = function() { 
        log("ng-clicked");
      };
      $scope.model = new myViewModel();
      scope = $scope;
    });
    $(":button").click(function() {
      var action = $(this).data("action");
      if (!action) return; //for ng-click button
      if (action == "runDigest") {
        log("Run $digest");
        scope.$digest();
      }
      else if (action == "runApply") {
        log("Run $apply");
        scope.$apply();
      }
      else if (action == "runEval") {
        log("Run $eval()");
        scope.$eval();
      }
      else {
        expr = action.split(',')[1];
        log("Run $eval(" + expr + ")");
        scope.$eval(expr);
      }
      
    });
  </script>
</body>

實際測試,各指令引發的fullName()計算次數如下:

  • $eval() 0次
  • $eval("model.firstName") 0次
  • $eval("model.fullName()") 1次
  • $digest() 1次
  • $apply() 1次
  • ng-click 1次

由結果可知,$digest()會重算一次,$eval()只有在涉及fullName()時才會觸發重算,$apply()等於$eval() + $digest()[請參見文件Pseudo-Code of $apply()一節],故重算次為1,有趣的是ng-click事件會被包在$apply()中,所以也會觸發一次重算。

由以上觀察,我學到一件事:

Angular不需宣告特殊Observable物件就能實現屬性相依重算,但背後需藉由反覆重跑$digest()以確定資料停止變動。每次資料異動後,相依的計算型屬性會連續執行兩次以上(通常是兩次,但最多可能到10次),這是Angular的設計機制使然。了解此一特性後,開發時需留意反覆執行的運算成本,避免在計算型屬性放入複雜運算邏輯(尤其要避免AJAX呼叫),否則將有損效能。

了解這點後,我開始懷念Knockout的ko.observable()、ko.computed(),雖然宣告時麻煩,但相依性的連動比Angular靠反覆檢查來得直覺有效,只能說每種做法各有優劣,每個決策背後都意味取捨,既然決定採用,就要熟悉你的工具的長處與短處,才能發揮最大的威力。

[NG系列]
http://www.darkthread.net/kolab/labs/default.aspx?m=post&t=angularjs

Comments

# by kcw

詳細的圖文解釋 謝謝!

# by 小黑

黑大 ~ 我對你的景仰如滔滔江水...

# by 流浪小風

如果使用$watch來實作呢? http://plnkr.co/edit/PGfs3NADPsg5Qvwnhjfx?p=preview

# by Jeffrey

to 流浪小風,我想改用$watch實作計算屬性可將重算時機降縮小到"值改變",會有正面幫助。而檢測資料的表示式(如你例中的[firstName, lastName])愈簡單愈好,對效能也會是正面的。謝謝補充。

# by kerrigan

想請問一下,就是我把暗黑大第一個範例http://jsbin.com/javaru/1/embed?console,output 裡面<span ng-bind="model.fullName()"></span>改成 {{model.fullName()}} 就變成跑三次,有點不知道為什麼...可以解惑一下嗎

# by Jeffrey

to kerrigan, 是指每次變更後會觸發三次重算?我修改成{{model.fullName()}},測試結果與ng-bind="model.fullName()"相同。範例: http://www.darkthread.net/photos/2525-7095-o.gif 莫非修改方式有所不同?

# by kerrigan

暗黑大,因為我一直都沒看到我的回文出現,我在回一次好了 我發現我使用你的範例修改 如果使用1.2.4版本就會按照你所解釋的運行兩次 如果我改用1.3.2就會運行三次 不太理解中間是有什麼底層原理變更..?

# by Jeffrey

to kerrigan, 依我觀察,換成1.3.2版後,除了每次按鍵產生兩次,焦點由<input>移開時,會再觸發第三次,猜想是底層的運作規則做了調整。

Post a comment