构建自己的AngularJS - 作用域和Digest(二)

作用域

第一章 作用域和Digest(二)

放弃一个不稳定的Digest

在我们当前的实现中有一个明显的遗漏:如果发生了两个监控函数互相监控对方的变化的情况会如何?也就是,万一状态永远不能稳定呢?就像下面的测试案例展示的情况:

test/scope_spec.js

it("gives up on the watchers after 10 iterations", function(){
    scope.counterA = 0;
    scope.counterB = 0;

    scope.$watch(
        function(scope) { return scope.counterA; },
        function(newValue, oldValue, scope){
            scope.counterB ++;
        });
    scope.$watch(
        function(scope) { return scope.counterB; },
        function(newValue, oldValue, scope){
            scope.counterA ++;
        });

    expect(function() { scope.$digest(); }).toThrow();

});

我们希望scope.digest抛出一个异常,但是他永远不会。事实上,这个测试永远不会完成。因为两个counter相互依赖对方,因此每一次$digestOnce中的迭代都会是脏的值。

注意到我们我们并没有直接调用scope.$digest 函数。而是给Jasmine的期望函数传递了一个函数。他会帮我们调用这个函数,并且会检查其是否像我们期望的那样抛出了一个异常。

既然测试永远停不下来,一旦我们修复了这个问题,你需要结束掉Testem进程,并重启。

我们需要做的是讲digest循环控制在可接受的循环次数内。如果在在这些循环次数后scope仍然发生变化,我们不得不举起我们的手声称状态很可能永远不会稳定下来。因此,我们可以抛出一个异常,因为scope的状态可能不是按照用户希望的那样发展。

循环的最大次数被称为TTL(“Time To Live”的缩写)。默认是10,这个数字可能看起来有点小,但是请铭记,这里是性能敏感区域,因为digest循环发生的非诚普遍并且每次循环都需要执行所有的监控函数。用户不太可能有超过10个的背靠背的监控链。

可以在Angular中改变TTL的数值。当我们讨论了provider和依赖注入了以后我们还会回到这里的。

让我们继续,在digest循环中添加一个循环计数器。如果达到了TTL,我们会抛出一个异常:

src/scope.js

Scope.prototype.$digest = function(){
    var tt1 = 10;
    var dirty;
    do {
        dirty = this.$$digestOnce();
        if(dirty && !(tt1 --)) {
            throw ‘10 digest iterations reached‘;
        }
    } while (dirty);
};

正如测试案例所希望的那样,该更新的版本让我们相互依赖的监控的例子抛出了一个异常。这保持了digest循环的正常。

当最后一个监控干净时短路digest循环

在当前的实现中,我们会保持监控数组被遍历知道我们发现了每个监控函数都没有变化(或者达到了TTL)。

既然在digest循环中可能有很大数量的监控函数,尽可能的减少他们执行的数量很重要。这就是我们要在digest循环上应用一个最优化的解决方案的原因。

考虑在作用域上有100个监控的情况,每次digest循环,这100个监控函数只有第一个变脏了。这一个监控让所有的监控都变脏了,我们不得不运行所有。在第二次循环中,没有监控变脏,digest结束了。但是在结束之前,我们不得不做了200次的监控函数的执行。

我们可以通过跟踪我们监控到最后一个变脏的函数来让我们的执行数量减半。然后,每次我们遇到一个干净的监控函数,我们检查他是否是上一个变脏的函数。如果是,这意味着一个循环已经结束并且没有函数变脏。在这种情况下,没有必要去运行剩下的循环。我们可以立即退出。这里有一个测试案例:

test/scope_spec.js


it(‘ends the digest when the last watch is clean‘, function(){
    scope.array = _.range(100);
    var watchExecutions = 0;

    _.times(100, function(i){
        scope.$watch(
            function(scope) {
                watchExecutions ++;
                return scope.array[i];
            },
            function(newValue, oldValue, scope){

            });
    });

    scope.$digest();
    expect(watchExecutions).toBe(200);

    scope.array[0] = 420;
    scope.$digest();
    expect(watchExecutions).toBe(301);
});

