放弃不稳定的Digest
在我们现在实现的代码中有一个明显的遗漏问题:如果有两个监视器相互监视彼此的变化那么会发生什么?换而言之,如果状态永远是不稳定会是什么样的情况?下面的代码将呈现这种情形:
test/scope_spec.js
it("gives up on the watches 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){ retrun scope.counterB; },
function(newValue,oldValue,scope){
scope.counterA++;
}
);
expect((function(){ scope.$digest(); })).toThrow();
});
我们期望scope.$digest抛出一个异常,但他并没有。事实上这个测试永远不会结束。这是因为这两个计数器相互依赖,以至于在每一次的迭代它们当中的$$digestOnce总有一个是dirty的。
注意我们并没有直接调用scope.$digest函数,而是使用Jasmine的excpect函数,它会替我们调用该函数,以便它能够检查是否抛出一个与我们预期一样的异常。
由于测试用例将会一直运行,你需要终止测试进程来结束测试用例并当我们修复这个问题后再重新运行。
我们需要做的就是给digest一个可循环的次数。如果超过这个次数scope还一直在在改变那么我们就退出循环并声明它可能永远不会结束,在这一点上我们可以会抛出一个异常,因为scope的这种状态可能并不是用户想要的。
最大数量的迭代就叫做TTL("Time To Live"的缩写)。默认设置为10。这个次数看起来可能有点小,但是考虑到这是影响性能的敏感区,因为digests的次数繁多而且每次digest都会运行所有监视函数。况且一个用户有超过10个监视器紧密链接的情况也是不太可能的。
实际上在Angular中TTL是可以调整的。当我们讨论到了providers和依赖注入时,我们将再进行详细说明。
让我们继续并添加一个循环计数器到我们的外部digest循环中。如果它到达TTL,我们将抛出一个异常:
src/scope.js
Scope.prototype.$digest = function(){
var ttl = 10;
var dirty;
do{
dirty = this.$$digestOnce();
if(dirty && !(ttl--)){
throw "10 digest iterations reached";
}
}while(dirty);
};
这次更新后再运行我们相互依赖的监视器的例子,会抛出一个如我们测试预计一样的异常。它应该为我们结束digest的运行。
Digest的短路(当最后一个监视器Clean时立即结束Digest)
在目前执行情况,我们不停的迭代监视器的集合直到我们在一次完整的循环里面所有的监视器都是clean(或者循环次数当到达TTL)。
因为我们可能在一次digest循环中拥有大量的监视器,尽可能少的执行它们就变得尤其重要。这就是为什么我们要为digest循环使用一个特定的优化。
假设在一个scope上有100个监视器。当我们digest这个scope,只有第一个监视器恰好是dirty的。那么这一个监视器使得整个digest循环变成了dirty的,而且我们不得不在做一次循环。在第二次循环中,没有监视器是dirty的然后digest结束。但是在这之前我们必须执行200个监视器!
为了使我们的执行次数减少一半,我们可以追踪在循环中最后为dirty的监视器,然后,每当我们遇见一个clean的监视器,我们就检查是否这个监视器就是我们之前所遇的那个dirty的监视器。如果是,这意味着在一整个循环中已经没有dirty的监视器了。在这种情况下当前这个循环结束后就没有必要在继续下一次了。我们可以立即退出。下面是一个表示这种情况的测试用例:
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);
});
我们首先把包含100个元素的数组放到scope的array属性中。然后我们附上100个监视器,每个监视数组中一个单独的元素。同时我们也添加一个本地递增变量,每当一个监视器执行时就执行自增操作,这样我们就可以跟踪监视器执行次数的总数。
然后为了初始化监视器,我们运行一次digest。在digest每个监视器将被运行两次。
然后我们改变数组中的第一项。如果短路优化已经生效,那么digest将会在第一个监视器在被执行第二次迭代时立即结束,最后的执行总数将是301而不是400。
如前面所说的,这种优化可以通过跟踪最后一个dirty的监视器来实现。让我们在Scope的构造器中为其添加一个字段:
src/scope.js
function Scope(){
this.$$watchers = [];
this.$$lastDirtyWatch = null;
}
现在,无论digest什么时候开始,让我们把这个字段设置为null:
src/scope.js
Scope.prototype.$digest = function(){
var ttl = 10;
var dirty;
this.$$lastDirtyWatch = null;
do{
dirty = this.$$digestOnce();
if(dirty && !(ttl--)){
throw "10 digest iterations reached";
}
} while (dirty);
};
在$$digestOnce中,无论我们何时遇到一个dirty的监视器,都把它分配当这个字段当中:
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中,无论何时我们碰到一个clean的监视器我们同样对它做和最后一个dirty的监视器同样的操作。让我们打断循环并返回一个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;
};
由于此时我们还没有遇到任何dirty的监视器,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因为我们检查到第一个监视器就是最后一个dirty的监视器,而现在它是clean的。让我们通过当一个监视器被添加时,禁用优化复位$$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团队才决定使用它。
现在,让我们把注意力转到我们怎样才能发现哪些东西发生了变化。