1. 程式人生 > >理解$watch ,$apply 和 $digest --- 理解資料繫結過程

理解$watch ,$apply 和 $digest --- 理解資料繫結過程

這篇博文主要是寫給新手的,是給那些剛剛開始接觸Angular,並且想了解資料幫定是如何工作的人。如果你已經對Angular比較瞭解了,那強烈建議你直接去閱讀原始碼。

Angular使用者都想知道資料繫結是怎麼實現的。你可能會看到各種各樣的詞彙:$watch</code>,<code class="prettyline">$apply,$digest,dirty-checking… 它們是什麼?它們是如何工作的呢?這裡我想回答這些問題,其實它們在官方的文件裡都已經回答了,但是我還是想把它們結合在一起來講,但是我只是用一種簡單的方法來講解,如果要想了解技術細節,檢視原始碼。

讓我們從頭開始吧。

瀏覽器事件迴圈和Angular.js擴充套件

我們的瀏覽器一直在等待事件,比如使用者互動。假如你點選一個按鈕或者在輸入框裡輸入東西,事件的回撥函式就會在javascript直譯器裡執行,然後你就可以做任何DOM操作,等回撥函式執行完畢時,瀏覽器就會相應地對DOM做出變化。 Angular拓展了這個事件迴圈,生成一個有時成為angular context的執行環境(記住,這是個重要的概念),為了解釋什麼是context以及它如何工作,我們還需要解釋更多的概念。

watchwatch list)

每次你繫結一些東西到你的UI上時你就會往watch<

codeclass="prettyline">watch。想象一下$watch就是那個可以檢測它監視的model裡時候有變化的東西。例如你有如下的程式碼

index.html

User:<input type="text" ng-model="user"/>Password:<input type="password" ng-model="pass"/>

在這裡我們有個$scope.user</code>,他被繫結在了第一個輸入框上,還有個<code class="prettyline">$scope.pass,它被繫結在了第二個輸入框上,然後我們在$watch list</code>裡面加入兩個<code class="prettyline">$watch

:

controllers.js

app.controller('MainCtrl',function($scope){
  $scope.foo ="Foo";
  $scope.world ="World";});

index.html

Hello,{{World}}

這裡,即便我們在$scope</code>上添加了兩個東西,但是隻有一個繫結在了UI上,因此在這裡只生成了一個<code class="prettyline">$watch. 再看下面的例子: controllers.js

app.controller('MainCtrl',function($scope){
  $scope.people =[...];});

index.html

<ul><ling-repeat="person in people">{{person.name}} - {{person.age}}</li></ul>

這裡又生成了多少個$watch</code>呢?每個person有兩個(一個name,一個age),然後ng-repeat又有一個,因此10個person一共是<code class="prettyline">(2 * 10) +1</code>,也就是說有21個<code class="prettyline">$watch。 因此,每一個繫結到了UI上的資料都會生成一個$watch</code>。對,那這寫<code class="prettyline">$watch是什麼時候生成的呢? 當我們的模版載入完畢時,也就是在linking階段(Angular分為compile階段和linking階段—譯者注),Angular直譯器會尋找每個directive,然後生成每個需要的$watch。聽起來不錯哈,但是,然後呢?

$digest迴圈

還記得我前面提到的擴充套件的事件迴圈嗎?當瀏覽器接收到可以被angular context處理的事件時,$digest</code>迴圈就會觸發。這個迴圈是由兩個更小的迴圈組合起來的。一個處理<code class="prettyline">evalAsync</code>佇列,另一個處理<code class="prettyline">$watch佇列,這個也是本篇博文的主題。 這個是處理什麼的呢?$digest</code>將會遍歷我們的<code class="prettyline">$watch,然後詢問:

  • 嘿,$watch,你的值是什麼?
    • 是9。
  • 好的,它改變過嗎?
    • 沒有,先生。
  • (這個變數沒變過,那下一個)
  • 你呢,你的值是多少?
    • 報告,是Foo
  • 剛才改變過沒?
    • 改變過,剛才是Bar
  • (很好,我們有DOM需要更新了)
  • 繼續詢問知道$watch佇列都檢查過。

這就是所謂的dirty-checking。既然所有的$watch</code>都檢查完了,那就要問了:有沒有<code class="prettyline">$watch更新過?如果有至少一個更新過,這個迴圈就會再次觸發,直到所有的$watch都沒有變化。這樣就能夠保證每個model都已經不會再變化。記住如果迴圈超過10次的話,它將會丟擲一個異常,防止無限迴圈。 當$digest迴圈結束時,DOM相應地變化。

例如: controllers.js

app.controller('MainCtrl',function(){
  $scope.name ="Foo";

  $scope.changeFoo =function(){
      $scope.name ="Bar";}});