首先我们在scope上添加了一个长度为100的数组。然后我们添加了100个监控函数,每个监控函数监控数组中的一个值。我们还添加了一个局部变量,每次watch函数运行的时候自增,因此我们追踪到了watch执行的总次数。

然后我们运行一次digest,仅仅是初试话监控。在这次中,每个watch函数会运行两次。

然后我们改变数组的第一个元素的值。如果短路优化起作用了,这意味着在第二次遍历中,在遇到第一个监控函数时,digest函数会短路并立即停下来,是监控函数的运行的总次数为301而不是400.

正如上面提到的,通过记录最后一次变脏的监控函数,优化可以实现。让我们在Scope的构造函数中为其添加一个变量。

src/scope.js

function Scope() {
    this.$$watchers = [];
    this.$$lastDirtyWatch = null;
}

现在,每次digest开始时,将该变量设为null:

src/scope.js

Scope.prototype.$digest = function(){
    var tt1 = 10;
    var dirty;
    this.$$lastDirtyWatch = null;
	do {
		dirty = this.$$digestOnce();
        if(dirty && !(tt1 --)) {
            throw ‘10 digest iterations reached‘;
        }
    } while (dirty);
};

在$$digestOnce中,每次遇到一个变脏的监控函数,将其赋给该变量:

src/scope.js

Scope.prototype.$$digestOnce = function(){
	var self = this;
	var newValue, oldValue, dirty;
	_.forEach(this.$$watchers, function(watcher){
        newValue = watcher.watchFn(self);
        oldValue = watcher.last;
        if(newValue !== oldValue){

            self.$$lastDirtyWatch = watcher;

            watcher.last = newValue;
            watcher.listenerFn(newValue,
                (oldValue === initWatchVal ? newValue: oldValue),
                self);
            dirty = true;
        }
    });
    return dirty;
};

还是在$$digestOnce中,每次遇到了一个干净的监控函数并且它是我们上一次存储的变脏的监控,让我们立刻跳出循环,并且返回false让$digest知道他应该停止遍历。

src/scope.js

Scope.prototype.$$digestOnce = function(){
	var self = this;
	var newValue, oldValue, dirty;
	_.forEach(this.$$watchers, function(watcher){
        newValue = watcher.watchFn(self);
        oldValue = watcher.last;
        if(newValue !== oldValue){

            self.$$lastDirtyWatch = watcher;

            watcher.last = newValue;
            watcher.listenerFn(newValue,
                (oldValue === initWatchVal ? newValue: oldValue),
                self);
            dirty = true;
        }else if(self.$$lastDirtyWatch === watcher){
            return false;
        }
    });
    return dirty;
};

因为一个digest循环中我们没有发现变脏的监控函数,dirty的值是undefined,这个值将会作为函数的返回值。

显示的在_.forEach循环中返回false会导致LoDash短路循环并且立即退出。

优化已经起作用了。有一个角落需要测试案例覆盖,我们需要梳理在一个监听函数中添加另一个监控函数的情况:

test/scope_spec.js

it(‘does not end digest so that new watches are not run‘, function(){

    scope.aValue = ‘abc‘;
    scope.counter = 0;

    scope.$watch(
        function(scope) { return scope.aValue; },
        function(newValue, oldValue, scope){
            scope.$watch(
                function(scope) { return scope.aValue; },
                function(newValue, oldValue, scope){ scope.counter ++; });
        });

    scope.$digest();
    expect(scope.counter).toBe(1);

});

第二个监控函数没有被执行。原因是第二次digest遍历,在新的监控函数将要运行之前,我们结束了digest因为我们检测到了第一个watch函数(上一次的脏值)现在是干净的。通过添加watch函数的时候重置$$lastDirtyWatch的方式来纠正这个问题,同时能够有效的阻止优化。

