5.3 作用域高级特性
前面介绍了AngularJS作用域的基本概念以及作用域原型继承机制,本节将介绍AngularJS作用域的一些高级特性,包括作用域属性监视、digest循环等。
5.3.1 $watch方法监视作用域
在使用AngulaJS框架编写应用时,我们经常需要做的一件事情就是对作用域中的属性进行监视,并对其发生的变化做出相应的处理。AngularJS为我们提供了一个非常方便的$watch()方法,可以帮助我们监视作用域中属性的变化。下面给出一个使用$watch()方法监视作用域属性变化的案例:
代码清单:ch05\ch05_05.html
<!doctype html> <html ng-app="watchModule"> <head> <meta charset="UTF-8"> <title>ch05_05</title> <script type="text/javascript" src="../angular-1.5.5/angular.js"> </script> </head> <body> <input ng-model='name' type='text'/> <div>change count: {{count}}</div> <script> angular.module('watchModule', []) .run(['$rootScope', function($rootScope){ $rootScope.count = 0; $rootScope.name = ’江荣波’; $rootScope.$watch('name', function(){ $rootScope.count++; }) }]); </script> </body> </html>
这段代码非常简单,使用作用域对象的$watch()方法对$rootScope中的name属性进行监视,并在它发生变化的时候将$rootScope中的count属性值增加1。因此,每当我们对输入框中的内容进行一次修改时,界面显示的change count数值就会增加1。
在AngularJS内部,每当我们对ng-model绑定的name属性进行一次修改时,AngularJS内部的$digest循环就会运行一次,并在运行结束之后检查我们使用$watch()方法来监视的内容,如果和上一次进行$digest之前相比有了变化,就执行我们使用$watch()方法绑定的处理函数。
然而,我们在实际运用中常常不只是对一个原始类型的属性进行监视,如果读者还记得JavaScript语言中的6种数据类型,就一定会记得基本类型(数字、字符串)和引用类型的区别。对于基本类型,如果我们使用了一个赋值操作,这个基本类型变量就会“真正”被复制一次,然而对于引用类型,在进行赋值时,仅仅是将赋值的变量指向了这个引用类型。在AngularJS作用域对象的$watch()方法中,对基本类型和引用类型的操作有所不同。基本类型,就像我们上面的例子,没有什么特别之处,然而要对一个引用类型进行监视时,尤其是在实际运用中常见的数组对象,情况就不一样了。我们来看下面的例子:
代码清单:ch05\ch05_06.html
<! doctype html> <html ng-app="watchModule"> <head> <meta charset="UTF-8"> <title>ch05_06</title> <script type="text/javascript" src="../angular-1.5.5/angular.js"> </script> </head> <body> <div ng-repeat='item in items'> <input ng-model='item.value'/> <span>{{item.value}}</ span><br/><br/> </div> <div>change count: {{count}}</div> <script> angular.module('watchModule', []) .run(['$rootScope', function($rootScope){ $rootScope.count = 0; $rootScope.items = [ { "value": 1 }, { "value": 2 }, { "value": 3 }, { "value": 4 } ] $rootScope.$watch('items', function(){ $rootScope.count++; }) }]); </script> </body> </html>
在浏览器中运行ch05_06.html页面,如图5.1所示。
图5.1 作用域监视案例
此时读者如果对上面4个文本框中内容进行修改,就会发现count值始终不变,这是怎么回事?难到$watch方法不起作用了吗?
正如前面提到的,$watch()方法在对待基本类型和引用类型时会有不同的处理方式,这需要首先介绍一下$watch()方法的第三个参数。在前面的例子中,我们知道,$watch()方法可以接收两个参数,第一个参数是需要监视的属性,第二个参数是在监视属性发生变化时需要回调的方法,实际上$watch()方法还能接收第三个参数,默认情况下参数值为false。
在默认情况下,即不显式指明第三个参数或者将其指明为false时,我们进行的监视叫作“引用监视”(reference watch),也就是说只要监视的对象引用没有发生变化,就不算它发生变化。
具体来说,在上面的例子中,只要是items数组的引用没有发生变化,就算items数组中的一些元素属性发生了变化,$watch()方法也不会执行回调方法。那么在什么时候算是引用发生了变化呢?比如说将一个新的数组newItems赋值给items,此时$watch才会认为监视的属性发生了变化,进而调用回调方法。
相反,将$watch()方法的第三个变量设置为true,此时进行的监视就叫作“全等监视”(equality watch)。此时只要监视的属性发生变化,$watch就会执行相应的回调方法。
读者可参考ch05_07.html案例,在$watch方法增加第三个参数值并设为true,在浏览器中预览就会发现只要任意一个文本框内容发生变化,count的值就会增加。
既然全等监视这么好,那么我们为什么不直接用全等监视呢?当然,任何事情都有好坏两个方面,全等监视固然好,但是在运行时需要先遍历整个监视对象,然后在每次$digest之前使用angular.copy()将整个对象深复制一遍,然后在运行之后用angular.equal()将前后的对象进行对比。上面例子中的items比较简单,因此可能在性能上不会有什么差别,但是到了实际生产环境时,我们要面对的数据千千万万,可能因为全等监视这一个设置就消耗大量的资源,让应用停滞不前。因此需要在使用时进行权衡,再决定究竟使用哪一种监视方式。
除了上面提到的两种方式之外,在angular1.1.4版本之后,新增加了一个$watchCollection()方法来针对数组(也就是集合)进行监视,它的性能介于全等监视和引用监视之间,即它并不会对数组中每一项的属性进行监视,但是可以对数组的项目增减做出反应。还是上面的例子,我们稍做修改,完整代码可参考ch05_08.html:
angular.module('watchModule', []) .run(['$rootScope', function($rootScope){ $rootScope.count = 0; $rootScope.items = [ { "value": 1 }, { "value": 2 }, { "value": 3 }, { "value": 4 } ] $rootScope.$watchCollection('items', function(){ $rootScope.count++; }) }]);
如上面的代码所示,把$watch()方法修改为$watchCollection()方法,如果改变了items[0]的value属性值,$watch()方法并不会做出反应,但是如果我们在items上push或者pop一个元素,$watch()方法就会执行回调方法了。
5.3.2 作用域监视解除
上一小节我们学习了AngularJS作用域监视机制,本小节介绍如何解除作用域监视。这需要我们关注一下$watch()方法的返回值,该方法调用完毕后返回另一个方法,我们只需调用返回的方法即可解除作用域监视。我们来看下面的例子:
代码清单:ch05\ch05_09.html
<!doctype html> <html ng-app=”watchModule”> <head> <meta charset=”UTF-8”> <title>ch05_09</title> <script type=”text/javascript” src=”../angular-1.5.5/angular.js”> </script> </head> <body> <input ng-model='num' type='number'/> <div>change count: {{count}}</div> <script> angular.module('watchModule', []) .run(['$rootScope', function($rootScope){ $rootScope.count = 0; $rootScope.num = 100; var unbindWatcher= $rootScope .$watch('num', function(newValue, oldVaule){ if(newValue == 2){ unbindWatcher(); } $rootScope.count++; }) }]); </script> </body> </html>
在浏览器中运行ch05_09.html页面,效果如图5.2所示。
图5.2 作用域监视解除案例
在本案例中,最初文本框内容发生改变时count值会累加,当文本框值为2时,我们调用$watch()返回的方法unbindWatcher()解除了作用域监视,所以再次修改本文框内容时count值不再累加。
5.3.3 $apply方法与$digest循环
$apply与$digest是AngularJS中两个核心的概念,如果读者想深入研究AngularJS双向数据绑定实现原理,就必须先了解这两个概念,为了方便说明问题,我们先看下面的代码片段:
<input id="input" type="text" ng-model="name"/> <div id="output">{{name}}</div>
当我们写下AngularJS表达式(例如{{name}})时,AngularJS框架会在幕后为我们在$scope中设置一个watcher,用来在数据发生变化的时候更新View。这里的watcher和5.3.2小节中手动调用$watch方法添加的watcher是一样的:
$scope.$watch('name', function(newValue, oldValue){ //update the DOM with newValue });
如上面的代码所示,$watch()方法的第二个参数是一个回调方法,该方法会在name属性的值发生变化的时候被调用。当name发生变化的时候,这个回调方法会被调用来更新View,这点不难理解,但是,还存在一个很重要的问题!AngularJS是如何知道什么时候要调用这个回调方法的呢?换句话说,AngularJS是如何知晓name属性发生了变化才调用了对应的回调方法呢?它会周期性地运行一个函数来检查scope模型中的数据是否发生了变化,这就是所谓的$digest循环。
在$digest循环中,watchers会被触发。当一个watcher被触发时,AngularJS会检测scope模型,如果它发生了变化,那么关联到该watcher的回调方法就会被调用。那么,$digest循环是在什么时候以各种方式开始的呢?
在调用了$scope.$digest()后,$digest循环就开始了。假设你在一个ng-click指令对应的事件处理方法中更改了scope中的一条数据,此时AngularJS会自动地通过调用$digest()来触发一轮$digest循环。当$digest循环开始后,它会触发每个watcher。这些watcher会检查scope中的当前属性值是否和上一次$digest循环时的属性值相同。如果不同,对应的回调方法就会被执行。调用该方法的结果就是View中的表达式内容被更新。除了ng-click指令,还有一些其他的AngularJS内置指令和服务来让我们能够更改模型数据(比如ng-model指令、$timeout服务等)和自动触发一次$digest循环。
除此之外,还有一个问题,在上面的例子中,AngularJS并不直接调用$digest()方法,而是调用$scope.$apply(),后者会调用$rootScope.$digest()。因此,一轮$digest循环在$rootScope开始,随后会访问所有子作用域中的watcher。
5.3.4 $apply与$digest应用实战
当AngularJS作用域中的模型数据发生变化时,AngularJS会自动触发$digest循环,从而达到自动更新视图的目的。但是在有些情况下,模型数据修改后需要我们手动调用$apply()方法来触发$digest循环,例如使用JavaScript的setTimeout()方法来更新一个模型数据,AngularJS框架就没有办法知道我们修改了什么,也就无法触发$digest循环了。下面的案例就能说明这种情况:
代码清单:ch05\ch05_10.html
<!doctype html> <html ng-app=”msgModule”> <head> <meta charset=”UTF-8”> <title>ch05_10</title> <script type=”text/javascript” src=”../angular-1.5.5/angular.js”> </script> </head> <body> <div ng-controller=”MsgController”> <div> <button ng-click=”scheduleTask()">3秒后回显信息 </button> </div> <div> {{message}} </div> </div> <script> angular.module('msgModule', []).controller('MsgController', function($scope){ $scope.scheduleTask = function() { setTimeout(function() { $scope.message = ’信息内容’; console.log('message='+$scope.message); }, 3000); } }); </script> </body> </html>
如上面的代码所示,我们使用ng-click指令对按钮单击事件进行事件绑定,单击按钮时会调用scheduleTask()方法,在该方法中使用setTimeout()方法3s后设置message属性的内容。在浏览器中预览ch05_10.html页面,效果如图5.3所示。
图5.3 $digest循环无法触发案例
单击按钮3s后,控制台中输出信息,说明我们在作用域中添加message属性成功,但是页面中并没有回显任何内容,这种情况下AngularJS框架无法自动触发$digest循环,需要我们手动调用$apply()方法来触发$digest循环。$apply()方法接收一个方法作为参数。我们对上面的控制器代码进行修改,下面只列出关键代码片段,完整代码可参考ch05\ch05_11.html。
angular.module('msgModule', []).controller('MsgController', function($scope){ $scope.scheduleTask = function() { setTimeout(function() { $scope.$apply(function(){ $scope.message = ’信息内容’; console.log('message='+$scope.message); }); }, 3000); } });
如上面的黑体代码所示,我们把修改作用域属性的代码移到一个匿名方法中,然后把该匿名方法作为$apply()方法的参数,这样就可以触发$digest循环了。在浏览器中预览ch05_11. html,效果如图5.4所示。
图5.4 调用$apply方法触发$digest实例
单击按钮,3秒过后,作用域中message属性内容已经能够回显到页面中,说明我们手动调用$apply()方法触发$digest循环成功。
5.3.5 $timeout与$interval服务介绍
在5.3.4小节中我们使用JavaScript的setTimeout()方法达到延迟执行某个方法的效果。JavaScript中还有一个与setTimeout()类似的方法setInterval(),作用是每隔一段时间调用一次特定的JavaScript方法。这两个方法都需要我们手动调用$apply()方法来触发$digest循环。
使用AngularJS内置的指令或服务通常不需要我们手动调用$apply()方法触发$digest循环,AngularJS为我们提供了两个实用的服务$timeout和$interval,功能和setTimeout()、setInterval()方法相同,使用这两个服务修改作用域属性时会自动触发$digest循环,我们可以对5.3.4小节的案例进行修改,使用$timeout服务实现,控制器代码如下,完整代码请参考ch05\ch05_12.html。
angular.module('msgModule', []).controller('MsgController', function($scope, $timeout){ $scope.scheduleTask = function() { $timeout(function() { $scope.message = ’信息内容’; console.log('message='+$scope.message); }, 3000); } });
在浏览器中预览ch05_12.html,效果和5.3.4小节相同,$interval的使用方法和$timeout类似,这里不再赘述。