Devil May Code...

Vergil's Blog

       在AngularJS的术语中,把检测模型变化的过程称为$digest循环。这个名字来源于Scope实例上的$digest方法。这种方法被作为$apply中的重要一步来调用,它会检测注册在所有作用域上的所有监视对象。

       为什么AngularJS中要有$digest循环呢?它又是如何判断模型发生变化的?$digest循环的存在是为了解决下面的两个问题。

  • 判定模型的哪些部分发生变化,以及DOM中的哪些属性应该被更新。这一步的目的是让检测模型变化的过程对开发者保持尽可能的简单。在开发时,我们只需要修改属性值,之后AngularJS指令会自动找出网页中哪些部分应该被重绘。
  • 减少不必要的重绘以提升应用性能,避免UI界面闪烁。为了实现这点,AngularJS会将DOM重绘尽可能推迟到最后一刻,此时模型趋于稳定(所有模型值都已完成运算,时刻准备驱动UI重绘)。

       要理解AngularJS如何实现这种效果,首先要明白web浏览器只有一个UI线程。浏览器中当然还有其他线程(如负责网络相关操作的线程),但只有一个线程用于渲染DOM元素、监视DOM事件,以及执行JavaScript代码。浏览器不停地在JavaScript执行环境和DOM渲染环境之间切换。

       AngularJS确保在将控制权交还给DOM渲染环境之前,所有的模型值都已完成运算且已“稳定”。这种方法保证了UI一次性完成重绘,而不会为了响应某个单独的模型变化而不停重绘。这保证了更快的执行速度(因为运行环境的切换很少)和更好的视觉效果(所有重绘一次完成)。每个单独的模型属性变化都出发一次UI重绘会让界面变得很慢,而且会出现明显的闪烁。

解剖$watch

       AngularJS使用脏检查(dirty checking)机制来判定某个模型值是否真正发生了变化。脏检查的工作机制是将之前保存的模型值和能导致模型发生变化的事件(DOM事件、XHR事件等)发生后计算的新模型值做对比。

       再来看一下,注册一个新的模型监视基本语法如下:

$scope.$watch(watchExpression, modelChangeCallback);

       当作用域添加一个新的$watch时,AngularJS会运算watchExpression表达式,然后在内部将运算所得的值存储起来。紧接着进入$digest循环,watchExpression会被再次运算,运算所得的新值会和之前保存的值进行对比。modelChangeCallback只会在新值与旧值不同时才会执行。这个新值也会被保存起来以备下一次对比使用,整个过程可以一直这样持续下去。

       作为开发者,我们对自己手动注册的监视(watches)很清楚。但我们还要明白任何指令(AngularJS的内置核心指令和任何第三方指令)都可以设置自己的监视。任何插值表达式({{wapression}})也会在作用域上注册一个新的监视。

模型的稳定性

       如果模型上的任何一个监视器都检测不到任何变化,AngularJS就认为该模型是稳定的(此时就可以进行UI渲染工作了)。只要一个监视器的一个变化,就足以使整个$digest循环变“脏”(dirty),迫使AngularJS进入下一轮循环。此时使用了“一颗老鼠屎坏了一锅汤”的原则,当然这么做是有充足的理由的。

       AngularJS会持续执行$digest循环,反复运算所有作用域上的所有监视,知道没有发现任何变化为止。连续几轮$digest循环是必要的,因为模型监视回调会有一些副作用。如果只是简单地设置一个回调,当见识的模型值变化时执行,那就可能改变我们已经运算过且认为是稳定的那些模型。

       我们来考虑一个简单的表单例子,该表单有两个字段Start和End。在这个表单中结束时间(End)应该永远晚于开始时间(Start)。

<div>
    <form>
        Start date: <input ng-model="startDate">
        End date: <input ng-model="endDate">
    </form>
</div>

       为了保证在模型中endDate永远晚于StartDate,可以注册一个监视,代码如下:

function oneDayAhead(dateToIncrement){
    return dateToIncrement.setDate(dateToIncrement.getDate() + 1);
}

