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

Posted F_AA

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了构建自己的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.digestcounter$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很应用程序代码沟通交流上。

以上是关于构建自己的AngularJS - 作用域和Digest的主要内容,如果未能解决你的问题,请参考以下文章

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

AngularJS-Scope作用域和控制器

javascript学习中自己对作用域和作用域链理解

详解Python的作用域和命名空间

变量,作用域和内存问题笔记

JS作用域和作用域链