1. 程式人生 > >深入理解Angular作用域

深入理解Angular作用域

摘要

在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

(注意:為了節省空間,我把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

假設我們又做了如下操作

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

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

圖3

假設我們做如下操作:

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

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

圖3

重要結論:

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

最後一個場景:

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

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

圖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

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

圖6

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

圖7

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

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

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

圖8

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

對於不涉及表單元素的情況,另一個解決方案是在父作用域中定義一個函式來修改原始資料型別。然後保證子作用域總是呼叫這個函式,由於原型繼承子作用域能夠訪問到該函式。例如,

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

ng-switch

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

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

因此這個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

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

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

ng-view

和ng-include類似

ng-controller

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

指令

  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
    最後注意:使用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

這個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追蹤父-子關係(即層級結構)

掃一掃吧
AngularJS公眾號