$scope.$watch('startDate', function(newValue){
    if(newValue <= $scope.startDate) {
        $scope.endDate = oneDayAhead($scope.startDate);
    }
});

       在上面的监视中,我们让两个模型值之间产生一个依赖关系,其中一个模型的变化(startDate)可以触发另一个变化。这个例子就能说明模型变化回调的副作用,一个模型变化时可能会让另一个已被认为“稳定”的模型值也发生变化。

       对脏检查算法的深入理解,会让你明白一个watchExpression在每次$digest循环中都会被至少运算两次。可以通过创建下面代码来验证这一点:

<input ng-model="name">
{{getName()}}

       getName()是在作用域上的一个函数

$scope.getName = function(){
    console.log('dirty-checking');
    return $scope.name;
}

       如果运行上面的代码,注意观察控制台的输出,就会发现<input>的每次变化都会输出两条日志。

任何一个$digest循环至少都会运行一次,一般情况下会运行两次。这意味着每一个被监视的表达式在每个$digest循环中都会被运算两次(在浏览器离开JavaScript环境转向UI渲染之前)

不稳定的模型

有些情况下,$digest执行两次循环也不足以确定模型的稳定性。更糟糕的是,有可能会出现永远无法确定模型稳定性的模型的情况!我们来看下面的例子:

<span>Random Value: {{random()}}</span>

       random()函数定义如下:

$scope.random = Math.random;

       监视表达式等于Math.random(),这样会(极大可能)导致每次$digest循环运算所得的值都不一样。这表示每次检查的结果都是“脏”的,需要启动新一轮检查。这会导致一遍又一遍地循环,直到AngularJS认为这个模型是“不稳定”的,然后强制跳出$digest循环。

AngularJS默认最多会执行10次循环,之后就会声明该模型是不稳定的,然后中断$digest循环。

       在中断$digest之后,AngularJS会抛出一个错误(使用$exceptionHandler服务来处理,该服务用来在控制台记录错误)。抛出的错误中会包含5个最新的不稳定的监视信息(其中含有这些表达式的新值和旧值)。大多数情况下只会有一个不稳定的监视模型,所以很容易找出罪魁祸首。

       在$digest循环被中止之后,JavaScript线程就会离开“AngularJS世界”。此时,没有什么能够阻挡浏览器对渲染的向往。之后,用户就会看到使用$digest循环最后一次运行所得到的模型值渲染的页面。

即使$digest循环运行超过10次上限,页面还是会被渲染。在你观察控制台日志之前,这种错误很难定位,所以它可能会被忽略一段时间。即便如此,还是应该追踪并解决与不稳定模型有关的问题

$digest循环和作用域的层级

        每次$digest循环都会从$rootScope开始,重新计算所有作用域上的所有监视表达式。这一眼看上去有点违反直觉,有些人可能会说只要重新计算指定作用域及其子作用域上的表达式足矣。不幸的是,这样做会导致UI和模型不同步。原因就是子作用域上的变化可能会影响父作用域上的变。请看下面的例子,含有两个作用域(一个是$rootScope,另一个是ng-controller指令创建的):

<body ng-app ng-init="user = {name: 'Superhero'}">
    Name in parent: {{user.name}}
    <div ng-controller="ChildCtrl">
        Name in child; {{user.name}}
        <input ng-model="user.name">
    </div>
</body>

       模型(user.name)的变化是被子作用域(ng-controller)触发的,但是这个变化也改变了$rootScopr上对应的属性。

       这种情况迫使AngularJS次那个$rootScope开始逐级往下(使用深度优先遍历)运算所有的监视表达式。如果AngularJS只是运算某个作用域上(加上它的子作用域)的监视表达式,那么,一旦模型有变化,应用中就存在模型和实际显示不同步的风险,比如我们讨论的这个例子, Name in Parent:{{user.name}}中的插值表达式就不会被运算,显示结果也就不同步。

在每次$digest循环中,AngularJS都需要运算所有作用域上的所有监视表达式。这种过程从$rootScope开始,然后以深度优先便利所有子作用域。


摘自《精通AngularJS》 11.2理解AngularJS的内部运作机制


添加新评论 »

在这里输入你的评论...

Powered by Typecho.