1. 程式人生 > 實用技巧 >AngularJS 作用域與資料繫結機制

AngularJS 作用域與資料繫結機制

來源:https://developer.ibm.com/zh/articles/os-cn-AngularJS/

AngularJS 簡介

AngularJS 是由 Google 發起的一款開源的前端 MVC 指令碼框架,既適合做普通 WEB 應用也可以做 SPA(單頁面應用,所有的使用者操作都在一個頁面中完成)。與同為 MVC 框架的 Dojo 的定位不同,AngularJS 在功能上更加輕量,而相比於 jQuery,AngularJS 又幫您省去了許多機械的繫結工作。在一些對開發速度要求高,功能模組不需要太豐富的非企業級 WEB 應用上,AngularJS 是一個非常好的選擇。AngularJS 最為複雜同時也是最強大的部分就是它的資料繫結機制,這個機制幫助我們能更好的將注意力集中在資料的模型建立和傳遞上,而不是對底層的 DOM 進行低階的操作。

AngularJS 作用域

基於 jQuery 的傳統 WEB 應用中,為了監聽使用者的輸入等行為,需要為每一個 DOM 元素設定一個監聽方法,也即是監聽 DOM 上發生的各類事件,然後由 jQuery 做出迴應並展示在頁面上。這種方法簡便直觀,但是一旦 WEB 應用變得龐大而且複雜,那麼監聽程式碼就顯得非常的機械而且冗餘,更可怕的是,如果對於 DOM 的事件監聽沒有做好管理,那麼很容易出現瀏覽器資源的洩露。

針對以上所暴露的問題,AngularJS 用一系列指令來代替 jQuery 的事件繫結程式碼。為了能夠組織好各類指令之間的協調工作而不出現資料混亂,AngularJS 在模型層上引申出作用域的概念,以配合控制器來實現對檢視層的展現工作。

作用域(Scope)

AngularJS 中,作用域是一個指向應用模型的物件,它是表示式的執行環境。作用域有層次結構,這個層次和相應的 DOM 幾乎是一樣的。作用域能監控表示式和傳遞事件。

在 HTML 程式碼中,一旦一個 ng-app 指令被定義,那麼一個作用域就產生了,由 ng-app 所生成的作用域比較特殊,它是一個根作用域($rootScope),它是其他所有$Scope 的最頂層。

清單 1. 生成根作用域
<html>
 <head><script src="angular.min.js"></script></head>
<body data-ng-app="app">...</body> </html>
除了用 ng-app 指令可以產生一個作用域之外,其他的指令如 ng-controller,ng-repeat 等都會產生一個或者多個作用域。此外,還可以通過 AngularJS 提供的建立作用域的工廠方法來建立一個作用域。這些作用域都擁有自己的繼承上下文,並且根作用域都為$rootScope。

在生成一個作用域之後,在編寫 AngularJS 程式碼時,$scope 物件就代表了這個作用域的資料實體,我們可以在$scope 內定義各種資料型別,之後可以直接在 HTML 中以 {{變數名}} 方式來讓 HTML 訪問到這個變數,程式碼如下:

清單 2. 簡單的資料繫結
<script>
angular.module('app', [])
 .controller("ctrl", function ($scope) {
 $scope.btns = {
 ibm : 'ibm'
 };
 });
</script>
</head>
<body data-ng-app="app" >
 <div data-ng-controller="ctrl">
 <button>{{btns.ibm}}</button>
 </div>
</body>

這就是 AngularJS 中最簡單的資料繫結方式,同時也是應用最為廣泛的資料繫結方式。

繼承作用域(Inherited Scope)

AngularJS 在建立一個作用域時,會檢索上下文,如果上下文中已經存在一個作用域,那麼這個新建立的作用域就會以 JavaScript 原型繼承機制繼承其父作用域的屬性和方法(有個例外是孤立作用域,下文討論)。

一些 AngularJS 指令會建立新的子作用域,並且進行原型繼承: ng-repeat、ng-include、ng-switch、ng-view、ng-controller, 用 scope: true 和 transclude: true 建立的 directive。

以下 HTML 中定義了三個作用域,分別是由 ng-app 指令所建立的$rootScope,parentCtrl 和 childCtrl 所建立的子作用域,這其中 childCtrl 生成的作用域又是 parentCtrl 的子作用域。