index.html

{{ name }}<button ng-click="changeFoo()">Change the name</button>

這裡我們有一個$watch</code>因為ng-click不生成<code class="prettyline">$watch(函式是不會變的)。

  • 我們按下按鈕
  • 瀏覽器接收到一個事件,進入angular context(後面會解釋為什麼)。
  • $digest</code>迴圈開始執行,查詢每個<code class="prettyline">$watch是否變化。
  • 由於監視$scope.name</code>的<code class="prettyline">$watch報告了變化,它會強制再執行一次$digest迴圈。
  • 新的$digest迴圈沒有檢測到變化。
  • 瀏覽器拿回控制權,更新與$scope.name新值相應部分的DOM。

這裡很重要的(也是許多人的很蛋疼的地方)是每一個進入angular context的事件都會執行一個$digest</code>迴圈,也就是說每次我們輸入一個字母迴圈都會檢查整個頁面的所有<code class="prettyline">$watch

通過$apply來進入angular context

誰決定什麼事件進入angular context,而哪些又不進入呢?$apply

如果當事件觸發時,你呼叫$apply</code>,它會進入<code class="prettyline">angular context</code>,如果沒有呼叫就不會進入。現在你可能會問:剛才的例子裡我也沒有呼叫<code class="prettyline">$apply啊,為什麼?Angular為了做了!因此你點選帶有ng-click的元素時,時間就會被封裝到一個$apply</code>呼叫。如果你有一個<code class="prettyline">ng-model="foo"</code>的輸入框,然後你敲一個<code class="prettyline">f</code>,事件就會這樣呼叫<code class="prettyline">$apply("foo = 'f';")

Angular什麼時候不會自動為我們$apply呢?

這是Angular新手共同的痛處。為什麼我的jQuery不會更新我繫結的東西呢?因為jQuery沒有呼叫$apply</code>,事件沒有進入<code class="prettyline">angular context</code>,<code class="prettyline">$digest迴圈永遠沒有執行。

我們來看一個有趣的例子:

假設我們有下面這個directive和controller

app.js

app.directive('clickable',function(){return{
  restrict:"E",
  scope:{
    foo:'=',
    bar:'='},template:'<ul style="background-color: lightblue"><li>{{foo}}</li><li>{{bar}}</li></ul>',
  link:function(scope, element, attrs){
    element.bind('click',function(){
      scope.foo++;
      scope.bar++;});}}});

app.controller('MainCtrl',function($scope){
  $scope.foo =0;
  $scope.bar =0;});

它將foobar從controller裡繫結到一個list裡面,每次點選這個元素的時候,foobar都會自增1。

那我們點選元素的時候會發生什麼呢?我們能看到更新嗎?答案是否定的。因為點選事件是一個沒有封裝到$apply裡面的常見的事件,這意味著我們會失去我們的計數嗎?不會

真正的結果是:$scope</code>確實改變了,但是沒有強制<code class="prettyline">$digest迴圈,監視foobar$watch</code>沒有執行。也就是說如果我們自己執行一次<code class="prettyline">$apply那麼這些$watch就會看見這些變化,然後根據需要更新DOM。

如果我們點選這個directive(藍色區域),我們看不到任何變化,但是我們點選按鈕時,點選數就更新了。如剛才說的,在這個directive上點選時我們不會觸發$digest</code>迴圈,但是當按鈕被點選時,ng-click會呼叫<code class="prettyline">$apply,然後就會執行$digest</code>迴圈,於是所有的<code class="prettyline">$watch都會被檢查,當然就包括我們的foobar$watch了。

現在你在想那並不是你想要的,你想要的是點選藍色區域的時候就更新點選數。很簡單,執行一下$apply就可以了:

element.bind('click',function(){
  scope.foo++;
  scope.bar++;

  scope.$apply();});

$apply</code>是我們的<code class="prettyline">$scope(或者是direcvie裡的link函式中的scope)的一個函式,呼叫它會強制一次$digest</code>迴圈(除非當前正在執行迴圈,這種情況下會丟擲一個異常,這是我們不需要在那裡執行<code class="prettyline">$apply的標誌)。

有用啦!但是有一種更好的使用$apply的方法:

element.bind('click',function(){
  scope.$apply(function(){
      scope.foo++;
      scope.bar++;});})

有什麼不一樣的?差別就是在第一個版本中,我們是在angular context的外面更新的資料,如果有發生錯誤,Angular永遠不知道。很明顯在這個像個小玩具的例子裡面不會出什麼大錯,但是想象一下我們如果有個alert框顯示錯誤給使用者,然後我們有個第三方的庫進行一個網路呼叫然後失敗了,如果我們不把它封裝進$apply裡面,Angular永遠不會知道失敗了,alert框就永遠不會彈出來了。

