理解$watch ,$apply 和 $digest --- 理解數據綁定過程

這篇博文主要是寫給新手的,是給那些剛剛開始接觸Angular,并且想了解數據幫定是如何工作的人。如果你已經對Angular比較了解了,那強烈建議你直接去閱讀源代碼。Angular用戶都想知道數據綁定是怎么實現的。你可能會看到各種各樣的詞匯:$watch,$apply,$digest,dirty-checking,它們是什么?它們是如何工作的呢?這里我想回答這些問題,其實它們在官方的文檔里都已經回答了,但是我還是想把它們結合在一起來講,但是我只是用一種簡單的方法來講解,如果要想了解技術細節,查看源代碼。

瀏覽器事件循環和Angular.js擴展

我們的瀏覽器一直在等待事件,比如用戶交互。假如你點擊一個按鈕或者在輸入框里輸入東西,事件的回調函數就會在javascript解釋器里執行,然后你就可以做任何DOM操作,等回調函數執行完畢時,瀏覽器就會相應地對DOM做出變化。 Angular拓展了這個事件循環,生成一個有時成為angular context的執行環境(記住,這是個重要的概念),為了解釋什么是context以及它如何工作,我們還需要解釋更多的概念。$watch 隊列($watch list)每次你綁定一些東西到你的UI上時你就會$watch隊列里插入一條$watch。想象一下$watch就是那個可以檢測它監視的model里時候有變化的東西。例如你有如下的代碼

index.html

User: <input type="text" ng-model="user" />Password: <input type="password" ng-model="pass" />
在這里我們有個$scope.user,他被綁定在了第一個輸入框上,還有個$scope.pass
,它被綁定在了第二個輸入框上,然后我們在$watch list里面加入兩個$watch:
controllers.js
app.controller('MainCtrl', function($scope) { $scope.foo = "Foo"; $scope.world = "World";});
index.html
Hello, {{ World }}

這里,即便我們在$scope上添加了兩個東西,但是只有一個綁定在了UI上,因此在這里只生成了一個$watch. 再看下面的例子:

  • controllers.js

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

  • index.html

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

這里又生成了多少個$watch呢?每個person有兩個(一個name,一個age),然后ng-repeat又有一個,因此10個person一共是(2 * 10) +1,也就是說有21個$watch
。 因此,每一個綁定到了UI上的數據都會生成一個$watch。對,那這寫$watch
是什么時候生成的呢? 當我們的模版加載完畢時,也就是在linking階段(Angular分為compile階段和linking階段---譯者注),Angular解釋器會尋找每個directive,然后生成每個需要的$watch。聽起來不錯哈,但是,然后呢?

$digest

循環(這個digest不知道怎么翻譯)
還記得我前面提到的擴展的事件循環嗎?當瀏覽器接收到可以被angular context處理的事件時,$digest循環就會觸發。這個循環是由兩個更小的循環組合起來的。一個處理evalAsync隊列,另一個處理$watch隊列,這個也是本篇博文的主題。 這個是處理什么的呢?$digest將會遍歷我們的$watch,然后詢問:

- 嘿,$watch
-  你的值是什么?是9。

- 好的,它改變過嗎?沒有,先生。

- (這個變量沒變過,那下一個)
你呢,你的值是多少?
- 報告,是Foo。

- 剛才改變過沒?改變過,剛才是Bar。

(很好,我們有DOM需要更新了)
 繼續詢問知道$watch
 隊列都檢查過。

這就是所謂的dirty-checking

既然所有的$watch都檢查完了,那就要問了:有沒有$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因為ng-click不生成$watch(函數是不會變的)。我們按下按鈕瀏覽器接收到一個事件,進入angular context(后面會解釋為什么)。
$digest循環開始執行,查詢每個$watch是否變化。由于監視$scope.name的$watch
報告了變化,它會強制再執行一次$digest循環。新的$digest循環沒有檢測到變化。
瀏覽器拿回控制權,更新與$scope.name新值相應部分的DOM。

這里很重要的(也是許多人的很蛋疼的地方)是每一個進入angular context
的事件都會執行一個$digest
循環,也就是說每次我們輸入一個字母循環都會檢查整個頁面的所有$watch。
通過$apply來進入angular context

