作用域
作用域继承(一)
Angular作用域继承机制直接建立在Javascript原型继承基础上,并在其根部加入了一些内容。这意味着当你理解了Javascript原型链后,将对Angular作用域继承有深入了解。
根作用域
到目前为止,我们一直在和一个作用域对象打交道,该作用域使用Scope
构造函数创建:
var scope = new Scope();
根作用域就是这样创建的。之所以称之为根作用域,是因为他没有父作用域,它是典型的一个由所有作用域组成的树的根。
在现实中,你永远不用用这种方式来创建一个作用域。在Angular应用中,只用一个根作用域(可以通过注入$rootScope
取到它)。其他所有的作用域都是其后代,为控制器和指令创建。
创建一个子作用域
可以通过在当前作用域上调用一个名为$new
的函数来创建一个子作用域。先来添加几个测试案例:
test/scope_spec.js
it("inherits the parent‘s properties", function() {
var parent = new Scope();
parent.aValue = [1, 2, 3];
var child = parent.$new();
expect(child.aValue).toEqual([1, 2, 3]);
});
这件事反过来不正确。一个定义在子作用域上的属性不存在于其父作用域上:
test/scope_spec.js
it("does not cause a parent to inherit its properties", function() {
var parent = new Scope();
var child = parent.$new();
child.aValue = [1, 2, 3];
expect(parent.aValue).toBeUndefined();
});
和父作用域共享属性和该属性定义的时间无关。当有一个父作用域的属性被定义,所有存在的子作用域都能获取到该属性:
test/scope_spec.js
it("inherits the parent‘s properties whenever they are defined", function(){
var parent = new Scope();
var child = parent.$new();
parent.aValue = [1, 2, 3];
expect(child.aValue).toEqual([1, 2, 3]);
});
你也可以通过子作用域操作父作用域中的属性,因为他们只想同一个值:
test/scope_spec.js
it("can manipulate a parent scope‘s property", function(){
var parent = new Scope();
var child = parent.$new();
parent.aValue = [1, 2, 3];
child.aValue.push(4);
expect(child.aValue).toEqual([1, 2, 3, 4]);
expect(parent.aValue).toEqual([1, 2, 3, 4]);
});
你同样可以在子作用域中监控父作用域的属性:
test/scope_spec.js
it("can watch a property in the parent", function(){
var parent = new Scope();
var child = parent.$new();
parent.aValue = [1, 2, 3];
child.counter = 0;
child.$watch(
function(scope) { return scope.aValue;},
function(newValue, oldValue, scope){
scope.counter ++;
},
true
);
child.$digest();
expect(child.counter).toBe(1);
parent.aValue.push(4);
child.$digest();
expect(child.counter).toBe(2);
});
你可以已经注意到了,子作用域也有我们定义在
Scope.prototype
上的$watch
函数,这和我们自定义的属性是一样的继承机制:因为父作用域继承了Scope.prototype
,同时子作用域又继承了父作用域,所以所有作用域都能够获取到Scope.prototype
。
最终,上面所讨论的对于任意深度的作用域层次都适用:
test/scope_spec.js
it("can be nested at any depth", function(){
var a = new Scope();
var aa = a.$new();
var aaa = aa.$new();
var aab = aa.$new();
var ab = a.$new();
var abb = ab.$new();
a.value = 1;
expect(aa.value).toBe(1);
expect(aaa.value).toBe(1);
expect(aab.value).toBe(1);
expect(ab.value).toBe(1);
expect(abb.value).toBe(1);
ab.anotherValue = 2;
expect(abb.anotherValue).toBe(2);
expect(aa.anotherValue).toBeUndefined();
expect(aaa.anotherValue).toBeUndefined();
});
到目前我们指定的所有内容,实现起来特别直白。我们只需要用到Javascript对象继承,因为Angular作用域有意的使用Javascript的工作机制。基本上,每当你创建了一个子作用域,其父作用域充当了他的原型。
让我们在Scope的原型上创建$new
函数。它为当前的作用域创建子作用域,并将其返回:
src/scope.js
Scope.prototype.$new = function(){
var ChildScope = function() {};
ChildScope.prototype = this;
var child = new ChildScope();
return child;
};
在该函数中我们首先为子作用域创建了一个构造函数,并将其当做局部变量。该构造函数不需要做任何操作,所以我们仅仅实现了一个空函数。然后我们将当前Scope
赋值给ChildScope
的原型。最终我们使用ChildScope
构造并返回了一个新的对象。
属性遮蔽
scope继承有一个方面经常误导Angular新手,那就是遮蔽某些属性。然而这是使用Javascript原型链的直接后果。
从我们当前的测试案例可以看到我们都是在读作用域上的某个属性,这样会查找原型链,如果在当前的作用域上没有找到该属性,会在其父作用域上继续查找。然后,当你在一个作用域上写某个属性时,该属性只在当前作用域上存在,在父作用域上不存在。
test/scope_spec.js
it("shadows a parent‘s property with the same name", function(){
var parent = new Scope();
var child = parent.$new();
parent.name = ‘Joe‘;
child.name = ‘Jill‘;
expect(parent.name).toBe("Joe");
expect(child.name).toBe("Jill");
});
当我们给子作用域分配了一个已经在父作用域上存在的属性,他并不会改变父作用域。实际上,在作用域链上我们有两个不同的属性,它俩都叫name
。这通常被描述为遮盖:从子作用域的角度,父作用域的name
属性被子作用域上的name
属性遮盖了。
这就是困惑的根源,当然也有真正的修改父作用域的使用案例。为了绕过这条规则,一个经常使用的模式是用一个对象来包装属性。该对象里的内容是可以被改变的(和上一章节中的数组操作的例子类似):
test/scope_spec.js
it("dose not shadow members of parent scope‘s attributes", function(){
var parent = new Scope();
var child = parent.$new();
parent.user = {name: ‘Joe‘};
child.user.name = ‘Jill‘;
expect(child.user.name).toBe(‘Jill‘);
expect(parent.user.name).toBe(‘Jill‘);
});
这能工作的原因是,我们没有给子作用域附加任何属性。我们只是读了该作用域上user
的属性,并在这个对象上添加了一些内容。这两个作用域都有一个引用指向了同一个user
对象,该对象是一个简单JavaScript对象,并且和作用域继承没有任何关系。
这种模式也成为点原则,指的是对于能够使作用域发生改变的表达式,你应该在获取属性的时候使用
.
操作符。正如Mi?ko Hevery所说,“不论你什么时候使用ngModel,在里面的某处一定有一个点。如果没有,那么你做错了。”
分离监控
我们已经看到我们能够在子作用域上添加监控,因为子作用域继承了父作用域的所有方法,包括$watch
和$digest
。但是监控到底被存储在哪里?被执行的是哪个作用域上的监控?
在我们当前的实现中,实际上所有的监控都被存储在根作用域上。这是因为我们在Scope
上定义了$$watchers
数组,在根作用域的构造函数上。任何一个子作用域都能获取到$$watchers
数组(或者在构造器中定义的其他属性),通过属性链,他们获得了根作用域的一个拷贝。
这有一个重要含义:不论我们在哪个作用域上调用$digest
,我们会在作用域层次结构上执行所有的监控。因为这里只有一个监控数组:在根作用域上的监控数组。这不是我们所需要的。
我们所希望的是当我们调用$digest
函数,只是循环遍历我们调用的作用域以及其子作用域上的监控。而不是现在发生的调用其父作用域和子作用域上的监控:
test/scope_spec.js
it("does not digest its parent(s)", function(){
var parent = new Scope();
var child = parent.$new();
parent.aValue = ‘abc‘;
parent.$watch(
function(scope) { return scope.aValue;},
function(newValue, oldValue, scope){
scope.aValueWas = newValue;
}
);
child.$digest();
expect(child.aValueWas).toBeUndefined();
});
这个测试失败了,因为当我们调用child.$digest()
,我们实际上在执行父作用域上的监控。让我们来修改这个问题:
解决方案是给每个子作用域非配自己的$$watchers
数组:
src/scope.js
Scope.prototype.$new = function(){
var ChildScope = function() {};
ChildScope.prototype = this;
var child = new ChildScope();
child.$$watchers = [];
return child;
};
你可能注意到我们在此使用了我们上一章节讨论的属性遮盖。每个子作用域的$$watchers
数组遮盖了其父作用域上的属性。层次结构中的每个作用域有其自己的监控。当我们调用了某个作用域上的$digest
,只有该作用域上的监控被执行了。
digest递归
在前一章节,我们讨论了调用$digest
时不应该执行沿着层次结构向上执行监控。而是应该沿着层次结构向下执行,即执行我们调用的作用域的所有子作用域。这是有意义的,因为有些子作用域可以通过属性链监控上层作用域的属性。
既然现在我们的每一个作用域都有一个独立的监控数组,这代表着当我们调用父作用域上的$digest
时,子作用域上的$digest
不会被执行。我们现在需要修改$digest
:当改变$digest
时,不仅当前作用域会工作,其子作用域也会。
我们遇到的第一个问题是:当前的作用域不知道他是否含有子作用域,也不知道他的子作用域是谁。我们需要绕过每个作用域能够追踪到其子作用域。根作用域和子作用域都需要。让我们将这些作用域存储在一个数组中,命名为$$children
:
test/scope_spec.js
it("keeps a record of its children", function(){
var parent = new Scope();
var child1 = parent.$new();
var child2 = parent.$new();
var child2_1 = child2.$new();
expect(parent.$$children.length).toBe(2);
expect(parent.$$children[0]).toBe(child1);
expect(parent.$$children[1]).toBe(child2);
expect(child1.$$children.length).toBe(0);
expect(child2.$$children.length).toBe(1);
expect(child2.$$children[0]).toBe(child2_1);
});
我们需要在根作用域的构造函数中初试化$$children
:
src/scope.js
function Scope(){
this.$$watchers = [];
this.$$lastDirtyWatch = null;
this.$$asyncQueue = [];
this.$$applyAsyncQueue = [];
this.$$applyAsyncId = null;
this.$$postDigestQueue = [];
this.$$children = [];
this.$$phase = null;
}
然后我们需要在创建子作用域时,将其加入到该数组中。我们还需给子作用域分配自己的$$children
数组(遮盖了其父作用域的同名属性),当我们遍历监控时不会遇到相同的问题。这些变化都在$new
函数中:
src/scope.js
Scope.prototype.$new = function(){
var ChildScope = function() {};
ChildScope.prototype = this;
var child = new ChildScope();
this.$$children.push(child);
child.$$watchers = [];
child.$$children = [];
return child;
};
现在我们能够记录子作用域,我们将要讨论他们的digest循环。在父作用域上调用$digest
也能够执行子作用域中的监控:
test/scope_spec.js
it("digests its children ", function(){
var parent = new Scope();
var child = parent.$new();
parent.aValue = ‘abc‘;
child.$watch(
function(scope) { return scope.aValue; },
function(newValue, oldValue, scope){
scope.aValueWas = newValue;
});
parent.$digest();
expect(child.aValueWas).toBe(‘abc‘);
});
注意这个测试基本上是上节测试的一个镜像,在上节测试中我们在子作用域上调用$digest
不应该在父作用域上引起监控函数的执行。
为了让这个能够工作,我们需要改变$$digestOnce
函数,来让监听沿着层次机构向下运行。为了更简单,首先我们先添加一个助手函数$$everyScope
(根据JavaScript的Array.every
来命名),该函数针对层次结构中的每一个作用域都可以执行任意一个函数,知道某个函数返回false:
src/scope.js
Scope.prototype.$$everyScope = function(fn){
if(fn(this)){
return this.$$children.every(function(child){
return child.$$everyScope(fn);
});
}else{
return false;
}
};
对于当前作用域,该函数调用fn
一次,然后在其子作用域上递归调用。
我们可以在$$digestOnce
的外层循环中使用该函数进行操作:
src/scope.js
Scope.prototype.$$digestOnce = function(){
var self = this;
var dirty;
var continueLoop = true;
this.$$everyScope(function(scope){
var newValue, oldValue;
_.forEachRight(scope.$$watchers, function(watcher){
try{
if(watcher){
newValue = watcher.watchFn(scope);
oldValue = watcher.last;
if(!(scope.$$areEqual(newValue, oldValue, watcher.valueEq))){
scope.$$lastDirtyWatch = watcher;
watcher.last = watcher.valueEq ? _.cloneDeep(newValue) : newValue;
watcher.listenerFn(newValue,
(oldValue === initWatchVal ? newValue: oldValue),
scope);
dirty = true;
}else if(self.$$lastDirtyWatch === watcher){//请注意这里是当前作用域的$$lastDirtyWatch进行比较,而不是scope.$$lastDirtyWatch
continueLoop = false;
return false;
}
}
} catch (e){
console.error(e);
}
});
return continueLoop;
});
return dirty;
};
现在$$digestOnce
函数会向下向下遍历整个层次结构,并且会返回一个布尔型的值,来指示在层次结构是是否有监控室脏的。
内层循环遍历作用域的层次结构直到所有的作用域都被遍历,或者短路优化发生了作用。如果优化起作用了,将被存储在continueLoop
变量中。如果该值为false
,我们将会跳出循环和$$digestOnce
函数。
注意到在内层循环中我们使用了一个特殊的变量scope
来指向this
。监控函数应该传入其原始的作用域对象,而不是正在调用$digest
的作用域对象。
注意到$$lastDirtyWatch
属性指向了最高层的作用域。短路优化应该对作用域层级上的所有监控负责。如果我们在当前作用域上设置了$$lastDirtyWatch
,它将遮盖掉其父作用域的属性。