因此,如果你想使用一個jQuery外掛,並且要執行$digest</code>迴圈來更新你的DOM的話,要確保你呼叫了<code class="prettyline">$apply

有時候我想多說一句的是有些人在不得不呼叫$apply時會“感覺不妙”,因為他們會覺得他們做錯了什麼。其實不是這樣的,Angular不是什麼魔術師,他也不知道第三方庫想要更新繫結的資料。

使用$watch來監視你自己的東西

你已經知道了我們設定的任何繫結都有一個它自己的$watch,當需要時更新DOM,但是我們如果要自定義自己的watches呢?簡單

來看個例子:

app.js

app.controller('MainCtrl',function($scope){
  $scope.name ="Angular";

  $scope.updated =-1;

  $scope</span><span class="pun">.</span><span class="pln">$watch('name',function(){
    $scope.updated++;});});

index.html

<bodyng-controller="MainCtrl"><inputng-model="name"/>
  Name updated: {{updated}} times.
</body>

這就是我們創造一個新的$watch</code>的方法。第一個引數是一個字串或者函式,在這裡是只是一個字串,就是我們要監視的變數的名字,在這裡,<code class="prettyline">$scope.name(注意我們只需要用name)。第二個引數是當$watch</code>說我監視的表示式發生變化後要執行的。我們要知道的第一件事就是當controller執行到這個<code class="prettyline">$watch時,它會立即執行一次,因此我們設定updated為-1。

例子2:

app.js

app.controller('MainCtrl',function($scope){
  $scope.name ="Angular";

  $scope.updated =0;

  $scope</span><span class="pun">.</span><span class="pln">$watch('name',function(newValue, oldValue){if(newValue === oldValue){return;}// AKA first run
    $scope.updated++;});});

index.html

<bodyng-controller="MainCtrl"><inputng-model="name"/>
  Name updated: {{updated}} times.
</body>

watch的第二個引數接受兩個引數,新值和舊值。我們可以用他們來略過第一次的執行。通常你不需要略過第一次執行,但在這個例子裡面你是需要的。靈活點嘛少年。

例子3:

app.js

app.controller('MainCtrl',function($scope){
  $scope.user ={ name:"Fox"};

  $scope.updated =0;

  $scope</span><span class="pun">.</span><span class="pln">$watch('user',function(newValue, oldValue){if(newValue === oldValue){return;}
    $scope.updated++;});});

index.html

<bodyng-controller="MainCtrl"><inputng-model="user.name"/>
  Name updated: {{updated}} times.
</body>

我們想要監視$scope.user物件裡的任何變化,和以前一樣這裡只是用一個物件來代替前面的字串。

呃?沒用,為啥?因為$watch</code>預設是比較兩個物件所引用的是否相同,在例子1和2裡面,每次更改<code class="prettyline">$scope.name都會建立一個新的基本變數,因此$watch</code>會執行,因為對這個變數的引用已經改變了。在上面的例子裡,我們在監視<code class="prettyline">$scope.user,當我們改變$scope.user.name</code>時,對<code class="prettyline">$scope.user的引用是不會改變的,我們只是每次建立了一個新的$scope.user.name</code>,但是<code class="prettyline">$scope.user永遠是一樣的。

例子4:

app.js

app.controller('MainCtrl',function($scope){
  $scope.user ={ name:"Fox"};

  $scope.updated =0;

  $scope</span><span class="pun">.</span><span class="pln">$watch('user',function(newValue, oldValue){if(newValue === oldValue){return;}
    $scope.updated++;},true);});

index.html

<bodyng-controller="MainCtrl"><inputng-model="user.name"/>
  Name updated: {{updated}} times.
</body>

現在有用了吧!因為我們對$watch</code>加入了第三個引數,它是一個bool型別的引數,表示的是我們比較的是物件的值而不是引用。由於當我們更新<code class="prettyline">$scope.user.name$scope.user也會改變,所以能夠正確觸發。

關於$watch還有很多tips&tricks,但是這些都是基礎。

總結

好吧,我希望你們已經學會了在Angular中資料繫結是如何工作的。我猜想你的第一印象是dirty-checking很慢,好吧,其實是不對的。它像閃電般快。但是,是的,如果你在一個模版裡有2000-3000個watch,它會開始變慢。但是我覺得如果你達到這個數量級,就可以找個使用者體驗專家諮詢一下了

無論如何,隨著ECMAScript6的到來,在Angular未來的版本里我們將會有Object.observe那樣會極大改善$digest迴圈的速度。同時未來的文章也會涉及一些tips&tricks。

另一方面,這個主題並不容易,如果你發現我落下了什麼重要的東西或者有什麼東西完全錯了,請指正(原文是在GITHUB上PR 或報告issue