原文链接:Understanding Scopes

在AngularJS中,子作用域通常会原型继承于父作用域。这种情况的唯一例外是当一个指令设置了scope:{ ... } -- 这会创建一个孤立的作用域,该作用域不会进行原型继承。这种设置通常用于创建可复用组件。在指令中,默认情况下直接使用父作用域,这意味着,你在指令中作的任何改动都会同时改变父作用域。如果你设置scope:true(而不是scope:{ ... }),那么该指令会进行原型继承。

一般来说,作用域继承是很简单的,通常你甚至不需要知道它正在运作...直到你试图从子作用域中对父级作用域的基本类型数据(比如,数字,字符串,布尔值)进行数据双向绑定(即表单元素,ng-model指令)。这种做法通常不会符合我们的预期。这是因为子作用域会创建自身的属性,从而隐藏/遮蔽了父级作用域的同名属性。这种特性是JavaScript原型链运作原理,而不是AngularJS本身实现造成的。AngularJS初学者通常没有意识到,ng-repeatng-switchng-viewng-include所有这些指令都会创建一个子作用域,所以当执行这些指令时便会出现问题。

如果我们遵循记得在ng-model指令中使用'.'的“最佳实践”-- 值得花3分钟看看,我们能轻易地回避这个问题。Misko用ng-switch阐述了基本类型数据绑定的问题。

在你的ng-model指令中使用“.”能保证原型继承链起作用。所以,我们应该使用:

<input type="text" ng-model="someObj.prop1">  

而不是:

<input type="text" ng-model="prop1">  

如果你真的想或者真的需要用到基本类型数据,这里有两种变通方案:

  1. 在子作用域中使用$parent.parentScopeProperty,防止子作用域创建自身的属性
  2. 在父作用域中定义一个函数,并在子作用域中调用并传递基本类型数据给父作用域(并不是总能够做到)

JavaScript 原型继承

首先,我们要对JavaScript的原型继承有个良好的认知,这很重要,如果你有服务端编程的背景,更是如此。所以让我们先回顾一下原型继承的原理。

假设父级作用域有以下属性aStringaNumberanArrayanObject 和 aFunction。如果子作用域原型继承于父作用域,我们有:

JavaScript 原型继承

当我们试图从子作用域中访问父作用域上定义的属性,JavaScript会先在子作用域上查询该属性,如果没有找到该属性,再访问父级作用域并查询该属性。(如果在父作用域中依旧没有找到这个属性,JavaScript会继续顺着原型链往上查找... 直到根作用域)。因此,以下均为true:

childScope.aString === 'parent string'  
childScope.anArray[1] === 20  
childScope.anObject.property1 === 'parent prop1'  
childScope.aFunction() === 'parent output'  

假设我们接下来进行以下操作:

childScope.aString = 'child string';  

原型链并未被查询,而子作用域中新增了一个 aString 属性。这个新的属性隐藏/遮蔽了父作用域的同名属性。当我们下面讨论到ng-repeat指令和ng-include指令时,这特性会变得非常重要。

原型链属性覆盖

接下来假设我们执行:

childScope.anArray[1] = '22'  
childScope.anObject.property1 = 'child prop1'  

因为在子作用域中没有找到 anArray 和 anObject 对象,所以原型链被查询了。在父作用域中被找到这两个对象,所以属性值被更新到了原始的对象上。子作用域上没有添加新的属性,也没有创建新的对象。(注意,在JavaScript中数组和函数都是对象)。

修改原型对象属性

接着,假设我们这么做:

childScope.anArray = [100, 555]  
childScope.anObject = { name: 'Mark', country: 'USA' }  

原形链并未被访问,并且子作用域获得了两个新的对象属性,这两个属性也会遮蔽父作用域上的同名属性。

定义新属性覆盖原型属性

顺便提一下:

  • 如果我们读取childScope.propertyX,并且子作用域有 propertyX 属性,那么原型链将不会被访问。
  • 如果我们设置childScope.propertyX,那么原型链也不会被访问。

最后一种情况:

delete childScope.anArray  
childScope.anArray[1] === 22  // true  

我们先删除子作用域的属性,然后当我们试图再次访问该属性,此时原型链会被访问。

删除属性后,原型同名属性可以被访问

Angular 作用域的继承

两种不同的情况:

  • 以下指令会创建新的作用域,而且原型继承父级作用域:ng-repeat、 ng-includeng-switchng-viewng-controller、带scope: true的指令、设置了transclude:true的指令
  • 以下指令会创建新的作用域,但不会原型继承:设置了scope: { ... }的指令。这指令创建的是孤立的作用域。

注意,通常情况下,即默认情况下scope:false,指令不会创建新的作用域。

ng-include

假设我们的控制器中有:

$scope.myPrimitive = 50;
$scope.myObject    = {aNumber: 11};

而且在我们的HTML中:

<script type="text/ng-template" id="/tpl1.html">  
    <input ng-model="myPrimitive">
</script>  
<div ng-include src="'/tpl1.html'"></div>  
<script type="text/ng-template" id="/tpl2.html">  
    <input ng-model="myObject.aNumber">
</script>  
<div ng-include src="'/tpl2.html'"></div>  

每一个ng-include指令都生成一个新的子作用域,这些子作用域都原型继承于其父作用域。

ng-include指令作用域

在第一个输入框中输入77,子作用域将会得到一个新的myPrimitive属性,该属性会遮蔽了父作用域的同名属性。这可能不是你想要的。

子作用域属性覆盖父作用域属性

在第二个输入框中输入99不会新建一个子作用域属性。因为tpl2.html绑定的数据是一个对象属性。当ngModel指令查询该对象,原型继承起到了作用,最终在父作用域中查找到该对象。

查找父作用域属性

如果我们不想将我们的数据从基本类型改为对象,我们可以用$parent变量重写第一个模版:

<input ng-model="$parent.myPrimitive">  

在该输入框中输入22不会生成一个新的子作用域属性。现在,这个模型是绑定在父级作用域的一个属性上(因为$parent是子作用域上指向父作用域的属性值)。

使用$parent显式指定父作用域

对于所有的作用域(无论是否原型继承),Angular总会通过$parent$$childHead`和`$$childTail记录下父-子关系(即一种层级关系)。以上的图表并没有展示这些属性值。

对于一些不涉及表单元素的情况,另一种解决方法是在父级作用域中定义一个函数用来修改基本类型数值。然后保证其子作用域都调用该函数,由于原型继承,其子作用域都能够访问的该函数。比如:

// in the parent scope
$scope.setMyPrimitive = function(value) {
    $scope.myPrimitive = value;
}

更多阅读:What is the angularjs way to databind many inputs?

ng-switch

ng-switch指令的作用域继承的运行原理就类似于ng-include指令。所以如果你需要对父级作用域中的一个基本类型值进行双向版定,你可以使用$parent,或者将数据模型改成对象的形式,然后绑定该对象上的属性。这可以避免子作用域遮蔽到了父作用域上的属性。

更多阅读:AngularJS, bind scope of a switch-case?

ng-repeat

ng-repeat指令的运行原理有点不一样。假设我们控制器中有:

$scope.myArrayOfPrimitives = [ 11, 22 ];
$scope.myArrayOfObjects    = [{num: 101}, {num: 202}];

而且我们的HMTL中:

<ul>  
    <li ng-repeat="num in myArrayOfPrimitives">
       <input ng-model="num"></input>
    </li>
</ul>  
<ul>  
    <li ng-repeat="obj in myArrayOfObjects">
       <input ng-model="obj.num"></input>
    </li>
</ul>  

每次迭代,ng-repeat指令都会创建一个新的作用域,该作用会原型继承于其父级作用域,但是同时该指令会给这个新作用域的一个新的属性分配本次迭代对应数值。(这个属性的名称就是循环变量的名字)。以下就Angular源码中ng-repeat具体实现:

childScope = scope.$new(); // child scope prototypically inherits from parent scope ...  
childScope[valueIdent] = value; // creates a new childScope property  

如果迭代项为基本类型,实质上把该值的拷贝分配给了子作用域新的属性。改变这个属性值(即子作用域的属性num)不会改变父作用域引用的数组。所以在上述第一个ng-repeat指令中,每个子作用域都获得一个独立于myArrayOfPrimitives数组的num属性:

ng-repeat每个元素创建子作用域

这个ng-repeat指令不会如你期望搬工作。在Angular1.0.2及之前版本中,在输入框中输入,会改变灰色框框内的值,即子作用域的属性值。在Angular 1.0.3+版本,在文本框中输入不会有任何效果(参考Artem在stackOverflow上的解释)。我们想要的是,输入的值能改变myArrayOfPrimitives数组,而不是子作用域的属性值。为了实现这一点,我们需要将模型改成一个包含对象的数组。

所以,如果迭代元素是一个对象,那么分配到子作用域上的就是一个对原始对象的引用(而不是拷贝)。改变子作用域的属性值便会同时改变父级作用域引用的对象。所以在上述第二个ng-repeat指令中,我们有:

(我用灰色标记其中一条线,以便清晰展现它的指向)

这将如期工作。在文本框中的输入将改变灰色框框中的值,这将同时反映到子作用域和父级作用域中。

更多阅读:Difficulty with ng-model, ng-repeat, and inputsWhat is the angularjs way to databind many inputs?

ng-view

待定,但我认为该指令和ng-include指令表现一致。

ng-controller

使用ng-controller指令嵌套控制器会造成常规的原型继承,就像ng-include指令和ng-switch指令,所以我们可以用相同的方法解决。然而,“通过作用域继承,在两个控制器中共享数据是一种非常糟糕的实现” --AngularJS Sticky Notes Pt 1 – Architecture ,我们应该用服务在控制器之间共享数据。

(如果你真的要通过控制器的作用域继承来分享数据,你不需要做额外的工作。子作用域可以访问所有父级作用域的属性。更多阅读Controller load order differs when loading or navigating)。

指令

1.默认设置scope: false

指令不会新建一个作用域,所以这里不存在继承关系。这很简单,但同时也很危险,比如某指令中可能会创建一个新的属性,然而事实上,这个属性影响到了另一个已经存在的属性。对于书写可复用组件的指令来说,这不是一个好的选择。

2.scope: true

指令会创建一个新的子作用域,原型继承于父级作用域。如果多个指令(在同一个DOM元素上)请求新的作用域,那么只会创建一个作用域。因为涉及到原型继承,就像ng-includeng-switch,所以我们要谨慎对待父级作用域基本类型数据的双向绑定和子作用域遮掩父级作用域属性的问题。

3.scope: { ... }

指令会新建一个封闭的作用域。该作用域不会进行原型继承。这样的配置通常是你创建可复用组件的最好选择,因为这指令不会意外地读取或修改父级作用域。然而,有些指令通常需要访问父作用域的数据。设置对象是用来配置父作用域和封闭作用域之间的双向绑定(使用=)或单向绑定(使用@)。这里也可以使用&绑定父作用域上的表达式。所以,这些配置都会将来自父作用域的数据创建到本地作用域属性中。

要注意的是,这些配置选项只是用来设置绑定方式 -- 你只能运用Dom元素的属性引入父作用域的属性们,而不可以在配置选项中直接引用。比如你想将父作用域的属性parentProp绑定到封闭的作用域:<div my-directive>scope: { localProp: '@parentProp'},这不会起作用。我们必须用DOM元素属性定义指令需要绑定的每一个父作用域属性:<div my-directive the-Parent-Prop=parentProp>scope: { localProp: '@theParentProp' }

封闭作用域的__proto__引用的是一个Scope对象。封闭作用域的$parent指向父作用域,所以,虽然该作用域保持封闭而且不会原型继承于父作用域,但它依旧是一个子作用域。

对于下图我们有<my-directive interpolated="{{parentProp1}}" twowayBinding="parentProp2">scope: { interpolatedProp: '@interpolated', twowayBindingProp: '=twowayBinding' },而且假设这个指令在link函数中进行如下操作:scope.someIsolateProp = "I'm isolated"

孤立作用域

更多关于封闭作用域的信息请查阅:AngularJS Sticky Notes Pt 2 – Isolated Scope

4.transclude: true

指令新建一个用于"transclude(嵌入)"的子作用域,该作用域原型继承于父作用域。所以如果你嵌入的内容(指替换ng-transclude指令的内容)中需要对父作用域中的数据进行双向绑定,你应该使用$parent或把数据模型改成对象,然后把需要的属性绑定在这对象上。这样能够避免子作用域遮蔽父作用域的属性。

如果ng-transclude指令和封闭作用域是同级关系,那么它们各自作用域的$parent属性都指向同一个父作用域。如果ng-transclude指令和封闭作用域同时存在,那么封闭作用域上的$$nextSibling会指向ng-transclude作用域。

更多ng-transclude指令作用域的信息请查阅:Two way binding not working in directive with transcluded scope

假设上面的指令加上transclude:true,我们有下面这张图:

transclude的作用域

在Angular 1.3+中,此图稍有变化,TranscludedScope依旧继承自ParentScope,但是IsolateScope的$parent是ParentScope,TranscludedScope的$parent是IsolateScope,而且IsolateScope也没有$$nextSibling指向TranscludedScope

关于TranscludeScope和IsolateScope可以查看StackoverFlow上的回答

总结

一共有3种类型的作用域:

  1. 常规的原型继承的作用域 -- ng-includeng-switchng-controller, 设置了scope: true的指令。
  2. 封闭作用域 -- 设置scope: {...}的指令。这种作用域没有原型继承,但=`@, 和 &提供了一套通过元素属性访问父作用域的机制。
  3. transclude作用域 -- 设置了transclude: true的指令。这种作用域也是常规的原型继承,但它和任何封闭作用域是同级关系。 
    对于所有作用域(无论是否原型继承),Angular都会通过作用域的属性$parent$$childHead$$childTail记录下父-子关系。