AngularJS讲义 - 作用域
什么是作用域?
Angular中作用域(scope)是模板以及工作的上下文环境,作用域中存放了应用模型和视图相关的回调行为。作用域是层次化结构的与相关联的DOM结构相对应。作用域可以观察表达式以及传播事件。
原文: scope is an object that refers to the application model. It is an execution context for expressions. Scopes are arranged in hierarchical structure which mimic the DOM structure of the application. Scopes can watch expressions and propagate events.
作用域的特性
作用域提供了相关的APIs($watch)来监控模型的状态并且将Angular系统(视图、服务、事件处理器)内部的模型的变化同步到视图。
作用域可以嵌套来控制应用组件对模型属性的访问。嵌套的作用域可以是“父子”关系或者"同级"关系。子作用域可以继承父作用域的属性,相邻作用域是互补可见的。
作用域提供了表达式的上下文环境。例如表达式{{username}}只有在定义了username属性的作用域中才有意义。
作用域作为数据模型
作用域是连接Angular控制器和视图的中间地带。指令会在模板链接阶段(linking)在作用域中建立对表达式的监控($watch)服务。这样$watch就可以将模型属性的变化情况及时通知给指令从而更新视图。
控制器和指令只能通过作用域连接,不可以直接关联。这样就实现了控制器和视图的解耦。这样就可以实现一套模型绑定多个视图,也提高了前端代码的可测性。
index.html
script.js
1 angular.module('scopeExample', [])
2 .controller('MyController', ['$scope', function($scope) {
3 $scope.username = 'World';
5 $scope.sayHello = function() {
6 $scope.greeting = 'Hello ' + $scope.username + '!';
7 };
8 }]);
上述例子说明了作用域的工作原理:
1. 在MyController控制器中定义了username属性, 并在输入文本控件中绑定了该属性。username被初始化为'World',这样作用域会通知文本框中并在文本框预填入初始值。
2. 同样控制器在作用域中定义了sayHello行为,并通过ng-click注册到按钮的点击事件。当用户在input中输入其他值时,会通过作用域更新username属性,从而改变sayHello的结果。
运行结果:
{{greeting}}表达式的工作原理如下:
1. 先找到{{greeting}}表达式所在DOM相关的作用域。在此例中为MyController中的$scope.
2. 在作用域中找到greeting属性并替换{{greeting}}, 既而更新了视图。
scope及其属性提供了用来展现视图的数据。(原文: The scope is the single source-of-truth for all things view related.)
从测试的角度考虑, 视图与控制器分离是必要的, 这样我们就可以单独测试视图后面的行为而不用考虑视图的细节。
1 it('should say hello', function() { 2 var scopeMock = {}; 3 var cntl = new MyController(scopeMock); 5 // Assert that username is pre-filled 6 expect(scopeMock.username).toEqual('World'); 8 // Assert that we read new username and greet 9 scopeMock.username = 'angular'; 10 scopeMock.sayHello(); 11 expect(scopeMock.greeting).toEqual('Hello angular!'); 12 });
作用域的层次化结构
每个Angular应用都有一个根作用域(root scope), 在根作用域下可以有多个子作用域。一些指令(directives)也会创建子作用域。 新的作用域会被添加到相应的父作用域上,这样就形成了与DOM视图相平行的树形结构。
让我们通过一个具体的例子来理解:
1 <div class="show-scope-demo"> 2 <div ng-controller="GreetController"> 3 Hello {{name}}! 4 </div> 5 <div ng-controller="ListController"> 6 <ol> 7 <li ng-repeat="name in names">{{name}} from {{department}}</li> 8 </ol> 9 </div> 10 </div>
输出:
对应的DOM结构:
我们可以注意到Angular会自动给绑定作用域的DOM元素加上"ng-close"类, CSS文件中给ng-scope类的元素加了高亮显示。为<li>创建子作用域是必要的,因为每个<li>都会有{{name}}表达式指向自己的name属性。{{department}}中department则继承根作用域$rootScope.department属性。
获取DOM的作用域
作用域是与视图相关联的,我们可以在debug的时候通过api获取绑定到视图的作用域。根作用域(root scope)定义在含有ng-app指令的DOM元素上。通常ng-app放在<html>元素中, 当然ng-app也可以放在任何DOM元素上,例如我们只想局部视图被angular控制。
在Chrome中我们只需右击然后选择“审查元素”选项进入调试界面。
通过$0便可获得当前选中的DOM元素。
通过angular.element($0).scope()或者$scope可以获得当前元素对应的作用域。
作用域事件传播
类似DOM事件,我们可以在作用域间传播事件。事件可以被广播($broadcast)到子作用域或者向上传播到父作用($emit)域中。
让我们看具体的例子:
1 <div ng-controller="EventController"> 2 Root scope <tt>MyEvent</tt> count: {{count}} 3 <ul> 4 <li ng-repeat="i in [1]" ng-controller="EventController"> 5 <button ng-click="$emit('MyEvent')">$emit('MyEvent')</button> 6 <button ng-click="$broadcast('MyEvent')">$broadcast('MyEvent')</button> 7 <br> 8 Middle scope <tt>MyEvent</tt> count: {{count}} 9 <ul> 10 <li ng-repeat="item in [1, 2]" ng-controller="EventController"> 11 Leaf scope <tt>MyEvent</tt> count: {{count}} 12 </li> 13 </ul> 14 </li> 15 </ul> 16 </div>
1 angular.module('eventExample', [])
2 .controller('EventController', ['$scope', function($scope) {
3 $scope.count = 0;
4 $scope.$on('MyEvent', function() {
5 $scope.count++;
6 });
7 }]);
8
在HTML模板中,我们通过ng-click注册了点击事件监听,分别来向子作用域广播和向上传播MyEvent时间,在控制器我们通过$scope.$on('MyEvent')监听事件。
运行结果如下:
作用域的生命周期
通常浏览器接收一个事件时会执行一段Javascript回调执行相关的处理,等回调执行完会自动更新DOM继续等待处理新事件。当浏览器调用的js代码不在angular执行上下文中,angular将不会注意模型的更改。我们可以通过$apply方法,让模型的更改在angular的上下文中进行。也就是说只有在$apply过程中的模型更改才会被angular甄别。例如,某个指令监听DOM事件(比如ng-click),必须在$apply方法中执行angular表达式。
在表达式执行完成后,$apply会执行$digest。在$digest过程中,作用域会检查所有$watch的表达式,比较表达式的当前状态和上一次状态。这种脏数据(dirty data)的检查是异步的。也就是说给模型属性赋予新的值时,$watch不会立即被通知,通知$watch发生在$digest阶段。这个短暂的延迟是有原因的,angular会批量通知$watch模型的状态情况,并且保证同时只有一个$watch在进行。如果$watch改变了模型的状态,会再强行触发一次$digest过程:
1. 创建 - 更作用域会在应用启动时通过注入器创建并注入。在模板连接阶段,一些指令会创建自己的作用域。
2. 注册观察者 - 在模板连接阶段,将会注册作用域的监听器。这也监听器被用来识别模型状态改变并更新视图。
3. 模型状态改变 - 更新模型状态必须发生在scope.$apply方法中才会被观察到。Angular框架封装了$apply过程,无需我们操心。
4. 观察模型状态 - 在$apply结束阶段,angular会从根作用域执行$digest过程并扩散到子作用域。在这个过程中被观察的表达式或方法会检查模型状态是否变更及执行更新。
5. 销毁作用域
当不再需要子作用域时,通过scope.$destroy()销毁作用域,回收资源。
作用域和指令
在编译(compiling)阶段,angular编译器会将DOM模板和指令匹配绑定。指令一般可分为以下两类:
1. 观察指令(Observing directives), 例如表达式, 通过$watch方法注册监听。这类指令在表达式变化时会被通知从而更新视图。
2. 监听器指令(Listener directives), 例如ng-click会在DOM元素上注册监听事件。DOM事件会触发指令执行相关的表达式或者通过$apply更新视图。
当接收到外部事件时(用户动作,计时器或者ajax相关的事件),相关作用域的表达式必须通过$apply方法执行,确保所有监听器状态被正确的更新。
哪些指令会创建作用域?
在大多数情况下,指令不会自行创建自己的作用域。但一些指令,例如ng-controller, ng-repeat等会创建子作用域和DOM绑定。我们可以通过angular.element(aDomElement).scope()
获取和DOM元素相关联的作用域。
控制器和作用域
控制器和作用域可以通过以下方式交互:
1. 控制器通过作用域暴露给模板相关的行为。
2. 控制器定义可以操作模型的方法。
3. 控制器可以通过$watch注册监听模型的状态。这些watch会立即在控制器方法执行后被触发执行。
angular会自动检查作用域内模型状态的变更,这些检查并不触及DOM操作,而只是检查作用域的属性。
出于对性能的考虑,对不同数据类型(引用、集合、 值)的检查会有不同的策略(参考图中的决策树):
检查引用 - 当表达式返回一个新的对象或者数组时,scope.$watch(watchExpression, listener)不会再具体对象里面的具体内容,而是比较引用是否指向新的内存地址。
检查集合的内容 - 当对集合类型的数据增加、删除元素或者排序时,$scope.$watchCollection(watchExpression, listener)会检查集合中的元素。对集合中内容的检查是Shallow的, 即不会检查嵌套在集合中的集合或者对象。这种策略试图减少对内存资源的消耗。
检查值 - 根据模型的数据结构,scope.$watch (watchExpression, listener, true)
深度遍历数据结构中的每个域的值,这种策略是最强大的但需要相当大的资源开销。
与浏览器事件的交互
我们将结合下图分析angular与浏览器事件交互的工作原理:
1. 浏览器的事件环路会一直等待事件发生,这里的事件包括用户的交互,计时器,网络事件等。
2. 事件会触发监听器在js上下文环境中调用相关回调方法更新DOM结构。
3. 回调结束后,浏览器会脱离js上下文环境,基于DOM的变动重新渲染视图。
angular在以上的Javascript流程中加入了自己的事件处理机制,把js分成了传统的js上下文和angular执行的上下文。angular中的操作会得益于angular的数据绑定机制、异常处理和属性监听等框架特性。我们也可以通过$apply进入angular的执行上下文环境。
在大多数情况下(控制器、服务),angular会自动给我们调用$apply处理事件。除非是实现自定义的事件及回调或者是与第三方库结合使用时才会显示调用$apply进入angular上下文环境。$apply工作步骤如下:
1. 通过调用scope.$apply(stimulusFn)进入angular上下文环境,stimulusFn为希望在angular上下文中执行的代码。
2. 通常stimulusFn用来改变应用的状态。
3. angular进入$digest环路,$digest环路包括两个子循环分别处理$evalAsync队列和$watch列表。$digest会持续迭代直到$evalAsync队列清空并且$watch列表中没有任何状态更新。
4. $evalAsync队列被用来调度图中右半部分渲染DOM之前的子任务。
5. $watch列表中包含了上一次迭代后变化的表达式。当相关的模型变化是,$watch会更新表达式的值并更新视图。
6. 当$digest循环终止即离开Angular和Javascript的上下文环境,浏览器会随之更新视图。
基于上述原理我们详解一下"Hello World"例子中的数据绑定是怎么实现的:
1. 在angular编译阶段:
- ng-model和input指令在<input>控件中建立了"keydown"事件的监听器
- angular通过(interpolation)建立$watch对name属性的跟踪
2. 在运行阶段:
- 键入’x‘ 即触发keydown事件, input指令会捕获输入的变化调用$apply("name = 'x'"),更新模型数据。
- 更新完成后进入$digest循环,$watch列表检测到name属性发生了变化并通知interpolation,更新DOM视图。
- angular退出运行上下文,从而退出了keydown事件和与之相关的js上下文环境。
- 浏览器检测到DOM变化重新展现视图。