为什么你的Angular双向数据绑定会失效?

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及子作用域做脏值检测,意味着性能消耗更大。支持回掉函数算是一个好处。

总结

视图 ==事件绑定==> 模型

模型 <==脏值检测== 模型


一部由众多技术专家推荐, 帮你成为具有全面能力和全局视野工程师的进阶利器—— 《了不起的JavaScript工程师》出版了! 点击下方链接即刻踏上进阶之路!


亚里士朱德 wechat
更多WEB技术分享请订阅微信公众号“WEB学习社”