Angular雙向資料繫結原理探究。
文章原始碼引用較多,覺得難以理解可以直接跳到末尾總結處。
接觸過Angular的人一定會對其“雙向資料繫結”的特性印象深刻,而使用過的人更會對莫名其妙出現的雙向資料繫結失效的“坑”所困擾。例如下面一段程式碼:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 |
<!--ctrl控制器下引入com指令--> <body ng-app="app"> <div ng-controller="ctrl"> <input id="ipt" type="text" ng-model="value"> <button com>increase</button> <span id="span" ng-bind="value"></span> </div> </body> var app = angular.module("app", []) app.directive("com", function() { return function (scope, element) { element.on("click", function() { //修改scope.value模型的值,觀察檢視變化 scope.value="yalishizhude" //疑問1:執行結果怎麼是 "" ? console.log(document.getElementById('span').textContent) }); }; }); app.controller("ctrl", function($scope) { var e = angular.element(document.querySelector('#ipt')) setTimeout(function() { //修改檢視元素的值,觀察$scope.value模型的值變化 e.val('100') //疑問2:執行結果是 undefined ? console.log($scope.value) }, 1000) }); |
原始碼地址:http://jsbin.com/xogosim/edit?html,js,console,output
如果上面程式碼中的兩個問題你都知道答案,那麼你可以跳過下面的內容,如果並不完全清楚,那麼我們接著往下說~
雙向資料繫結,指的是檢視和模型之間的對映關係。雙向即 檢視 ==> 模型 和 模型 ==> 檢視 兩個方向。
我們以Angular1.3為例,探究一下這個問題。
檢視 ==> 模型
拋開Angular不說,如果我們要實現檢視修改時觸發模型的修改,很簡單,事件(鍵盤事件、滑鼠事件、UI事件)監聽就能實現。而Angular會不會也是這麼實現的?
最常用的場景便是表單元素的資料繫結,當元素的值發生變化時我們要通知模型層(比如校驗、聯動),例如用於實現這一功能的 ngModel
指令。
但是我們如果直接找到ngModel
的原始碼,並沒有找到直接的事件繫結,依賴ngModelOptions
指令倒是有一段程式碼繫結了事件
1 2 3 4 5 6 |
//第23769行 if (modelCtrl.$options && modelCtrl.$options.updateOn) { element.on(modelCtrl.$options.updateOn, function(ev) { modelCtrl.$$debounceViewValueCommit(ev && ev.type); }); } |
可是平常沒使用ngModelOptions
的時候也能同步元素的修改,難道是一開始就想錯了?
回憶一下Angular定義指令的時候,不光有像ngModel
這樣通過屬性定義,也有直接定義成元素的,例如form
就是一個指令。而最常用最簡單的就是把ngModel
用在input
元素上,不,應該是input
指令。
於是找到input指令的程式碼
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
//20436行 var inputDirective = ['$browser', '$sniffer', '$filter', '$parse', function($browser, $sniffer, $filter, $parse) { return { restrict: 'E', require: ['?ngModel'], link: { pre: function(scope, element, attr, ctrls) { if (ctrls[0]) { (inputType[lowercase(attr.type)] || inputType.text)(scope, element, attr, ctrls[0], $sniffer, $browser, $filter, $parse); } } } }; }]; |
發現只要nhgModel
指令存在的時候,它就會根據type屬性執行一段函式。
我們找到inputType.text
這個函式之後,層層追尋…
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 |
//19928行 if ($sniffer.hasEvent('input')) { element.on('input', listener); } else { var timeout; var deferListener = function(ev, input, origValue) { if (!timeout) { timeout = $browser.defer(function() { timeout = null; if (!input || input.value !== origValue) { listener(ev); } }); } }; element.on('keydown', function(event) { var key = event.keyCode; // ignore // command modifiers arrows if (key === 91 || (15 < key && key < 19) || (37 <= key && key <= 40)) return; deferListener(event, this, this.value); }); // if user modifies input value using context menu in IE, we need "paste" and "cut" events to catch it if ($sniffer.hasEvent('paste')) { element.on('paste cut', deferListener); } } // if user paste into input using mouse on older browser // or form autocomplete on newer browser, we need "change" event to catch it element.on('change', listener); |
終於找到了它在繫結事件的證據,而且還很智慧,根據瀏覽器對事件的支援情況來進行繫結。
發現繫結的事件都執行了一個函式:$setViewValue
。繼續查詢,發現呼叫ngModelSet
函式來修改模型。
模型 ==> 檢視
我們再次拋開Angular,回到原生實現,如果我們想要修改檢視也比較簡單,獲取dom元素並修改對應的屬性。
再找一個在Angular中將模型值同步到dom上的指令ngBind
。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
//20583行 var ngBindDirective = ['$compile', function($compile) { return { restrict: 'AC', compile: function ngBindCompile(templateElement) { $compile.$$addBindingClass(templateElement); return function ngBindLink(scope, element, attr) { $compile.$$addBindingInfo(element, attr.ngBind); element = element[0]; scope.$watch(attr.ngBind, function ngBindWatchAction(value) { element.textContent = value === undefined ? '' : value; }); }; } }; }]; |
發現其在scope.$watch
回撥函式中來修改dom元素的文字內容。那我們可以大膽地推測,應該是在修改了對應的$scope
屬性值之後,觸發了scope.$watch
呼叫了ngBindWatchAction
回撥函式才導致頁面元素文字變化的。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 |
//14000行 $watch: function(watchExp, listener, objectEquality) { var get = $parse(watchExp); if (get.$$watchDelegate) { return get.$$watchDelegate(this, listener, objectEquality, get); } var scope = this, array = scope.$$watchers, watcher = { fn: listener, last: initWatchVal, get: get, exp: watchExp, eq: !!objectEquality }; lastDirtyWatch = null; if (!isFunction(listener)) { watcher.fn = noop; } if (!array) { array = scope.$$watchers = []; } // we use unshift since we use a while loop in $digest for speed. // the while loop reads in reverse order. array.unshift(watcher); return function deregisterWatch() { arrayRemove(array, watcher); lastDirtyWatch = null; }; } |
從原始碼中可以看到,當我們在呼叫$watch
監控變數的時候,其實是建立了一個watcher
物件,並將其放入$scope.$$watchers
陣列中。
那麼誰會用到這個陣列,並且其中的回撥函式呢?
這個程式碼有點難找,直到找到一個叫做$digest
的函式定義。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 |
//14394行 do { // "traverse the scopes" loop if ((watchers = current.$$watchers)) { // process our watches length = watchers.length; while (length--) { try { watch = watchers[length]; // Most common watches are on primitives, in which case we can short // circuit it with === operator, only when === fails do we use .equals if (watch) { if ((value = watch.get(current)) !== (last = watch.last) && !(watch.eq ? equals(value, last) : (typeof value === 'number' && typeof last === 'number' && isNaN(value) && isNaN(last)))) { dirty = true; lastDirtyWatch = watch; watch.last = watch.eq ? copy(value, null) : value; watch.fn(value, ((last === initWatchVal) ? value : last), current); if (ttl < 5) { logIdx = 4 - ttl; if (!watchLog[logIdx]) watchLog[logIdx] = []; watchLog[logIdx].push({ msg: isFunction(watch.exp) ? 'fn: ' + (watch.exp.name || watch.exp.toString()) : watch.exp, newVal: value, oldVal: last }); } } else if (watch === lastDirtyWatch) { // If the most recently dirty watcher is now clean, short circuit since the remaining watchers // have already been tested. dirty = false; break traverseScopesLoop; } } } catch (e) { $exceptionHandler(e); } } } // Insanity Warning: scope depth-first traversal // yes, this code is a bit crazy, but it works and we have tests to prove it! // this piece should be kept in sync with the traversal in $broadcast if (!(next = (current.$$childHead || (current !== target && current.$$nextSibling)))) { while (current !== target && !(next = current.$$nextSibling)) { current = current.$parent; } } } while ((current = next)); |
簡單概括一下這段程式碼,遍歷$scope.$$watchers
,判斷如果需要檢測的表示式的值(可以理解為$scope的屬性)發生了修改,那麼執行對應回撥函式(比如ngBindg中的ngBindWatchAction)。
修改$scope對應的屬性,並呼叫$scope.$digest
。完成這兩個條件即可同步模型資料到檢視,修改dom元素。換句話說,這兩個條件缺一不可。而呼叫$scope.digest
這一過程,我們一般叫做髒值檢測。
有人可能會說我呼叫$scope.$apply
也可以啊~
理論上來說,用$scope.$digest
完成的手動試圖同步都可以用$scope.$apply
,但是他們之間還是有區別。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
//14666行 $apply: function(expr) { try { beginPhase('$apply'); return this.$eval(expr); } catch (e) { $exceptionHandler(e); } finally { clearPhase(); try { $rootScope.$digest(); } catch (e) { $exceptionHandler(e); throw e; } } } |
區別就在於,$apply是對$rootScope
及子作用域做髒值檢測,意味著效能消耗更大。支援回掉函式算是一個好處。
總結
檢視 ==事件繫結==> 模型
模型 <==髒值檢測== 模型
打賞支援我寫出更多好文章,謝謝!
打賞作者
打賞支援我寫出更多好文章,謝謝!
任選一種支付方式