誰決定什么事件進入angular context,而哪些又不進入呢?$apply!
如果當事件觸發時,你調用$apply,它會進入angular context,如果沒有調用就不會進入。現在你可能會問:剛才的例子里我也沒有調用$apply啊,為什么?Angular為了做了!因此你點擊帶有ng-click的元素時,時間就會被封裝到一個$apply調用。如果你有一個ng-model="foo"的輸入框,然后你敲一個f,事件就會這樣調用$apply("foo = 'f';")

Angular什么時候不會自動為我們$apply呢?
這是Angular新手共同的痛處。為什么我的jQuery不會更新我綁定的東西呢?因為jQuery沒有調用$apply,事件沒有進入angular context,$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;});

它將foo和bar
從controller里綁定到一個list里面,每次點擊這個元素的時候,foo和bar
都會自增1。那我們點擊元素的時候會發生什么呢?我們能看到更新嗎?答案是否定的。因為點擊事件是一個沒有封裝到$apply里面的常見的事件,這意味著我們會失去我們的計數嗎?不會真正的結果是:$scope確實改變了,但是沒有強制$digest
循環,監視foo 和bar的$watch沒有執行。也就是說如果我們自己執行一次$apply
那么這些$watch就會看見這些變化,然后根據需要更新DOM。
試試看吧:http://jsbin.com/opimat/2/
如果我們點擊這個directive(藍色區域),我們看不到任何變化,但是我們點擊按鈕時,點擊數就更新了。如剛才說的,在這個directive上點擊時我們不會觸發$digest
循環,但是當按鈕被點擊時,ng-click會調用$apply,然后就會執行$digest循環,于是所有的$watch都會被檢查,當然就包括我們的foo和bar的$watch了。
現在你在想那并不是你想要的,你想要的是點擊藍色區域的時候就更新點擊數。很簡單,執行一下$apply就可以了:
element.bind('click', function() { scope.foo++; scope.bar++; scope.$apply();});
$apply是我們的$scope(或者是direcvie里的link函數中的scope)的一個函數,調用它會強制一次$digest循環(除非當前正在執行循環,這種情況下會拋出一個異常,這是我們不需要在那里執行$apply的標志)。
試試看:http://jsbin.com/opimat/3/edit
有用啦!但是有一種更好的使用$apply的方法:
element.bind('click', function() { scope.$apply(function() { scope.foo++; scope.bar++; });})

有什么不一樣的?差別就是在第一個版本中,我們是在angular context
的外面更新的數據,如果有發生錯誤,Angular永遠不知道。很明顯在這個像個小玩具的例子里面不會出什么大錯,但是想象一下我們如果有個alert框顯示錯誤給用戶,然后我們有個第三方的庫進行一個網絡調用然后失敗了,如果我們不把它封裝進$apply里面,Angular永遠不會知道失敗了,alert框就永遠不會彈出來了。
因此,如果你想使用一個jQuery插件,并且要執行$digest循環來更新你的DOM的話,要確保你調用了$apply。
有時候我想多說一句的是有些人在不得不調用$apply時會“感覺不妙”,因為他們會覺得他們做錯了什么。其實不是這樣的,Angular不是什么魔術師,他也不知道第三方庫想要更新綁定的數據。使用$watch來監視你自己的東西你已經知道了我們設置的任何綁定都有一個它自己的$watch,當需要時更新DOM,但是我們如果要自定義自己的watches呢?簡單來看個例子:
app.js

app.controller('MainCtrl', function($scope) { $scope.name = "Angular"; $scope.updated = -1; $scope.$watch('name', function() { $scope.updated++; });});

index.html

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

這就是我們創造一個新的$watch的方法。第一個參數是一個字符串或者函數,在這里是只是一個字符串,就是我們要監視的變量的名字,在這里,$scope.name
(注意我們只需要用name)。第二個參數是當$watch說我監視的表達式發生變化后要執行的。我們要知道的第一件事就是當controller執行到這個$watch
時,它會立即執行一次,因此我們設置updated為-1。
試試看:http://jsbin.com/ucaxan/1/edit
例子2:
app.js

