Angular双向数据绑定原理探究。
文章源码引用较多,觉得难以理解可以直接跳到末尾总结处。
接触过Angular的人一定会对其“双向数据绑定”的特性印象深刻,而使用过的人更会对莫名其妙出现的双向数据绑定失效的“坑”所困扰。例如下面一段代码:
<!--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
指令倒是有一段代码绑定了事件
//第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指令的代码
//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
这个函数之后,层层追寻...
//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
。
//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
回调函数才导致页面元素文本变化的。
//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
的函数定义。
//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
,但是他们之间还是有区别。
//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
及子作用域做脏值检测,意味着性能消耗更大。支持回掉函数算是一个好处。
视图 ==事件绑定==> 模型
模型 <==脏值检测== 模型
作者:亚里士朱德 博客:http://yalishizhude.github.io
跟作者一起成长请follow 订阅博客请star