清單 3. 作用域的繼承例項
<body data-ng-app="app">
 <div data-ng-controller="parentCtrl">
<input data-ng-model="args">
<div data-ng-controller="childCtrl">
 <input data-ng-model="args">
</div>
 </div>
</body>
繼承作用域符合 JavaScript 的原型繼承機制,這意味著如果我們在子作用域中訪問一個父作用域中定義的屬性,JavaScript 首先在子作用域中尋找該屬性,沒找到再從原型鏈上的父作用域中尋找,如果還沒找到會再往上一級原型鏈的父作用域尋找。在 AngularJS 中,作用域原型鏈的頂端是$rootScope,AnguarJS 將會尋找到$rootScope 為止,如果還是找不到,則會返回 undefined。

我們用例項程式碼說明下這個機制。首先,我們探討下對於原型資料型別的作用域繼承機制:

清單 4. 作用域繼承例項-原始型別資料繼承
<script type="text/javascript">
 angular.module('app', [])
 .controller('parentCtrl', ['$scope', function($scope) {
 $scope.args = 'IBM DeveloperWorks';
 }])
 .controller('childCtrl', ['$scope', function($scope) {
 }]);
</script>
<body data-ng-app="app">
 <div data-ng-controller="parentCtrl">
 <input data-ng-model="args">
<div data-ng-controller="childCtrl">
 <input data-ng-model="args">
</div>
 </div>
</body> 

執行頁面,我們得到以下的結果:
圖 1. 頁面執行結果。

這個結果我們非常好理解,雖然在 childCtrl 中沒有定義具體的 args 屬性,但是因為 childCtrl 的作用域繼承自 parentCtrl 的作用域,因此,AngularJS 會找到父作用域中的 args 屬性並設定到輸入框中。而且,如果我們在第一個輸入框中改變內容,內容將會同步的反應到第二個輸入框:

圖 2. 改變第一個輸入框的內容後頁面執行結果

假如我們修改第二個輸入框的內容,此時會發生什麼事情呢?答案是第二個輸入框的內容從此將不再和第一個輸入框的內容保持同步。在改變第二個輸入框的內容時,因為 HTML 程式碼中 model 明確繫結在 childCtrl 的作用域中,因此 AngularJS 會為 childCtrl 生成一個 args 原始型別屬性。這樣,根據 AngularJS 作用域繼承原型機制,childCtrl 在自己的作用域找得到 args 這個屬性,從而也不再會去尋找 parentCtrl 的 args 屬性。從此,兩個輸入框的內容所繫結的屬性已經是兩份不同的例項,因此不會再保持同步。

圖 3. 改變第二個輸入框的內容後頁面執行結果

假如我們將程式碼做如下修改,結合以上兩個場景,思考下會出現怎樣的結果?

清單 5. 作用域繼承例項-物件資料繼承
<script type="text/javascript">
angular.module('app', [])
 .controller('parentCtrl', ['$scope', function($scope) {
 $scope.args = {};
 $scope.args.content = 'IBM DeveloperWorks';
}])
.controller('childCtrl', ['$scope', function($scope) {
}]);
</script>
<body data-ng-app="app">
 <div data-ng-controller="parentCtrl">
 <input data-ng-model="args.content">
 <div data-ng-controller="childCtrl">
 <input data-ng-model="args.content">
 </div>
 </div>
</body>
答案是無論改變任何一個輸入框的內容,兩者的內容始終同步。

根據 AngularJS 的原型繼承機制,如果 ng-model 繫結的是一個物件資料,那麼 AngularJS 將不會為 childCtrl 建立一個 args 的物件,自然也不會有 args.content 屬性。這樣,childCtrl 作用域中將始終不會存在 args.content 屬性,只能從父作用域中尋找,也即是兩個輸入框的的變化其實只是在改變 parentCtrl 作用域中的 args.content 屬性。因此,兩者的內容始終保持同步。

我們再看一個例子,這次請讀者自行分析結果。