src/scope.js

Scope.prototype.$watch = function(watchFn, listenerFn){
    var watcher = {
        watchFn: watchFn,
        listenerFn: listenerFn || function(){},
        last: initWatchVal
    };
    this.$$watchers.push(watcher);
	this.$$lastDirtyWatch = null;
};

现在我们的digest循环有潜力比之前快好多。在一个经典的应用中,该优化能够消除的遍历可能不会像我们的例子中的这样有效,但是平均情况下它执行的很好,所有Angular团队已经决定使用它。

现在,我们将注意力放到如何检查值发生变化上。

基于值的脏值检查

在之前,我们比较新值和旧值使用的是严格等于===操作符。在大多数情况下没问题,因为他检查所有的原始类型(字符串、数字等)的变化,同时检查所有的对象和数组的变化。但是还有一个地方是Angular能够chance发生变化的,那就是对象和数组的内部元素发生了变化。也就是说,你可以发现值的变化,而不仅仅是引用。

这种脏值检查是通过给$watch函数提供第三个可选的布尔值来激活的。当该标志为true时,使用的是基于值的检查机制。让我们添加一个测试案例:

test/scope_spec.js

it("compares based on value if enabled", function(){
    scope.aValue = [1, 2, 3];
    scope.counter = 0;

    scope.$watch(
        function(scope) { return scope.aValue; },
        function(newValue, oldValue, scope){
            scope.counter ++;
        },
        true);

    scope.$digest();
    expect(scope.counter).toBe(1);

    scope.aValue.push(4);
    scope.$digest();
    expect(scope.counter).toBe(2);

});

scope.aValue数组变化是,该测试案例的计数器自增。当我们在数组中增加一个值,我们希望数组被注意到发生变化,实际上并没有。scope.aValue还是同一个数组,现在只是内容发生了变化。

首先让我们重新定义$watch函数使其接受一个布尔值的标识,并将其存储在watcher中:

src/scope.js

Scope.prototype.$watch = function(watchFn, listenerFn, valueEq){
    var watcher = {
        watchFn: watchFn,
        listenerFn: listenerFn || function(){},
        valueEq: !!valueEq,
        last: initWatchVal
    };
    this.$$watchers.push(watcher);
	this.$$lastDirtyWatch = null;
};

我们所做的是给watcher添加一个标识,通过使用双重否定(!!)将其强制转换成布尔类型。当一个用户没有使用第三个参数调用$watch时,valueEq会是undefined,在watcher对象中会变成false

基于值的脏值检查意味着如果新值或者旧值是一个对象或者数组的话,我们需要遍历里面包含的所有内容。如果两个值之前有任何不同的话,监控函数是脏的。如果对象里面有其他的对象或者数组嵌套的话,同样需要递归比较其值。

Angular使用了他自己的检查函数,但是我们将要使用Lo-Dash提供的方法来代替,因为在这点上他满足了我们所有的需求。

src/scope.js

Scope.prototype.$$areEqual = function(newValue, oldValue, valueEq){
    if(valueEq){
        return _.isEqual(newValue, oldValue);
    }else {
        return newValue === oldValue;
    }
}

为了观察值的变化,我们还需要去改变我们对每个watcher存储旧值的方式。仅仅存储旧值的一个引用是不够的,因为每一次值的改变会被应用到我们存储的当前值的引用上。我们将永远得不到值的变化,因为$$areEqual一直得到的是同一个值的两个引用。因为这个原因我们需要深拷贝这个值并且保存该拷贝。

和相等检查一样,Angular有他自己的深拷贝函数,但是现在我们要使用Lo-Dash提供的函数

让我们用新的$$areEqual方法来更新一下$$areEqual函数,如果需要的话也可以拷贝上一次引用:

src/scope.js

