AngularJS的双向绑定
AngularJS为我们提供了非常酷炫的双向绑定,意味着View
中任何数据的变化会自动的同步到Scope
当中,同样的scope
模型中数据的变化也会同步到View
上去。当我们写下表达式{{aModel}}
的时候,Angular会在幕后在我们的scope中设置一个watcher
,它用来监控数据的变化,达到更新view
的目的,这里的watcher和和我们在scope中设置的watcher是一样的。$watch
接收的第2个参数是一个回调函数,用于更新View
的操作,问题在于AngularJS是如何知道aModel
发生了变化,然后去调用对应的回调函数?这就需要用到AngularJS中的$digest循环
了。
$digest循环
在调用了$digest
函数之后,就会开始$digest循环
,假如我们在一个ng-click
指令的对应handle函数
中更改的了scope的某条数据,此时AngularJS就会自动的调用$digest函数
来触发一轮$digest循环
,$digest循环
开始之后会触发每一个watcher
,这些watchers
会检查scope中的当前model值是否和上一次计算的到的model值是否相同,不同的话对应的回调函数就会执行,然后在View中就会相应更新。除了ng-click指令,还有一些其他的内置指令、服务可以来更改models(比如ng-model
,$timeout
等)会自动触发一次$digest循环
。
但是AngularJS并不会直接调用$digest()
而是调用$scope.$apply()
,然后$scope.$apply()
会调用$rootScope.$digest()
,因此$digest循环
在$rootScope
开始,随后访问到所有的子scope
中的watchers。
$apply
$scope.$apply()
会自动调用$rootScope.$digest()
。$apply()
方法有两种形式:
1.接受一个 函数 作为参数,执行函数并触发一轮$digest循环
2.不接受任何参数,触发一轮$digest循环
。
什么时候需要手动调用$apply?
AngularJS只负责发生在AngularJS上下文环境中的变更做出自动地相应,也就是说在$apply()
方法中发生对于models的更改,angularJS的内置指令就是这么做的,所以任何的models的更改都会反应到view上面去。但是如果我们在AngularJS上下文以外的任何地方修改了model,那么就需要手动调用$apply()
来通知AngularJS。
比如我们使用了原生JavaScript中的setTimeout()
来更新scope模型中的数据,AngularJS是没办法知道我们更改了什么,这时候就必须我们手动调用$apply()
来主动触发一轮$digest循环
。
例如下面的Demo使用setTimeout()
更新scope模型中的数据
angular.module("myApp", []).controller("appController", ["$scope", function($scope) {
$scope.aModel = "Angular";
setTimeout(() => {
$scope.aModel = "changed";
console.log("after 2 seconds, and aModel is " + $scope.aModel);
}, 2000);
}]);
运行上面的Demo,在控制台上面,我们看到model已经更新了,但是View上面没有同步这个更新,原因就是因为在这里需要我们手动去调用$apply()
方法,修改如下:
angular.module("myApp", []).controller("appController", ["$scope", function($scope) {
$scope.aModel = "Angular";
setTimeout(() => {
$scope.$apply(() => {
$scope.aModel = "changed";
console.log("after 2 seconds, and aModel is " + $scope.aModel);
});
}, 2000);
}]);
运行上面的例子,可以看到model和view都更新了,因为我们把我们的代码放到了$apply()
中,它会自动帮我们调用$rootScope.$digest()
,从而触发watchers
并更新View
我们 也可以 使用第二种形式,也就是不带任何参数的apply,我们只需要在修改过后,手动调用$apply()
即可。但是官方更推荐 使用第一种形式,因为我们将function传入到$apply()中时,这个function会被塞到一个try...catch
中,一旦有异常发生,AngularJS会帮我们处理。
$apply是如何工作的?
贴出官方源码:
$apply: function(expr) {
try {
beginPhase('$apply');
try {
return this.$eval(expr);
} finally {
clearPhase();
}
} catch (e) {
$exceptionHandler(e);
} finally {
try {
$rootScope.$digest();
} catch (e) {
$exceptionHandler(e);
throw e;
}
}
}
首先从方法的定义上来看,$apply()
方法接收一个Angular的表达式
,然后通过一个beginPhase
方法设置当前阶段状态,紧接着通过$eval
方法将表达式解析为函数并返回。最后调用clearPhase
方法清理阶段状态,同时,$eval
方法的try代码块也对应了一个finally代码块。目的是保证最关键的digest循环
会被触发:$rootScope.$digest()
这上面不熟悉的三个方法就是:beginPhase
、 clearPhase
、$eval
beginPhase/clearPhase的实现
贴出官源码:
function beginPhase(phase) {
if ($rootScope.$$phase) {
throw $rootScopeMinErr('inprog', '{0} already in progress', $rootScope.$$phase);
}
$rootScope.$$phase = phase;
}
function clearPhase() {
$rootScope.$$phase = null;
}
我们在调用$apply()
时,$rootScope.$$phase
字段会被设置成 "$apply"
如果当$$phase
已经被设置为某个值了,Angular会直接抛出一个异常,所以一般我们不需要重复去调用$apply()
方法,当$apply()方法完成后,会调用clearPhase
方法 清除掉这个状态,方便下一次的调用。
$eval实现
贴出官方源码:
$eval: function(expr, locals) {
return $parse(expr)(this, locals);
}
其实$eval
方法是基于$parse
方法的,$parse
方法就是将一个表达式解析成为函数
angular.module("myApp",[]).controller("appController",["$scope", function($scope) {
$scope.a = 1;
$scope.b = 2;
console.log($scope.$eval('a+b'));
console.log($scope.$eval(function($scope){ return $scope.a + $scope.b;}));
}]);
两条语句都会输出3 ,说明除了表达式,也可以传入一个函数,那么$eval
的作用其实就是,给定一个表达式,使用当前scope对象作为上下文进行该表达式的求值。
脏检查
脏检查是由$digest
执行的,那么$digest
会执行多少次呢?假如我们正在执行一次$digest循环
,watchers被触发,然后检测model是否改变,如果改变了就会执行对应的listener
,但是如果listener
本身也会修改scope中的models
呢?所以其实Angular并不会只执行一次$digest循环
,在当前$digest循环
结束后,会再执行一次$digest循环
来检查models是否发生了变化。这其实就是脏检查,用于处理在listener函数被执行时可能引起的model变化,因此,$digest循环
会持续运行直到model不再发生变化,但是最多十次,所以尽可能地不要在listener函数中修改model。所以$digest循环
至少也会执行两次,即使在listener中没有改变models,他会再执行一次确保没有models更新。
$digest实现
$digest: function() {
var watch, value, last, fn, get,
watchers,
length,
dirty, ttl = TTL,
next, current, target = this,
watchLog = [],
logIdx, asyncTask;
beginPhase('$digest');
// Check for changes to browser url that happened in sync before the call to $digest
$browser.$$checkUrlChange();
if (this === $rootScope && applyAsyncId !== null) {
// If this is the root scope, and $applyAsync has scheduled a deferred $apply(), then
// cancel the scheduled $apply and flush the queue of expressions to be evaluated.
$browser.defer.cancel(applyAsyncId);
flushApplyAsync();
}
lastDirtyWatch = null;
do { // "while dirty" loop
dirty = false;
current = target;
// It's safe for asyncQueuePosition to be a local variable here because this loop can't
// be reentered recursively. Calling $digest from a function passed to $applyAsync would
// lead to a '$digest already in progress' error.
for (var asyncQueuePosition = 0; asyncQueuePosition < asyncQueue.length; asyncQueuePosition++) {
try {
asyncTask = asyncQueue[asyncQueuePosition];
asyncTask.scope.$eval(asyncTask.expression, asyncTask.locals);
} catch (e) {
$exceptionHandler(e);
}
lastDirtyWatch = null;
}
asyncQueue.length = 0;
traverseScopesLoop:
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) {
get = watch.get;
if ((value = 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;
fn = watch.fn;
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.$$watchersCount && current.$$childHead) ||
(current !== target && current.$$nextSibling)))) {
while (current !== target && !(next = current.$$nextSibling)) {
current = current.$parent;
}
}
} while ((current = next));
// `break traverseScopesLoop;` takes us to here
if ((dirty || asyncQueue.length) && !(ttl--)) {
clearPhase();
throw $rootScopeMinErr('infdig',
'{0} $digest() iterations reached. Aborting!\n' +
'Watchers fired in the last 5 iterations: {1}',
TTL, watchLog);
}
} while (dirty || asyncQueue.length);
clearPhase();
// postDigestQueuePosition isn't local here because this loop can be reentered recursively.
while (postDigestQueuePosition < postDigestQueue.length) {
try {
postDigestQueue[postDigestQueuePosition++]();
} catch (e) {
$exceptionHandler(e);
}
}
postDigestQueue.length = postDigestQueuePosition = 0;
}
简单分析:
最外层的循环:
do {
} while (dirty || asyncQueue.length);
当脏值不存在并且$digest
队列为0的时候终止循环
内层的循环:
traverseScopesLoop
这个循环体去遍历所有的scope,对于每个scope又会去遍历每一个watcher
然后$digest
获取获取每个监听对象的当前值
和旧值
,如果有变化,就让dirty = true
,然后双向更新。然后将新值作为旧值再存放。一次遍历之后,只要一个监听对象有变化,dirty都会被置为true,如果dirty为true,则重新遍历scope下的所有watchers,遍历之前把dirty置为false,直到dirty为false就停止遍历了。
自己写一个低配版的脏检查
根据$digest
的原理,我们可以书写一个自己的低配版的脏检查了。
设置scope
scope可以包含任意我们想要存储的对象,然后可以扩展scope的原型来实现$digest
和$watch
var Scope = function() {
this.$$watchers = [];
};
Scope.prototype.$watch = function(watchExp, listener) {
};
Scope.prototype.$digest = function() {
};
Scope中的$$watchers
用来存储所有的watchers
,然后$watch
接受两个参数,当我们的$watch
被调用时,就把它push
到Scope中的$$watchers
数组里面去。
var Scope = function() {
this.$$watchers = [];
};
Scope.prototype.$watch = function(watchExp, listener) {
this.$$watchers.push({
watchExp,
listener: listener || function() {}
});
};
Scope.prototype.$digest = function() {
};
然后实现$digest
,来检查新旧值是否相等,不相等,监听器就会被触发,然后一直循环,直到相等为止。
var Scope = function() {
this.$$watchers = [];
};
Scope.prototype.$watch = function(watchExp, listener) {
this.$$watchers.push({
watchExp,
listener: listener || function() {}
});
};
Scope.prototype.$digest = function() {
var dirty;
do {
dirty = false;
for(var i = 0 ; i <this.$$watchers.length; i++) {
var newVal = this.$$watchers[i].watchExp(),
oldVal = this.$$watchers[i].last;
if (newVal !== oldVal) {
this.$$watchers[i].listener(newVal, oldVal);
dirty = true;
this.$$watchers[i].last = newVal;
}
}
} while(dirty)
};
最后我们可以创建一个$scope实例进行测试
var $scope = new Scope();
$scope.name = 'Lan';
$scope.$watch(function(){
return $scope.name;
}, function( newValue, oldValue ) {
console.log(newValue, oldValue);
} );
$scope.$digest();
上面的代码会在控制台上输出如下内容:
Lan undefined
因为之前的$scope.name
是undefined
,现在的值是Lan
现在我们把$digest
函数绑定到一个input元素的keyup
事件上。这就意味着我们不需要自己去调用$digest
。这样就可以实现一个简单的双向数据绑定了。
var $scope = new Scope();
$scope.name = "Lan";
var element = document.querySelectorAll('input');
var nameElement = document.querySelectorAll('strong');
element[0].onkeyup = function() {
$scope.name = element[0].value;
$scope.$digest();
};
$scope.$watch(function() {
return $scope.name;
}, function(newVal, oldVal) {
nameElement[0].innerHTML = $scope.name;
console.log(newVal, oldVal);
});
效果如下: