深入理解Angular作用域

翻譯自:Understanding Scopes

摘要

在AngularJS中,子作用域通常會原型繼承于其父作用域。有一個例外是當指令使用scope: { ... }來定義--這創建了一個沒有原型繼承的“獨立“作用域,這會在創建“可重復使用的組件“的指令時經常使用。如果你設置了scope:true(而不是scope: { ... }),這個指令會使用原型繼承。

通常情況下作用域繼承非常直白,你甚至不需要知道它正在發生。。。直到在一個定義在父作用域上原始類型(例如:number, string, boolean)在子作用域中使用了雙向數據綁定(即:表單元素,ng-model)。這并不會像大多數人期望的那樣工作,而是子作用域得到了它自己的屬性,從而覆蓋了父作用域上的同名屬性。這不是AngularJS做的事情-這是JavaScript的原型繼承起作用了。新入門的AngularJS開發者通常情況下不會意識到ng-repeat、 ng-switch、 ng-view 和 ng-include都創建了新的子作用域,所以當使用這些指令的時候,經常會有這種問題發生。

關于原始類型的這個問題通過下面的這個最佳建議很容易避免:在你的模型中始終使用'.'

在模型中使用'.' 會確保原型繼承始終發生。所以,使用代碼<input type="text" ng-model="someObj.prop1">而不是<input type="text" ng-model="prop1">

如果你必須要使用原始類型,有以下兩種解決方法:

  • 在子作用域上使用$parent.parentScopeProperty。這會阻止子作用域創建自己的屬性。
  • 在父作用域上定義一個函數,在子作用域上調用,通過該函數傳遞父作用域上的原始值。

JavaScript原型繼承


首先對JavaScript原型繼承有一個深入的了解很有必要,尤其你具有服務器端開發的背景,并且對于傳統的繼承很熟悉。讓我們先來復習一下。

假設parentScope具有如下屬性, aString, aNumber, anArray, anObject, and aFunction。如果childScope 原型繼承于parentScope,如下:

圖1
圖1

(注意:為了節省空間,我把anArray展示成一個藍色的有三個值的對象,而不是一個藍色的擁有三個分離的灰色的對象)

如果我們在childScope上獲取parentScope上定義的對象,JavaScript會首先在childscope上查找,沒有找到該屬性,查找其繼承的scope,找到這個屬性(如果在parentScope上沒有找到該屬性,會繼續查找原型鏈。。。直到到達root scope)。所以,以下全都為真:

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

假設我們有如下代碼:

childScope.aString = 'child string'

原型鏈并沒有被遍歷,一個新的屬性會被添加到childScope上。同時,這個新屬性隱藏了和parentScope具有同樣名稱的屬性。這對我們下面討論ng-repeat 和 ng-include非常重要

圖2
圖2

假設我們又做了如下操作

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

原型鏈被訪問了,因為對象(anArray和anObject)在childScope中沒有被找到。這兩個對象在parentScope中被找到了,屬性的值在原對象上被更新了。childScope上不會增加新的屬性,沒有新的對象被創建(注意:在JavaScript中數組和函數同樣是對象)

圖3
圖3

假設我們做如下操作:

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

原型鏈不會被訪問,childScope會創建兩個新的對象屬性,隱藏了和parentScope具有相同名稱的屬性。

圖3
圖3

重要結論:

  • 如果我們讀取childScope的某個屬性childScope.propertyX,
    并且childScope具有屬性propertyX,那么原型鏈不會被訪問。
  • 如果我們設置childScope的某個屬性propertyX,那么原型鏈不會被訪問。

最后一個場景:

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

首先我們刪除了childScope的屬性anArray,然后我們嘗試再次去獲得該屬性,原型鏈被訪問了。

圖4
圖4

這個jsfiddle中你可以看到JavaScript原型繼承的例子和結果(打開你的瀏覽器的控制臺查看輸出)

Angular Scope 繼承


  • 如下的指令創建了新的scope,并且基于原型繼承:ng-repeat, ng-include, ng-switch, ng-view, ng-controller,使用scope:true的指令,使用transclude: true的指令。

  • 如下的指令創建了新的scope,并且沒有基于原型繼承:使用scope: { ... }的指令。這創建了“孤立”的scope
    (注意: 默認情況下,指令不創建新的scope,默認值是scope: false)

ng-include

假設我們的controller中的代碼如下:

$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生成了一個新的基于其父作用域原型繼承的子作用域。

圖5
圖5