Scope.prototype.$$digestOnce = function(){
	var self = this;
	var newValue, oldValue, dirty;
	_.forEach(this.$$watchers, function(watcher){
		newValue = watcher.watchFn(self);
		oldValue = watcher.last;
		if(!(self.$$areEqual(newValue, oldValue, watcher.valueEq))){

            self.$$lastDirtyWatch = watcher;

            watcher.last = watcher.valueEq ? _.cloneDeep(newValue) : newValue;
            watcher.listenerFn(newValue,
                (oldValue === initWatchVal ? newValue: oldValue),
                self);
            dirty = true;
        }else if(self.$$lastDirtyWatch === watcher){
            return false;
        }
    });
    return dirty;
};

现在我们的代码支持两种方式的相等检查,并且单元测试通过了。

相比于检查引用,基于值的检查明显的更复杂。有时会更加复杂,遍历一个嵌套的数据结构需要花费很多时间,存储他的一个深拷贝同样需要很多内容。这就是Angular默认不做基于值的脏值检查的原因。你需要明确的设置该标志去启用他。

Angular还提供第三种脏值检查机制:集合检查。我们将会在第三章实现:

在我们完成值比较值之前,有一个javscript的怪癖我们需要去处理。

NaNs

在Javascript中,NaN (Not-a-Number) 不等于他自身。这可能听起来很奇怪,但他就是这样的。如果在脏值检查的函数中我们没有明显的处理NaN,一个监控NaN的函数将永远是脏的。

在这个问题上,因为基于值的脏值检查通过Lo-Dash的isEqual函数已经解决了。对于基于引用的检查我们需要自己去处理。这可以通过下面的这个测试来说明:

test/scope_spec.js

it("correct handle the NaNs", function(){
    scope.number = 0/0;
    scope.counter = 0;

    scope.$watch(
        function(scope) { return scope.number; },
        function(newValue, oldValue, scope){
            scope.counter ++;
        });

    scope.$digest();
    expect(scope.counter).toBe(1);

    scope.$digest();
    expect(scope.counter).toBe(1);

});

我们监控的值正好是NaN,每当它发生变化的时候,计数器自增。我们希望在第一次$digest之后,counter的值立即自增,之后保持不变。然而,当我们运行该测试案例是,我们遇到了循环“达到TTL”的异常。作用域没有达到稳定的状态,因为NaN被认为和上一次的值不相等。

让我们通过调整$$areEqual函数来修改这个问题:

src/scope.js

Scope.prototype.$$areEqual = function(newValue, oldValue, valueEq){
    if(valueEq){
        return _.isEqual(newValue, oldValue);
    }else {
        return newValue === oldValue||
            (typeof newValue ===  ‘number‘  && typeof oldValue ===  ‘number‘  &&
                   isNaN(newValue) && isNaN(oldValue));
    }
};

现在监控NaN也能够正常的运行了。

基于值的检查已经实现了,我们把我们的注意力转移到scope很应用程序代码沟通交流上。

时间: 2024-12-30 04:53:13

构建自己的AngularJS - 作用域和Digest(二)的相关文章

构建自己的AngularJS - 作用域和Digest(一)

作用域 第一章 作用域和Digest(一) Angular作用域是简单javascript对象,因此你可以像对其他对象一样添加属性.然而,他们也有一些额外的功能,用于观测数据结构的变化.这种观察能力是使用脏值检查在digest循环中运行来实现的.这就是我们这一章将要实现的内容. 作用域对象 Scope的创建是通过在Scope构造函数之前加入new关键字来创建的.这样会产生一个简单javascript对象.让我们先来创建一个单元测试.(测试驱动开发,先写测试案例) 对Scope创建一个测试文件te

构建自己的AngularJS - 作用域和Digest(三)

