[AngularJS面面观] 5. scope中的两个异步方法 - $applyAsync以及$evalAsync

时间:2021-05-03 18:36:31

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是如何实现的,有必要思考一下应该在什么场景下使用$applyAsync呢?或者说,Angular在设计这个方法之处是出于什么考虑呢?源代码中该方法的注释说明如下:

Schedule the invocation of $apply to occur at a later time. The actual time difference varies across browsers, but is typically around ~10 milliseconds. This can be used to queue up multiple expressions which need to be evaluated in the same digest.

简单翻译一下:让$apply晚一些运行,实际晚的时间根据浏览器的不同而有所不同,但是一般在10毫秒左右。可以用它将多个表达式的任务排队,从而让它们都在同一个digest循环中执行。

看上去有那么一点点道理,但还是有一些不明就里的感觉。但是下面这个例子就让它的用途清晰起来了。我们知道在Angular中发起AJAX请求一般通过$http服务,在得到来自后端的响应之后,它也会触发一轮digest循环。这也就是说,如果同时发起了10个AJAX请求,那么最终会触发10轮digest循环。而如果这10个AJAX请求并不是那么耗时,它们返回的速度很快,这就会造成10轮digest循环依次被触发,而很显然没有必要这么密集地触发digest循环。

所以在$http服务对应的配置对象$httpProvider中,有一个方法:useApplyAsync([value])。它的简要说明如下:

Configure $http service to combine processing of multiple http responses received at around the same time via $rootScope.$applyAsync. This can result in significant performance improvement for bigger applications that make many HTTP requests concurrently (common during application bootstrap).

配置$http服务使用$rootScope.$applyAsync来合并处理几乎在相同时间得到的http响应。对于同时发起很多HTTP请求(一般在应用启动阶段)的大型应用,能够大幅提高性能。

所以,答案就比较清晰了。如果在你的应用中,经常同时调用很多个AJAX请求,那么可以考虑配置:

$httpProvider.useApplyAsync(true);

好了,跟第一个主角$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上的某个数据是否发生了变化,判断的方式因该数据的类型而异。关于这一点再前面的文章中已经简要叙述过了,以后有空会有专门的文章再深入探讨这个问题。