修改第一個textbox中的值為‘77’會導致子作用域創建一個新的myPrimitive 屬性,并且隱藏了父作用域的同名屬性。這可能不是你所希望的。

圖6
圖6

修改第二個textbox中的值為‘99’不會導致創建一個新的子屬性。因為tpl2.html 綁定了一個對象的屬性,當ngModel查找對象myObject時原型繼承起作用了,最終在parentScope中找到了該屬性。

圖7
圖7

如果不想將model從原始類型改為對象類型的話,我們可以使用$parent來重寫第一個模板。

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

這次修改第一個textbox的值不會導致生成一個新的子屬性。模型現在綁定了parentScope中的屬性(因為$parent是子作用域指向父作用域的一個引用)。

圖8
圖8

對于所有的作用域(不管是否是基于原型繼承),Angular會通過作用域上的屬性$parent, $$childHead 和 $$\childTail 始終跟蹤其父-子關系(即層次結構)。在圖中我并沒有展示出這些屬性。

對于不涉及表單元素的情況,另一個解決方案是在父作用域中定義一個函數來修改原始數據類型。然后保證子作用域總是調用這個函數,由于原型繼承子作用域能夠訪問到該函數。例如,

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

這是一個使用了“parent function”的簡單的jsfiddle(部分來自于Stack OverFlow
還可以參考 http://stackoverflow.com/a/13782671/215945
https://github.com/angular/angular.js/issues/1267.

ng-switch

ng-switch scope 的繼承和ng-include類似。因此如果你需要雙向數據綁定到父作用域中的一個原始數據類型上,使用$parent或者將model改為對象的某個屬性。這會避免子作用域隱藏了父作用域的屬性。

可以查看Stack Overflow

ng-repeat

ng-repeat 和以上指令有點差別。假設我們的controller如下:

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

HTML 如下:

<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>

對于每次 item的iteration,ng-repeate創建了一個從父作用域原型繼承的新的作用域,但是它也將item的值分配給新的子作用域上的一個新的屬性(新的屬性的名稱是循環變量的名稱)。如下是ng-repeate的源代碼。

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

如果item是一個原始類型(例如上面的myArrayOfPrimitives),本質上該值的一個拷貝被分配給新的子scope。改變了子scope的屬性值(即使用ng-model、也就是子scope屬性num)并沒有改變父scope引用的數組。所以,在上面第一個ng-repeate,每一個子scope會得到一個獨立于myArrayOfPrimitives 的num屬性。

圖9
圖9

因此這個ng-repeat不會像你希望的那樣工作。在Angular1.0.2(包含)以前,修改textbox的值會改變上圖中灰色框的值,并且只在child scope中可見。在Angular 1.0.3以上,修改textbox的值不會有任何影響(參考Artem在Stack Overflow的解釋)(此處說法有點不太準確,在較新的Angular版本中,修改textbox的值會改變圖中灰色框中的值--譯者注)。我們所希望的是修改input的值能夠改變數組myArrayOfPrimitives,而不是子scope的一個原始類型的屬性。為了達到這個目的,我們需要將模型改為對象的數組(見第2個例子)。

因此,如果item是一個對象,原始對象的引用(非拷貝)會被分配成為新的子scope上的屬性。修改子scope的屬性值(例如,使用ng-model,obj.num)會修改父scope上的值。在上面的第二個ng-repeat中,我們有如下結論:

圖10
圖10

(注意圖中的灰線,能清楚的看到發生了什么)

按照預期工作了。修改textbox的值改變了灰色框中的值,同時對子作用域和父作用域都可見。

可以參考 Difficulty with ng-model, ng-repeat, and inputsng-repeat and databinding

ng-view

和ng-include類似

ng-controller

和ng-include、ng-switch的原理一致,使用ng-controller的嵌套的控制器會引起正常的原型繼承。然而,“不建議在兩個控制器中通過$scope的繼承關系來共享信息“--http://onehungrymind.com/angularjs-sticky-notes-pt-1-architecture/。在控制器中共享數據應該使用服務。

(如果你必須通過控制器的scope的繼承關系共享數據,你不需要做任何操作。子scope能夠取到父scope的所有屬性。參考Controller load order differs when loading or navigating

指令

  1. 默認(scope:false)-指令沒有創建任何新的作用域,因此不存在任何的原型繼承。這很簡單,但是同樣存在隱患,例如:一個指令可能以為它在作用域上創建了一個新的屬性,但實際上它修改了一個現有的屬性的值。這對于書寫可重復使用的組件來說并不是一個好的選擇。
  2. scope: true-指令創建了一個從父作用域基于原型繼承的子作用域。如果在同一個DOM上有多個指令需要創建新的作用域,那么只有一個新的子作用域會被創建。既然有“正常“的原型繼承,和ng-include 、ng-switch類似,警惕在父作用域上的原始數據類型的雙向數據綁定,子作用域會覆蓋掉父作用域上的屬性。
  3. scope: { ... }-指令創建了一個新的獨立作用域。并且沒有原型繼承。當你創建可以復用的組件時這是一個好的選擇,因為指令不能夠直接讀取或修改父作用域。然而,通常這種指令需要讀取父作用域的某些屬性。該對象可以在父作用域和獨立作用域上使用“=“創建雙向數據綁定,使用“@“創建單向綁定(父作用域改變會影響子作用域,子作用域改變并不會影響父作用域--譯者注)。也可以使用“&“綁定父作用域上的表達式。所以,這些方法同樣給子作用域創建了從父作用域衍生的屬性。注意這些屬性被用來幫助設置綁定--在對象中你不能直接引用父作用域的屬性名稱,你需要使用一個HTML屬性。例如:如下,你想要在獨立作用域上綁定父作用域的屬性parentProp將不會起作用:代碼<div my-directive>scope: { localProp: '@parentProp' }。指令想要綁定的父屬性必須要有明確的HTML屬性名:代碼<div my-directive the-Parent-Prop=parentProp>scope: { localProp: '@theParentProp' }。獨立作用域的proto 引用了一個Scope對象。獨立作用域的$parent引用了父作用域,盡管這是一個沒有原型繼承的獨立作用域,但他還是一個子作用域。
    如下圖片中:我們有代碼<my-directive interpolated="{{parentProp1}}" twoway-binding="parentProp2">
    scope: { interpolatedProp: '@interpolated', twowayBindingProp: '=twowayBinding' }。同樣假設在指令的link函數中有代碼scope.someIsolateProp = "I'm isolated"
    圖11
    圖11

    最后注意:使用link函數中attrs.$observe('attr_name', function(value) { ... })來得到獨立作用域中使用‘@‘綁定的屬性的值。例如:在link函數中有代碼--attrs.$observe('interpolated', function(value) { ... }) -- value會被設置為11。(scope.interpolatedProp在link函數中沒有定義(該文章寫的時間較早,譯者通過測試Angular1.4.7發現在該版本中,這個屬性已經有定義了,值為11)。而scope.twowayBindingProp有定義,因為他使用了‘=‘ )。
    關于獨立作用域的更多信息請查看:http://onehungrymind.com/angularjs-sticky-notes-pt-2-isolated-scope/
  4. transclude: true-指令創建了一個新的 "transcluded" 子作用域,并且原型繼承于父作用域。因此,如果你的嵌入的內容(即ng-transclude將被替換的內容)需要雙向數據綁定到父作用域上的一個原始類型上,使用$parent,或者將模型改為對象,綁定到改對象的某個屬性上。這會避免子作用域覆蓋父作用域的屬性。
    內嵌作用域和獨立作用域是同胞的--每個scope的$parent屬性指向同一個父作用域。當內嵌作用域和獨立作用域同時存在,獨立作用域的$$nextSibling 屬性會指向內嵌作用域。
    關于內嵌作用域的更多信息,請查看AngularJS two way binding not working in directive with transcluded scope
    假設上面的指令增加了屬性transclude: true ,scope的示意圖如下:
    圖12
    圖12

這個jsfiddle有一個用來檢查獨立作用域和他相關的內嵌作用域的showScope()函數。參考該fiddle中的注釋中的說明。

總結


有四種類型的作用域:

  1. 普通原型繼承作用域--ng-include、ng-switch、ng-controller和使用scope: true定義的指令
  2. 含有拷貝屬性的普通原型繼承作用域--ng-repeat。每次迭代ng-repeat都會創建一個新的子作用域,同時新的子作用域會得到一個新的屬性。
  3. 獨立作用域--使用scope: {...}定義的指令。這次沒有原型繼承,但是 '=', '@', and '&'提供了一種通過HTML屬性獲取父作用域屬性的機制。
  4. 內嵌作用域--使用transclude: true定義的指令。這次依舊是正常的基于原型的繼承,但是同時他也是任意獨立作用域的兄弟作用域。

對于所有的作用域(不論是否原型繼承),Angular總是通過$parent、$$childHead 和$$childTail追蹤父-子關系(即層級結構)

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。

推薦閱讀更多精彩內容