AngularJS 作用域(Scope)
作用域(Scope)
是一個存儲應用資料模型的對象
為 表達式 提供了一個執行上下文
作用域的層級結構對應于 DOM 樹結構
作用域可以監聽 表達式 的變化并傳播事件
作用域有什麼
作用域提供了 ($watch) 方法監聽資料模型的變化
作用域提供了 ($apply) 方法把不是由Angular觸發的資料模型的改變引入Angular的控制範圍内(如控制器,服務,及Angular事件處理器等)
作用域提供了基于原型鍊繼承其父作用域屬性的機制,就算是嵌套于獨立的應用元件中的作用域也可以通路共享的資料模型(這個涉及到指令間嵌套時作用域的幾種模式)
作用域提供了 表達式 的執行環境,比如像 {{username}} 這個表達式,必須得是在一個擁有屬性這個屬性的作用域中執行才會有意義,也就是說,作用域中可能會像這樣 scope.username 或是 $scope.username,至于有沒有 $ 符号,看你是在哪裡通路作用域了
作用域作為資料模型使用
作用域是Web應用的控制器和視圖之間的粘結劑。在Angular中,最直覺的表現是:在自定義指令中,處在模版的 連結(linking) 階段時, 指令(directive)會設定一個 $watch 函數監聽着作用域中各表達式(注:這個過程是隐式的)。這個 $watch 允許指令在作用域中的屬性變化時收到通知,
進而讓指令能夠根據這個改變來對DOM進行重新渲染,以便更新已改變的屬性值(注:屬性值就是scope對象中的屬性,也就是資料模型)。
其實,不止上面所說的指令擁有指向作用域的引用,控制器中也有(注:可以了解為控制器與指令均能引用到與它們相對應的DOM結構所處的作用域)。
但是控制器與指令是互相分離的,而且它們與視圖之間也是分離的,這樣的分離,或者說耦合度低,可以大大提高對應用進行測試的工作效率。
注:其實可以很簡單地了解為有以下兩個鍊條關系:
控制器 --> 作用域 --> 視圖(DOM)
指令 --> 作用域 --> 視圖(DOM)
讓我們來看下面一個例子,可以說明作用域作為視圖與控制器的黏合劑:
源碼
Your name:
greet
function MyController($scope) {
$scope.username = 'World';
$scope.sayHello = function() {
$scope.greeting = 'Hello ' + $scope.username + '!';
};
}
效果
Your name:
greet
在上面這個例子中,我們有:
控制器:MyController,它引用了 $scope 并在其上注冊了兩個屬性和一個方法
$scope 對象:持有上面例子所需的資料模型,包括 username 屬性、greeting屬性(注:這是在sayHello()方法被調用時注冊的)和 sayHello() 方法
視圖:擁有一個輸入框、一個按鈕以及一個利用雙向綁定來顯示資料的内容塊
那麼具體整個示例有這樣兩個流程,從控制器發起的角度來看就是:
控制器往作用域中寫屬性:給作用域中的 username 指派,然後作用域通知視圖中的 input 資料變化了,input 因為通過 ng-model 實作了雙向綁定可以知道 username 的變化,進而在視圖中渲染出改變的值,這裡是 World
控制器往作用域中寫方法給作用域中的 sayHello() 方法指派,該方法被視圖中的 button 調用,因為 button 通過 ng-click 綁定了該方法,當使用者點選按鈕時,sayHello() 被調用,這個方法讀取作用域中的 username 屬性,加上字首字元串 Hello,然後指派給在作用域中新建立的 greeting 屬性
整個示例的過程如果從視圖的角度看,那主要是以下三個部分:
input 中的渲染邏輯:展示了通過 ng-model 進行的作用域和 視圖中某表單元素的雙向綁定
根據 ng-model 中的 username 去作用域中取,如果已經有值,那麼用這個預設值填充目前的輸入框
接受使用者輸入,并且将使用者輸入的字元串傳給 username,這時候作用域中的該屬性值實時更新為使用者輸入的值
button 中的邏輯
接受使用者單擊,調用作用域中的 sayHello() 方法
{{greeting}} 的渲染邏輯
在使用者未單擊按鈕時,不顯示内容
取值階段:在使用者單擊後,這個表達式會去scope中取 greeting 屬性,而這個作用域和控制器是同一個的(這個例子中),這時候,該作用域下 greeting 屬性已經有了,這時候這個屬性就被取回來了
計算階段:在目前作用域下去計算 greeting 表達式 ,然後渲染視圖,顯示 HelloWorld
經過以上的兩種角度分析示例過程,我們可以知道:作用域(scope)對象以及其屬性是視圖渲染的唯一資料來源。
從測試的角度來看,視圖與控制器分離的需求在于它允許測試人員可以單獨對應用的操作邏輯進行測試,而不必考慮頁面的渲染細節。
it('should say hello', function() {
var scopeMock = {};
var cntl = new MyController(scopeMock);
// 確定username被預先填充為World
expect(scopeMock.username).toEqual('World');
// 確定我們輸入了新的username後得到了正确的greeting值
scopeMock.username = 'angular';
scopeMock.sayHello();
expect(scopeMock.greeting).toEqual('Hello angular!');
});
作用域分層結構
如上所說,作用域的結構對應于DOM結構,那麼最頂層,和DOM樹有根節點一樣,每個Angular應用有且僅有一個 root scope,當然啦,子級作用域就和DOM樹的子節點一樣,可以有多個的。
應用可以擁有多個作用域,比如 指令 會建立子級作用域(至于指令建立的作用域是有多種類型的,詳情參加指令相關文檔)。一般情況下,當新的作用域被建立時,它是以嵌入在父級作用域的子級的形式被建立的,這樣就形成了與其所關聯的DOM樹相對應的一個作用域的樹結構。(譯注:作用域的層級繼承是基于原型鍊的繼承,是以在下面的例子中會看到,讀屬性時會一直往上溯源,直到有未知)
作用域的分層的一個簡單例子是,假設現在HTML視圖中有一個表達式 {{name}} ,正如上面解釋過,Angular需要經曆取值和計算兩個階段才能最終在視圖渲染結果。那麼這個取值的階段,其實就是根據作用域的這個層級結構(或樹狀結構)來進行的。
首先,Angular在該表達式目前所在的DOM節點所對應的作用域中去找有沒有 name 這個屬性
如果有,Angular傳回取值,計算渲染;如果在目前作用域中沒有找到,那麼Angular繼續往上一層的父級作用域中去找 name 屬性,直到找到為止,最後實在沒有,那就到達 $rootScope 了
上面一個簡單的例子展示了在作用域分層結構中找屬性,是基于原型繼承的模式。接下來這個demo用一個圖具體展示了作用域的層級結構,讓你可以有更直覺的了解。
源碼
Hello !
- from
.show-scope-demo.ng-scope,
.show-scope-demo .ng-scope {
border: 1px solid red;
margin: 3px;
}
function GreetCtrl($scope, $rootScope) {
$scope.name = 'World';
$rootScope.department = 'Angular';
}
function ListCtrl($scope) {
$scope.names = ['Igor', 'Misko', 'Vojta'];
}
效果

