Angular中digest循环的主干是对于watchers的若干次遍历,直到整个scope中的数据”稳定”下来,这部分实现在这篇文章中已经进行了详尽的介绍。相关的一些细节优化也在这篇文章中进行了分析。
除了主干的内容,digest循环的内容其实还包括几个比较有趣的部分,比如这一节我们即将分析的$evalAsync
以及$applyAsync
,下面我们就来通过相关源代码来分析一下:
$digest: function() {
var watch, value, last, fn, get,
watchers,
length,
dirty, ttl = TTL,
next, current, target = this,
watchLog = [],
logIdx, logMsg, 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;
while (asyncQueue.length) {
try {
asyncTask = asyncQueue.shift();
asyncTask.scope.$eval(asyncTask.expression, asyncTask.locals);
} catch (e) {
$exceptionHandler(e);
}
lastDirtyWatch = null;
}
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();
while (postDigestQueue.length) {
try {
postDigestQueue.shift()();
} catch (e) {
$exceptionHandler(e);
}
}
}
以上就是digest方法的完整实现。有了前面的知识铺垫,我们再来阅读一下这段代码,看看是否会有新的收获。
L10-L20:
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();
}
首先,会使用beginPhase
方法将当前的状态标注为$digest
。
由于URL的变化可能和$digest
方法的调用同时发生,通过$browser
上定义的$$checkUrlChange
方法来检测一下是否发生了URL的变化:
self.$$checkUrlChange = fireUrlChange;
function fireUrlChange() {
if (lastBrowserUrl === self.url() && lastHistoryState === cachedState) {
return;
}
lastBrowserUrl = self.url();
lastHistoryState = cachedState;
forEach(urlChangeListeners, function(listener) {
listener(self.url(), cachedState);
});
}
如果URL没有发生变化那么立即返回。反之则会保存当前的URL和相关历史状态,同时调用当URL发生变化时注册过的监听器。
这部分的内容和我们这一节的内容关系并不大,以后我希望专门用一些篇幅来阐述,这里就不再深入下去。
好了,那么下面L14-L19是在做什么呢?
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();
}
这里出现了好多奇奇怪怪的东西。主要是这几个新概念:
1. $applyAsync
2. applyAsyncId
以及$browser.defer.cancel
3. flushApplyAsync()
这里出现了本节的主角之一$applyAsync
。
2和3则是围绕它的两个概念。
来看看它的实现:
$applyAsync: function(expr) {
var scope = this;
expr && applyAsyncQueue.push($applyAsyncExpression);
expr = $parse(expr);
scheduleApplyAsync();
function $applyAsyncExpression() {
scope.$eval(expr);
}
}
让我们看看这个方法的实现。首先,将传入的参数表达式通过闭包给包装到一个函数中,并将该函数置入到一个名为applyAsyncQueue
的数组中。解析表达式为Angular能够辨识的形式(通过$parse
服务)。最后调用scheduleApplyAsync
方法:
var applyAsyncId = null;
function scheduleApplyAsync() {
if (applyAsyncId === null) {
applyAsyncId = $browser.defer(function() {
$rootScope.$apply(flushApplyAsync);
});
}
}
需要明白的是,applyAsyncId
只是定义在$rootScope
上的一个变量而已。
在scheduleApplyAsync
方法中,会首先判断applyAsyncId
是否已经被定义了,如果没有定义的话会使用$browser.defer
来生成一个applyAsyncId
。
这里出现的$browser.defer
目前不打算分析它的代码。现在需要知道的是它只不过是浏览器环境下JavaScript原生函数setTimeout
的一层封装。而对应的$browser.defer.cancel(deferId)
用来取消由$browser.defer
定义的一个延迟执行任务。
很显然,需要调度的异步任务是:
$rootScope.$apply(flushApplyAsync);
// 下面是具体任务的定义
function flushApplyAsync() {
while (applyAsyncQueue.length) {
try {
applyAsyncQueue.shift()();
} catch (e) {
$exceptionHandler(e);
}
}
applyAsyncId = null;
}
具体执行的任务很好理解,从applyAsyncQueue
这个数组中依次拿出前面置入的函数并执行。最后将applyAsyncId
重置为空表明执行完毕。
初步了解了$applyAsync的实现后,再看看上面这段代码:
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();
}
正如同注释说明的那样,如果当前scope是$rootScope
并且定义了需要异步执行的任务的话,取消该任务并马上执行保存在applyAsyncQueue
这个数组中的每个表达式(通过flushApplyAsync
方法)。
这样做的原因也比较好理解,目前已经进入了一轮digest循环,这是执行之前定义的异步任务的一个合适契机。因为$apply
方法最终也会触发$digest
方法的执行,那么在这里直接执行就能够减少一次不必要的digest调用。
好了,跟第一个主角$applyAsync
打好招呼,下面这段代码我们会碰到第二个主角$evalAsync
:
在上面digest完整代码的L27-L35:
while (asyncQueue.length) {
try {
asyncTask = asyncQueue.shift();
asyncTask.scope.$eval(asyncTask.expression, asyncTask.locals);
} catch (e) {
$exceptionHandler(e);
}
lastDirtyWatch = null;
}
当代码运行到这里时,事实上已经进入了digest循环体。
它遍历asyncQueue
这个数组,并且通过$eval
方法依次执行定义在其中的表达式。
那么数组asyncQueue
是怎么什么时候被填充的呢?嗯,没错,就是在调用$evalAsync
时。
$evalAsync: function(expr, locals) {
// if we are outside of an $digest loop and this is the first time we are scheduling async
// task also schedule async auto-flush
if (!$rootScope.$$phase && !asyncQueue.length) {
$browser.defer(function() {
if (asyncQueue.length) {
$rootScope.$digest();
}
});
}
asyncQueue.push({scope: this, expression: $parse(expr), locals: locals});
}
如果当前不处于$digest
或者$apply
的过程中(只有在$apply
和$digest
方法中才会设置$$phase
这个字段),并且asyncQueue
数组中还不存在任务时,就会异步调度一轮digest循环来确保asyncQueue
数组中的表达式会被执行。
如果没有后面这个判断条件的话,每次调用$evalAsync
都会触发一次digest循环,那么会给整个应用造成不必要的负担。因为一次digest循环会执行掉asyncQueue
数组中所有的任务,而多次执行显然是没有意义的,添加的负担就是触发一轮watchers中watch
方法的遍历。而不可排出某些watch
方法可能会相当耗时(即时在watch
方法中执行耗时任务并不是一个好的实践)。
弄清楚了$evalAsync
的作用,那么使用它到底又有什么意义呢?在什么场合下需要使用它?
这就涉及到了一个问题,延迟执行的时机。我们知道,当我们需要在某个”晚一点”的时候执行一段代码的时候,我们会使用setTimeout
方法,或者在Angular环境中的$timeout
服务。但是它们的共通之处在于它们都依赖于浏览器的事件循环机制(Event Loop)。也就是说,在我们调用了setTimeout
或者$timeout
后,我们将何时执行这段延时代码的控制权交给了浏览器。可是我们的浏览器大哥可是很忙的,你以为你指定了timeout时间为100毫秒,大哥就一定会在100毫秒之后执行吗?这有一点不现实。如果事件循环中存在了一些耗时任务,那么你的任务的执行时间就完全不可控了。大哥可能在执行了一堆排在你的任务之前的任务后才会来执行你的任务。这个时候也许黄花菜都凉了。
而$evalAsync
就尝试解决这一问题。如果目前已经处于一轮digest循环中,那么它能够确保你定义的任务在本轮digest循环期间一定会被执行!因此,这个过程和浏览器就没有任何关系了,这样能够提高浏览器的渲染效率,因为无效的渲染被屏蔽了。关于$timeout
和$evalAsync
,在Stackoverlow上有比较好的一个总结,简单的翻译一下:
1. 如果在directive中使用$evalAsync
,那么它的运行时机在Angular对DOM进行操作之后,浏览器渲染之前。
2. 如果在controller中使用$evalAsync
,那么它的运行时机在Angular对DOM进行操作之前,同时也在浏览器渲染之前 - 很少需要这样做。
3. 如果通过$timeout
来异步执行代码,那么它的运行时机在Angular对DOM进行操作之后,也在浏览器渲染完毕之后(这也许会造成页面闪烁)。
如果当前不在一轮digest循环中,和$timeout就几乎没有区别了。因为它会通过下面的代码触发digest循环:
$browser.defer(function() {
if (asyncQueue.length) {
$rootScope.$digest();
}
});
而$browser.defer
等同于直接调用setTimeout
。
因此,我们可以记住一个结论:使用$evalAsync
的最佳场合是在指令的link
方法中。这样能够避免浏览器不必要的渲染而造成的页面闪烁。当你在directive中考虑使用$timeout
时,不妨试试$evalAsync
。
因为在digest循环中引入了对asyncQueue
数组的操作。循环的终止条件也需要相应作出调整:
...
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);
不能再以是否dirty作为循环的终止条件了。考虑一种极端情况,如果watcher的watch方法中不停的调用$evalAsync
,那么就会造成asyncQueue
数组永远无法被执行完。因此这种情况也会触发达到最大digest数的异常。
另外,在最外层的while循环条件中,也加入了asyncQueue.length
,只有当asyncQueue
数组中的所有任务都完成时,才能考虑推出digest循环。
最后,如果你细心,还会发现在digest方法的最后,digest循环体之外,还有一个while循环:
while (postDigestQueue.length) {
try {
postDigestQueue.shift()();
} catch (e) {
$exceptionHandler(e);
}
}
形式上和之前处理asyncQueue
数组挺相似的,看看在什么地方操作了postDigestQueue
数组就明白是啥意思了:
$$postDigest: function(fn) {
postDigestQueue.push(fn);
}
以上是scope定义的一个方法,按照Angular的代码规约,它实际上是一个private方法,因为它的前缀有两个$符号。那么它是用来干什么的呢?从该循环的位置可以得出判断:用于在digest循环后执行,因此也可以将$$postDigest
方法理解为一些callback的注册,这些callback会在digest循环完毕后被调用。尽管$$postDigest
方法是一个private方法,在确实有需求在digest循环后执行某些任务时,也是可以考虑使用的。
至此,digest循环的主体部分就介绍的差不多了。其实它还涉及到了一些其他的概念,比如:
1. scope的继承机制,因为digest循环会遍历整个scope树结构。
2. watcher的watch方法如何判断scope上的某个数据是否发生了变化,判断的方式因该数据的类型而异。关于这一点再前面的文章中已经简要叙述过了,以后有空会有专门的文章再深入探讨这个问题。