清單 6. 作用域繼承例項-不再訪問父作用域的資料物件。
<script type="text/javascript">
 angular.module('app', [])
 .controller('parentCtrl', ['$scope', function($scope) {
 $scope.args = {};
 $scope.args.content = 'IBM DeveloperWorks';
}])
.controller('childCtrl', ['$scope', function($scope) {
 $scope.args = {};
 $scope.args.content = 'IBM DeveloperWorks';
}]);
</script>
<body data-ng-app="app">
 <div data-ng-controller="parentCtrl">
 <input data-ng-model="args.content">
 <div data-ng-controller="childCtrl">
 <input data-ng-model="args.content">
 </div>
 </div>
</body>
答案是兩個輸入框的內容永遠不會同步。

孤立作用域(Isolate Scope)

孤立作用域是 AngularJS 中一個非常特殊的作用域,它只在 directive 中出現。在對 directive 的定義中,我們新增上一個 scope:{} 屬性,就為這個 directive 創建出了一個隔離作用域。

清單 7. directive 創建出一個孤立作用域
angular.module('isolate', []).directive("isolate", function () {
 return {
 scope : {},
 };
})
孤立作用域最大的特點是不會原型繼承其父作用域,對外界的父作用域保持相對的獨立。因此,如果在定義了孤立作用域的 AngularJS directive 中想要訪問其父作用域的屬性,則得到的值為 undefined。程式碼如下:
清單 8. 孤立作用域的隔離性
<script type="text/javascript">
 angular.module('app', [])
 .controller('ctrl', ['$scope', function($scope) {
 $scope.args = {};
 }])
 .directive("isolateDirective", function () {
 return {
 scope : {},
 link : function($scope, $element, $attr) {
 console.log($scope.$args); //輸出 undefined
 }
 };
});
</script>
<body data-ng-app="app">
 <div data-ng-controller="ctrl">
 <div data-isolate-directive></div>
 </div>
</body>

上面的程式碼中通過在 directive 中聲明瞭 scope 屬性從而建立了一個作用域,其父作用域為 ctrl 所屬的作用域。但是,這個作用域是孤立的,因此,它訪問不到父作用域的中的任何屬性。存在這樣設計機制的好處是:能夠創建出一些列可複用的 directive,這些 directive 不會相互在擁有的屬性值上產生串擾,也不會產生任何副作用。

AngularJS 孤立作用域的資料繫結

在繼承作用域中,我們可以選擇子作用域直接操作父作用域資料來實現父子作用域的通訊,而在孤立作用域中,子作用域不能直接訪問和修改父作用域的屬性和值。為了能夠使孤立作用域也能和外界通訊,AngularJS 提供了三種方式用來打破孤立作用域”孤立”這一限制。

單向繫結(@ 或者 @attr)

這是 AngularJS 孤立作用域與外界父作用域進行資料通訊中最簡單的一種,繫結的物件只能是父作用域中的字串值,並且為單向只讀引用,無法對父作用域中的字串值進行修改,此外,這個字串還必須在父作用域的 HTML 節點中以 attr(屬性)的方式宣告。

使用這種繫結方式時,需要在 directive 的 scope 屬性中明確指定引用父作用域中的 HTML 字串屬性,否則會拋異常。示例程式碼如下:

清單 9. 單向繫結示例
<script>
 angular.module('isolateScope', [])
 .directive("isolateDirective", function () {
 return {
 replace : true,
 template: '<button>{{isolates}}</button>',
 scope : {
 isolates : '@',
 },
 link : function($scope, $element, $attr) {
 $scope.isolates = "DeveloperWorks";
 }
 };
 })
 .controller("ctrl", function ($scope) {
 $scope.btns = 'IBM';
 });
</script>
<body data-ng-app="isolateScope" >
<div data-ng-controller="ctrl">
 <button>{{btns}}</button>
 <div data-isolate-directive data-isolates="{{btns}}"></div>
 </div>
</body>

簡單分析下上面的程式碼,通過在 directive 中聲明瞭 scope:{isolates:’@’} 使得 directive 擁有了父作用域中 data-isolates 這個 HTML 屬性所擁有的值,這個值在控制器 ctrl 中被賦值為’IBM’。所以,程式碼的執行結果是頁面上有兩個名為 IBM 的按鈕。

我們還注意到 link 函式中對 isolates 進行了修改,但是最終不會在執行結果中體現。這是因為 isolates 始終繫結為父作用域中的 btns 字串,如果父作用域中的 btns 不改變,那麼在孤立作用域中無論怎麼修改 isolates 都不會起作用。

引用繫結(&或者&attr)

通過這種形式的繫結,孤立作用域將有能力訪問到父作用域中的函式物件,從而能夠執行父作用域中的函式來獲取某些結果。這種方式的繫結跟單向繫結一樣,只能以只讀的方式訪問父作用函式,並且這個函式的定義必須寫在父作用域 HTML 中的 attr(屬性)節點上。

這種方式的繫結雖然無法修改父作用域的 attr 所設定的函式物件,但是卻可以通過執行函式來改變父作用域中某些屬性的值,來達到一些預期的效果。示例程式碼如下:

清單 10. 引用繫結示例
<script>
 angular.module('isolateScope', [])
 .directive("isolateDirective", function () {
 return {
 replace : true,
 scope : {
 isolates : '&',
 },
 link : function($scope, $element, $attr) {
 var func = $scope.isolates();
 func();
 }
 };
 })
 .controller("ctrl", function ($scope) {
 $scope.func = function () {
 console.log("IBM DeveloperWorks");
 }
 });
</script>
<body data-ng-app="isolateScope" >
 <div data-ng-controller="ctrl">
 <div data-isolate-directive data-isolates="func"></div>
 </div>
</body>

這個例子中,瀏覽器的控制檯將會輸出一段 “IBM DeveloperWorks” 文字。

上面的程式碼中我們在父作用域中指定了一個函式物件$scope.func,在孤立作用域中通過對 HTML 屬性的繫結從而引用了 func。需要注意的是 link 函式中對 func 物件的使用方法,$scope.isolates 獲得的僅僅是函式物件,而不是呼叫這個物件,因此我們需要在呼叫完 $scope.isolates 之後再呼叫這個函式,才能得到真正的執行結果。

雙向繫結(=或者=attr)

雙向繫結賦予 AngularJS 孤立作用域與外界最為自由的雙向資料通訊功能。在雙向繫結模式下,孤立作用域能夠直接讀寫父作用域中的屬性和資料。和以上兩種孤立作用域定義資料繫結一樣,雙向繫結也必須在父作用域的 HTML 中設定屬性節點來繫結。

雙向繫結非常適用於一些子 directive 需要頻繁和父作用域進行資料互動,並且資料比較複雜的場景。不過,由於可以自由的讀寫父作用域中的屬性和物件,所以在一些多個 directive 共享父作用域資料的場景下需要小心使用,很容易引起資料上的混亂。

示例程式碼如下:

清單 11. 雙向繫結示例
<script>
 angular.module('isolateScope', [])
 .directive("isolateDirective", function () {
 return {
 replace : true,
 template: '<button>{{isolates}}</button>',
 scope : {
 isolates : '=',
 },
 link : function($scope, $element, $attr) {
 $scope.isolates.ibm = "IBM";
 }
 };
 })
 .controller("ctrl", function ($scope) {
 $scope.btns = {
 ibm : 'ibm',
 dw : 'DeveloperWorks'
 };
 });
</script>
<body data-ng-app="isolateScope" >
 <div data-ng-controller="ctrl">
 <button>{{btns.dw}}</button>
 <button>{{btns.ibm}}</button>
 <div data-isolate-directive data-isolates="btns"></div>
 </div>
</body>

上面的程式碼執行的結果是瀏覽器頁面上出現三個按鈕,其中第一個按鈕標題為 “DeveloperWorks”,第二和第三個按鈕的標題為 “IBM”。

初始時父作用域中的 $scope.btns.ibm 為小寫的 “ibm”,通過雙向繫結,孤立作用域中將父作用域的 ibm 改寫成為大寫的 “IBM” 並且直接生效,父作用域的值被更改。

結束語

由於 AngularJS 框架的輕量性和其清晰的 MVC 特點使得其在推出之後就大受歡迎,實踐中也很容易上手。AngularJS 比較難以掌握和理解的就是其作用域和繫結機制,本文重點將作用域和繫結機制做了分析與討論,希望讀者能夠理解並熟練掌握這塊內容。