app.controller('MainCtrl', function($scope) { $scope.name = "Angular"; $scope.updated = 0; $scope.$watch('name', function(newValue, oldValue) { if (newValue === oldValue) { return; } // AKA first run $scope.updated++; });});

index.html

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

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

app.controller('MainCtrl', function($scope) { $scope.user = { name: "Fox" }; $scope.updated = 0; $scope.$watch('user', function(newValue, oldValue) { if (newValue === oldValue) { return; } $scope.updated++; });});

index.html

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

我們想要監視$scope.user對象里的任何變化,和以前一樣這里只是用一個對象來代替前面的字符串。
試試看:http://jsbin.com/ucaxan/3/edit
呃?沒用,為啥?因為$watch
默認是比較兩個對象所引用的是否相同,在例子1和2里面,每次更改$scope.name
都會創建一個新的基本變量,因此$watch會執行,因為對這個變量的引用已經改變了。在上面的例子里,我們在監視$scope.user,當我們改變$scope.user.name
時,對$scope.user的引用是不會改變的,我們只是每次創建了一個新的$scope.user.name,但是$scope.user永遠是一樣的。
例子4:
app.js

app.controller('MainCtrl', function($scope) { $scope.user = { name: "Fox" }; $scope.updated = 0; $scope.$watch('user', function(newValue, oldValue) { if (newValue === oldValue) { return; } $scope.updated++; }, true);});

index.html

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

試試看:http://jsbin.com/ucaxan/4/edit
現在有用了吧!因為我們對$watch加入了第三個參數,它是一個bool類型的參數,表示的是我們比較的是對象的值而不是引用。由于當我們更新$scope.user.name
時$scope.user也會改變,所以能夠正確觸發。
關于$watch還有很多tips&tricks,但是這些都是基礎。
總結
好吧,我希望你們已經學會了在Angular中數據綁定是如何工作的。我猜想你的第一印象是dirty-checking很慢,好吧,其實是不對的。它像閃電般快。但是,是的,如果你在一個模版里有2000-3000個watch,它會開始變慢。但是我覺得如果你達到這個數量級,就可以找個用戶體驗專家咨詢一下了
無論如何,隨著ECMAScript6的到來,在Angular未來的版本里我們將會有Object.observe那樣會極大改善$digest循環的速度。同時未來的文章也會涉及一些tips&tricks。
另一方面,這個主題并不容易,如果你發現我落下了什么重要的東西或者有什么東西完全錯了,請指正(原文是在GITHUB上PR 或報告issue

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 229,001評論 6 537
  • 序言:濱河連續發生了三起死亡事件,死亡現場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發現死者居然都...
    沈念sama閱讀 98,786評論 3 423
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 176,986評論 0 381
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 63,204評論 1 315
  • 正文 為了忘掉前任,我火速辦了婚禮,結果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 71,964評論 6 410
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發上,一...
    開封第一講書人閱讀 55,354評論 1 324
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 43,410評論 3 444
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 42,554評論 0 289
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當地人在樹林里發現了一具尸體,經...
    沈念sama閱讀 49,106評論 1 335
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 40,918評論 3 356
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發現自己被綠了。 大學時的朋友給我發了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 43,093評論 1 371
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 38,648評論 5 362
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質發生泄漏。R本人自食惡果不足惜,卻給世界環境...
    茶點故事閱讀 44,342評論 3 347
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 34,755評論 0 28
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 36,009評論 1 289
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 51,839評論 3 395
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 48,107評論 2 375

推薦閱讀更多精彩內容

  • 譯文地址:http://www.angularjs.cn/A0a6 英文原版地址:http://angular-t...
    Howie_一閱讀 200評論 0 1
  • 1.類庫( 提供類方法 ) 和框架 類庫提供一系列的函數和方法的合集,能夠加快你寫代碼的速度。但是主導邏輯的還是自...
    w_zhuan閱讀 1,808評論 0 8
  • 我不敢說好賭是一個好習慣,但是,生活處處皆賭倒是真的。 至于那黑壓壓,烏煙瘴氣的賭場,我倒也沒見過。不過,我們大可...
    銀色生命樹閱讀 319評論 2 1
  • 閱讀 平時我的閱讀量,一個月讀不完一本書。喜歡買書,沒能把一本書完全看完之后又去買,以至于家里囤積了很多書。這也是...
    小清新321521閱讀 210評論 0 0
  • 連著幾天的陰雨,今兒個居然出了太陽,天也意外的有點回暖,。大概因為明日就是中秋,整天單位的工作氛圍都很慵懶,大...
    迷荼塵埃閱讀 257評論 3 2