作用域 第一章 作用域和Digest(三) $eval - 在当前作用域的上下文中执行代码 Angular有多种方式让你在当前作用域的上下文中执行代码.最简单的是$eval.传入一个函数当做其参数,然后将当前的作用域作为参数传给该函数,并执行它.然后它返回该函数的执行结果.$eval还有第二个可选的参数,它仅仅是被传递给将要执行的函数. 有几个单元测试展示了我们如何使用$eval: test/scope_spec.js it("execute $eval'ed function and retu

创建自己的AngularJS - 作用域和Digest(五)

作用域 第一章 作用域和Digest(五) 销毁监控 当你注册一个监控,很多时候你想让它和scope一样保持活跃的状态,所以不必显示的删除他.然而,有些情况下,你需要销毁一个特定的监控,但是仍然保持作用域可操作.意思就是,我们需要给监控增加一个删除操作. Angular实现这个的方式特别聪明:Angular中的$watch函数有一个返回值.他是一个函数,但其被调用的时候,即删除了其注册的监控.如果想要能够移除一个监控,只要存储注册监控函数时返回的函数,然后当不需要监控的时候调用它即可: test

创建自己的AngularJS - 作用域和Digest(四)

作用域 第一章 作用域和Digest(四) 联合$apply调用 - $applyAsync 不论在digest里面还是外面调用$evalAsync去延迟工作,他实际是为之前的使用案例设计的.之所以在setTimeout中调用digest是为了在digest循环外面调用$evalAsync时防止混淆. 针对在digest循环外部异步调用$apply的情况,同样有一个名为$applyAsync来处理.其使用类似于$apply - 为了集成没有意识到Angular digest循环的代码.和$app

构建自己的AngularJS,第一部分:作用域和digest 转摘:http://www.ituring.com.cn/article/39865

构建自己的AngularJS,第一部分:Scope和Digest 原文链接:http://teropa.info/blog/2013/11/03/make-your-own-angular-part-1-scopes-and-digest.html Angular是一个成熟和强大的JavaScript框架.它也是一个比较庞大的框架,在熟练掌握之前,需要领会它提出的很多新概念.很多Web开发人员涌向Angular,有不少人面临同样的障碍.Digest到底是怎么做的?定义一个指令(directive

AngularJS作用域

AngularJS作用域 一.概要 在AngularJS中,子作用域(child scope)基本上都要继承自父作用域(parent scope). 但,事无绝对,也有特例,那就是指令中scope设置项为对象时,即scope:{…},这将会让指令创建一个并不继承自父作用域的子作用域,我们称之为隔离作用域(isolated scope). 指令中的scope一共可以有三个值,下面我们再来温习下: 指令之scope scope: false 默认值,指令不会新建一个作用域,使用父级作用域. scop

用angularjs开发下一代web应用(二):angularjs应用骨架(二)

1.浅谈非入侵式JavaScript <div ng-click="doSomething()">...</div>这些指令和原来的事件处理器有以下不同之处: 在所有浏览器中具有相同的行为.Angular将会帮你屏蔽差异性. 不会在全局命名空间中进行操作.你所指定的表达式只能访问元素控制器作用域范围内的函数和数据. 2.列表.表格以及其他迭代型元素         ng-repeat可能是最有用的Angular指令了,它可以根据集合中的项目一次创建一组元素的多份

angularJS 作用域

<!doctype html><html ng-app="firstApp"><head> <meta charset="utf-8"> <script src="angular-1.3.0.js"></script></head><body><pre> </pre><div ng-controller="par

4.AngularJS四大特征之二: 双向数据绑定

AngularJS四大特征之二: 双向数据绑定 (1)方向一:把Model数据绑定到View上--此后不论何时只要Model发生了改变,则View中的呈现会立即随之改变!实现方法: {{ }}.ngBind.ngRepeat.ngIf.ngSrc.ngStyle...都实现了方向1的绑定. (2)方向二:把View(表单控件)中修改绑定到Model上--此后不论任何时候,只要View中的数据一修改,Model中的数据会自动随之修改.实现方法:  只有ngModel指令. 提示:可以使用$scop