看到上面的框中,注意,Angular會自動為每個擁有作用域的DOM節點加上 ng-scope 類。上圖中,擁有紅色邊框樣式的節點,就意味着該節點擁有了自己的作用域,無論它是通過什麼方式建立的(譯注:上面可以看到有通過控制器建立的新的作用域,也有通過指令如 ng-repeat 建立的)。上例中,ng-repeat 建立的子級作用域是極其必要的,因為每個
中想要渲染輸出的 {{name}} 顯然是不同的值,那就需要為它們提供不同的作用域。同樣的,Angular在渲染 {{department}} 表達式時,先在目前和 相對應的作用域去找有沒有這個屬性,如果沒有,接着往上找,在這個例子中,直到找到 $rootScope 下時,才找到 department 屬性,然後将其取回,計算,渲染輸出。
從DOM中抓取作用域
作用域對象是與指令或控制器等Angular元素所在的DOM節點相關聯的,也就是說,其實DOM節點上是可以抓取到作用域這個對象的(當然,為了調試偶爾會用,一般不用)。
而對于 $rootScope 在哪裡抓呢?它藏在 ng-app 指令所在的那個DOM節點之中,請看更多關于 ng-app 指令。通常,ng-app 放在 标簽中, 當然,如果你的應用中隻是視圖的某一部分想要用Angular控制,那你可以把它放在想要控制的元素的最外層。
那來看看如何在調試的時候抓取作用域吧:
右鍵選去你想審查的元素,調出debugger,通常F12即可,這樣你選中的元素會高亮顯示(譯注:文檔都看到這的人了,會需要這句提示麼?原文檔這是在賣萌麼)
此時,調試器(debugger)允許你用變量 $0 來擷取目前選取的元素
在console中執行 angular.element($0).scope() 或直接輸入 $scope 即可看到你想要查詢的目前DOM元素節點綁定的作用域了
基于作用域的事件傳播
作用域可以像DOM節點一樣,進行事件的傳播。主要是有兩個方法:
broadcasted :從父級作用域廣播至子級 scope
emitted :從子級作用域往上發射到父級作用域
讓我們來看個例子:
源碼
Root作用域MyEvent count:
-
$emit('MyEvent')
$broadcast('MyEvent')
Middle作用域MyEvent count:
- Leaf作用域MyEvent count:
function EventController($scope) {
$scope.count = 0;
$scope.$on('MyEvent', function() {
$scope.count++;
});
}
效果
譯注:上面例子很簡單,有幾個需要注意的是:
$emit 和 $broadcast 是直接被寫在 html 模版中的,而不是寫在控制器的 JavaScript代碼中,因為這兩個方法是直接在 $scope 中就有的,
同一個控制器 EventController 被用在了三個不同的DOM節點中(這是為了省事,通常不這樣寫的)
上面的事件無非就是點選兩個按鈕,分别出發廣播/冒泡(發射)事件,然後在各節點設定監聽,這裡隻要用 $scope.$on() 方法(注:如果在指令中,可能就是 scope.$on()),就可以進行監聽了
作用域的生命周期
作用域的執行上下文
譯注:這個小節應該是在看完下個小節的基礎上再回過來看這個,是以建議先看下個小節:scope生命周期拆解。由于要遵從原文檔的大體順序,是以順序沒做改動。
浏覽器接收一個事件的标準的工作流程應該是:
接收事件-->觸發回調-->回調執行結束傳回-->浏覽器重繪DOM-->浏覽器傳回等待下一個事件
上面的過程中,如果一切都發生在Angular的執行上下文的話,那相安無事,Angular能夠知道資料模型發生的改變;但是如果當浏覽器的控制權跑到原生的 JavaScript中去時(譯注:比如通過jQuery監聽事件之類的非Angular的回調等),那麼應用執行的上下文就發生在Angular的上下文之外了,這樣就導緻Angular無法知曉資料模型的任何改變。想要讓Angular重新掌權并知曉正在發生的資料模型的變化的話,那就需要通過使用 $apply 方法讓上下文執行環境重新進入到Angular的上下文中(注:用法 $scope.$apply())。隻有執行上下文重新回到Angular中,那樣資料模型的改變才能被Angular所識别并作出相應操作(注:當然,如果執行上下文沒有發生改變,也就沒有必要顯式地去進行 $apply 操作)。舉個例子,像 ng-click 這個指令,監聽DOM事件時,表達式的計算就必須放在 $apply() 中(注:例子不夠完備,待補充)。
在計算完表達式之後,$apply() 方法執行Angular的 $digest 階段。
在 $digest 階段,scope 檢查所有通過 $watch() 監測的表達式(或别的資料)并将其與它們自己之前的值進行比較。這就是所謂的 髒值檢查(dirty checking)。
另外,需要注意的是,$watch() 的監測是異步執行的。這就意味着當給一個作用域中的屬性被指派時,如:$scope.username="angular",$watch() 方法不會馬上被調用,它會被延遲直到 digest() 階段跑完
(注:至于 $digest 階段到底是幹嘛的,你可以認為就是個緩沖階段,而且是必要的階段)。
通過 $digest() 給我們提供的這個延遲是很有必要的,也正是應用程式常常想要的(注:出于性能的考慮),因為有這個延遲,我們可以等待幾個或多個資料模型的改變/更新攢到一塊,
合并起來放到一個 $watch() 中去監測,而且這樣也能從一定程度上保證在一個 $wathc() 在監測期間沒有别的 $watch() 在執行。
這樣,目前的 $watch() 可以傳回給應用最準确的更新通知,進而重新整理試圖或是進入一個新的 $digest() 階段。
(譯注:這一段有點晦澀,可以看下面的一張圖結合着學習;還有就是可以把整個過程想象為為了提升效率,
把多個同性質的資料放在同一個 $digest 輪循中處理能夠大大提高效率,就像zf辦事經常這樣,當然,它們的效率不高,ng則不同,效率相對高)
scope生命周期拆解
相信看了上面一段話,沒了解的還是很多人,因為标題雖說是講作用域的生命周期,但是一上來就跟我講的是關于Angular的執行上下文,怎麼也沒聯系到一塊。說實話,翻譯這段,真心有點要命的感覺。當然,把它拆分成多個步驟來看,相信會更清晰,因為下面我們是真要講作用域的生命周期,讓我們來過一遍。
建立期
root scope 是在應用程式啟動時由 $injector 建立的。另外,在指令的模版連結階段(template
linking),指令會建立一些新的子級 scope。
注冊$watch
在模版連結階段(template linking),指令會往作用域中注冊 監聽器(watch),而且不止一個。這些 $watch 用來監測資料模型的更新并将更新值傳給DOM。
資料模型變化
正如上面一節所提到的,要想讓資料模型的變化能夠很好的被Angular監測,需要讓它們在 scope.$apply() 裡發生。
當然,對于Angular本身的API來講,無論是在控制器中做同步操作,還是通過 $http 或者 $timeout 做的非同步操作,
抑或是在Angular的服務中,是沒有必要手動去将資料模型變化的操作放到 $apply() 中去的,因為Angular已經隐式的為我們做了這一點。
資料模型變化監測
在把資料變化 $apply 進來之後,Angular開始進入 $digest 輪循(就是調用 $digest() 方法),首先是 rootscope 進入 $digest ,然後由其把各個監聽表達式或是函數的任務傳播配置設定給所有的子級作用域,那樣各個作用域就各司其職了,如果監聽到自己負責的資料模型有變化,馬上就調用 $watch 。(譯注:這裡所說的從根scope往下分發是譯者自己的想法,如有錯誤,請糾正)
銷毀作用域
當子級作用域不再需要的時候,這時候建立它們的就會負責把它們回收或是銷毀(注:比如在指令中,建立是隐式的,銷毀可以不但可以是隐式的,也可以是顯式的,如 scope.$destroy())。銷毀是通過 scope.$destroy() 這個方法。銷毀之後,$digest() 方法就不會繼續往子級作用域傳播了,這樣也就可以讓垃圾回收系統把這一個作用域上用來存放資料模型的記憶體給回收利用了。
作用域和指令
在編譯(或說解析)階段, 編譯器在HTML解析器解析頁面遇到非傳統的或是自己不能識别的标簽或别的表達式時,Angular編譯器就将這些HTML解析器不懂的東西(其實就是指令)在目前的DOM環境下解析出來。通常,指令分為兩種,一種就是我們常說的指令,另外一種就是我們通常叫它Angular表達式的雙大括号形式,具體如下:
監測型 指令 ,像雙大括号表達式 {{expression}} 。這種類型的指令需要在 $watch() 方法中注冊一個監聽處理器(譯注:隐式還是顯式的需要看執行上下文),來監聽控制器或是别的操作引起的表達式值改變,進而來更新視圖。
監聽型 指令 ,像 ng-click , 這種是在HTML标簽屬性中直接寫好當 ng-click 發生時調用什麼處理器,當DOM監聽到 ng-click 被觸發時,這個指令就會通過 $apply() 方法執行相關的表達式操作或是别的操作進而更新視圖。
綜上,無論是哪種類型的指令,當外部事件(可能是使用者輸入,定時器,ajax等)發生時,相關的 表達式 必須要通過 $apply() 作用于相應的作用域,這樣所有的監聽器才能被正确更新,然後進行後續的相關操作。
可以建立作用域的指令
大多數情況下, 指令和作用域互相作用,但并不建立作用域的新執行個體。但是,有一些特殊的指令,如 ng-controller 和 ng-repeat 等,則會建立新的下級作用域,并且把這個新建立的作用域和相應的DOM元素相關聯。如前面說過的從DOM元素抓取作用域的方式(如果你還記得的話),就是調用 angular.element(aDomElement).scope() 方法。
作用域與控制器
作用域和控制器的互動大概有以下幾種情況:
控制器通過作用域對模版暴露一些方法供其調用,詳情見 ng-controller
控制器中定義的一些方法(譯注:行為或操作邏輯)可以改變注冊在作用域下的資料模型(也就是作用域的屬性)
控制器在某些場合可能需要設定 監聽器 來監聽作用域中的資料模型(model)。這些監聽器在控制器的相關方法被調用時立即執行。
作用域$watch 性能
因為在Angular中對作用域進行髒值檢查($watch)實時跟蹤資料模型的變化是一個非常頻繁的操作,是以,進行髒值檢查的這個函數必須是高效的。一定要注意的是,用 $watch 進行髒值檢查時,一定不要做任何的DOM操作,因為DOM操作拖慢甚至是拖垮整體性能的能力比在 JavaScript對象上做屬性操作高好幾個數量級。
與浏覽器事件輪循整合
下圖與示例描述了Angular如何與浏覽器事件輪循進行互動。
浏覽器的事件輪循等待事件到來,事件可以是使用者互動,定時器事件,或是網絡事件(如 ajax 傳回)
事件發生,其回調被執行,回調的執行就使得應用程式的執行上下文進入到了 JavaScript 的上下文。然後在 JavaScript的上下文中執行,并修改相關的DOM結構
一旦回調執行完畢,浏覽器就離開 JavaScript的上下文回到浏覽器上下文并基于DOM結構的改變重新渲染視圖
講了那麼多些,那麼Angular是怎麼在這裡橫插一杠呢?看圖,Angular是插進了 JavaScript的上下文中,通過提供Angular自己的事件處理輪循來改變正常的JavaScript工作流。它其實是把JavaScript上下文很成了兩塊:一個是傳統的JavaScript執行上下文(圖中淺藍色區域),一個是Angular的執行上下文(圖中淡黃色區域)。
隻有在Angular上下文執行的操作才會受益于Angular的資料綁定,異常處理,屬性檢測,等等。當然,如果不在Angular的上下文中,你也可以使用 $apply() 來進入Angular的執行上下文。
需要注意的是,$apply() 在Angular本身的很多地方(如控制器,服務等)都已經被隐式地調用了來處理事件輪循。
顯示地使用 $apply() 隻有在你從 JavaScript上下文或是從第三方類庫的回調中想要進入Angular時才需要。讓我們來看看具體的流程:
進入Angular執行上下文的方法,調用 scope.$apply(stimulusFn) 。上面 $apply() 中的參數 stimulusFn 是你想要讓它進入Angular上下文的代碼
進入 $apply() 之後,Angular執行 stimulusFn() ,而這個函數通常會改變應用程式的狀态(可能是資料,或是方法調用等)
之後,Angular進入 $digest 輪循。這個輪循是由兩個較小的輪循構成,一個是處理 $evalAsync 隊列(異步計算的隊列),另一個是處理 $watch 清單。 $digest 輪循不斷疊代變更(在 $eval 和 $watch 之間變更)直到資料模型穩定,這個狀态其實就是 evalAsync 隊列為空且$watch 清單不再監測到變化為止。(譯注:其實這裡就是所有外來的異步操作堆起來成為一個隊列,由$eval一個個計算,然後 $watch 看一下這個異步操作對應的資料模型是否還有改變,有改變,就繼續 $eval 這個異步操作,如果沒改變,那就拿異步操作隊列裡的下個異步操作重複上述步驟,直到異步操作隊列為空以及 $watch 不再監測到任何資料模型變化為止)
$evalAsync 隊列是用來安排那些待進入Angular$digest 的異步操作,這些操作往往是在浏覽器的視圖渲染之前,且常常是通過 setTimeout(0) 觸發。但是用 setTimeout(0) 這個方法就不得不承受緩慢遲鈍的響應以及可能引起的閃屏(因為浏覽器在每次事件發生後都會渲染一次)(譯注:這裡個人覺得不要了解的太複雜,按照上面第三點了解就夠用了,這邊個人翻譯的也不是太好,後期配以例子完善)
$watch 清單則是存放了一組經過 $eval 疊代之後可能會改變的Angular的表達式集合。如果資料模型變化被監測到,那麼 $watch 函數被調用進而用新值更新DOM。
一旦Angular的 $digest 輪循完成,那麼應用程式的執行就會離開Angular及 JavaScript的上下文。然後浏覽器重新渲染DOM來反映發生的變化
接下來是傳統的 Helloworld 示例(就是本節的第一個例子)的流程剖析,這樣你應該就能明白整個例子是如何在使用者輸入時産生雙向綁定的。
編譯階段:ng-model 和 input 指令 在 标簽中設定了一個 keydown 監聽器
在{{greeting}} 插值(也就是表達式)這裡設定了一個 $watch 來監測 username 的變化
執行階段:在 輸入框中按下 'X' 鍵引起浏覽器發出一個 keydown 事件
input 指令捕捉到輸入值的改變調用 $apply("username = 'X';") 進入Angular的執行環境來更新應用的資料模型
Angular将 username='X'; 作用在資料模型之上,這樣 scope.username 就被指派為 'X' 了
$watch 清單中監測到 username 有一個變化,然後通知 {{greeting}} 插值表達式,進而更新DOM
執行離開Angular的上下文,進而 keydown 事件結束,然後執行也就退出了 JavaScript的上下文;這樣 $digest 完成
浏覽器用更新了的值重新渲染視圖