在标准的浏览器流程中,当事件被触发时,浏览器会执行注册给该事件的回调函数。页面加载、$http
请求返回响应、鼠标移动以及按钮被点击等情况都会触发事件。
当事件被触发时,JS就会创建一个事件对象,并执行这个事件对象所在的监听特定事件的所有函数。然后它会运行JS函数内的回调方法,这会回到浏览器中,还可能更新DOM。
同一时间不能运行两个事件。浏览器会等待前一个事件处理程序执行完成,再调用下一个事件处理程序。
在非Angular JS环境中,可以给div
的点击事件附加一个回调函数。只要发现元素上的点击事件,这个回调函数就会运行。
var div = document.getElementById("clickDiv");
div.addEventListener("click", function(evt) {
console.log("evt", evt);
});
无论何时,只要浏览器检测到点击事件,就会调用使用addEventListener
注册到文档上的函数。
当我们将Angular混入这个流程中时,它会扩展这个标准的浏览器流程,创建一个Angular上下文。这个Angular上下文指的是运行在Angular事件循环内的特定代码,该Angular事件循环通常被称作$digest
循环。$digest
循环有两个主要组成部分:
-
$watch
列表 -
$evalAsync
列表
$watch列表
每当我们在视图中追踪一个事件时,会给它注册一个回调函数,然后希望在触发该事件时调用这个回调函数。
<input ng-model="name" type="text" placeholder="Your name">
<h1>Hello {{ name }}</h1>
无论何时,只要用户更新这个输入字段,{{name}}
就会改变。发生这一变化是因为我们把输入字段绑定给了$scope.name
属性。为了更新这个视图,Angular需要追踪变化。它是通过给$watch
列表添加一个监控函数做到这一点的。
$scope
对象上的属性只会在其被用于视图时绑定。对于所有绑定给同一$scope
对象的UI元素,只会添加一个$watch
到$watch
列表中。这些$watch
列表会在$digest
循环中通过一个叫做“脏值检查”的程序解析。
脏值检查
脏值检查可归结为一个非常基础的概念:检查值是否发生了变化,而整个应用还没同步该变化。
Angular应用持续跟踪当前监控的值。Angular会遍历$watch
列表,如果从旧值更新后的值没有发生变化,它会继续遍历监控列表。如果值发生了变化,该应用会启用新值并继续遍历$watch
列表。
Anguar遍历完整个$watch
列表,只要有任何值发生变化,应用将会退回到$watch
循环中,直到检测到不再有任何变化。
为什么要再次运行这一循环?因为如果你更新了$watch
列表中某个用于更新另一个值的值,Angular将检测不到更新,除非再次运行这个循环。
如果这个循环运行10次或者更多次,Angular应用会抛出一个异常,同时停止运行。如果Angular没有抛出这个异常,应用就可能进入无限循环。
$watch
$scope
对象上的$watch
方法会给Angular事件循环内的每个$digest
调用装配一个脏值检查。如果在表达式上检测到变化,Angular总是会返回$digest
循环。
$watch
函数本身接受两个必要参数和一个可选的参数:
- watchExpression
watchExpression
可以是一个作用域对象的属性,或者是一个函数。在$digest
循环中的每个$digest
调用都会涉及它。
如果watchExpression
是一个字符串,Angular会在$scope
上下文中对它求值。如果它是一个函数,那么Angular会认为它会返回应该被监控的值。 - listener/callback
作为回调的监听器函数,它只会在watchExpression
的当前值与先前值不相等(除了首次运行初始化期间)时调用。 - objectEquality(可选)
objectEquality
是一个进行比较的布尔值,用来告诉Angular是否检查严格相等。
$watch
函数会给监听器返回一个注销函数,我们可以调用这个注销函数来取消Angular对当前值的监控。
//...
var unregisterWatch=$scope.$watch('newUser.email',function(newVal,oldVal){
if (newVal === oldVal) return; // 初始化
});
// 稍后,可以通过调用这个注销函数来注销这个监控器
unregisterWatch();
假如完成了对newUser.email
的监控,那么可以通过调用它所返回的注销函数来清除这个监控器。
例如,你想要解析一个输入字段的值,然后使用空格分割全名的方式找到名字和姓氏。假定给定的视图看起来像这样:
<input type="text" ng-model="full_name" placeholder="Enter your full name"/>
我们在full_name
属性上设置一个$watch
监听器来检测值的任意变化。
angular.module("myApp").controller("MyController",['$scope',function($scope){
$scope.$watch('full_name', function(newVal, oldVal, scope) {
// newVal表示在这里可以用的full_name新值
// 而oldVal表示full_name的旧值
});
}]);
监听函数会在初始化时被调用一次,而此时newVal
和oldVal
的值都是undefined
(并且是相等的)。在这种情况下,如果正处在初始化阶段或者先前的值发生了变化,通常最好是检查内部的表达式。在监控函数内很容易实现这一检查。
$scope.$watch('full_name',function(newVal,oldVal,scope) {
if(newVal === oldVal) {
// 只会在监控器初始化阶段运行
} else {
// 初始化之后发生的变化
}
});
在这段代码中,$scope.$watch()
函数在$scope
对象上为full_name
属性设置了一个监控表达式。
$watchCollection
此外,Angular还允许我们为对象的属性或者数组的元素设置浅监控,然后只要属性发生变化就触发监听器回调。
使用$watchCollection
还可以检测对象或数组何时发生了变化,以便确定对象或数组中的条目是何时添加、移除或者移动的。$watchConllection
的行为与$digest
循环中标准的$watch
的行为一样,我们甚至可以把它当作标准的$watch
。
$watchCollectiion()
函数接受2个参数。
- obj(字符串/函数)
这个对象就是一个要监控的对象。如果传入一个字符串,它将被当作Angular表达式求值。
如果传入的是一个函数,将在当前作用域中被调用,并且会返回要监控的值。 - listener(函数)
这个回调函数会在集合发生变化时触发。类似于$watch函数,这个函数会被来自$watch
的新集合触发调用,而原来的集合(先前集合的副本)以及所在的作用域也随之生效。
$watchConllection()
函数也返回一个注销函数。调用这个注销函数时,也会取消集合上的$watch
。
$scope.$watchCollection('names',function(newNames,oldNames,scope) {
// names集合已经发生了变化
});
页面中的$digest循环
首先,假设有一个登录页面,这个页面带有一个唯一的用户名字段,允许用户使用唯一的表单验证进行登录。
<h2>Sign in</h2>
<input type="text" placeholder="Your name" ng-model="name" ng-minlength="3" />
<input type="submit" ng-click="login()" value="Login" />
这里通过ng-model
指令在视图中绑定了一个name
,Angular会设置一个隐式的监控器,将这个输入字段的值绑定为当前的$scope
对象。当用户输入一个字符到表单中时,Angular上下文就会生效并开始遍历$$watchers
($watch
列表)。
在这个例子中,$watch
列表只包含了一个唯一的元素:$scope.name
。由于用户通过输入一个字符改变了输入字段的值,这个监控函数就会在$scope.name
绑定上执行。在我们退出$digest
循环之前,这一行为会触发在该值(由ng-model
绑定)上运行的验证和格式化操作。
由于在digest
循环中值发生了变化,Angular需要再次运行这一循环以确定它没有改变作用域对象上的其他值。
为什么要再次运行digest
循环?如果有一个名为$scope.full_name
的属性由$scope.first_name+$scope.last_name
组成,那么这些值的任何变化都会改变$scope.full_name
,因此循环需要再次执行以确认不再有任何变化了。
因为这里只改变了$scope.name
属性,并没有改变$scope
对象中的其他任何属性,所以$digest
循环会退出,然后浏览器会重绘DOM以刷新视图。
当用户在输入字段中输入他们的名字并点击提交按钮时,会引发一个略有不同的流程。
ng-click
为DOM元素绑定了浏览器原生的click
事件。当这个DOM元素收到点击事件时,ng-click
指令会调用$scope.$apply()
,同时进入$digest
循环。
$evalAsync 列表
$evalAsync()
方法是一种在当前作用域上调度表达式在未来某个时刻运行的方式。$digest
循环运行的第二个操作是执行$$asyncQueue
。可以使用$evalAsync()
方法访问这个工作队列。
$digest
循环期间,贯穿脏值检查生命周期的每个循环之间的队列都是空的,这意味着使用$evalAsync
来调用任何函数都会发生两件事情。
- 函数会在这个方法被调用的某个时刻之后执行。
- 表达式求值之后至少会执行一次
$digest
循环。
$evalAsync()
方法接受一个唯一参数:expression
(字符串/函数)。
这个表达式便是我们想要在当前作用域上执行的东西。如果传入一个字符串,Angular将会在当前作用域上使用$eval
求值该表达式。
如果传入的是一个函数,Angular将会使用传递给这个函数的scope
对象执行函数求值。
$scope.$evalAsync('attribute',function(scope) {
scope.foo = "Executed"
});
使用$evalAsync
时要注意的一些细节。
- 如果指令直接调用
$evalAsync()
,它会在Angular操作DOM之后、浏览器渲染之前运行。 - 如果控制器调用
$evalAsync()
,它也会在Angular操作DOM之后、浏览器渲染之前运行(永远不要使用$evalAsync()
来约定事件的顺序)。
无论何时,在Angular中,只要你想要在一个行为的执行上下文外部执行另一个行为,就应该使用$evalAsync()
函数。
你还可以使用它替代setTimeout()
函数,但是它可能在浏览器重新渲染视图之后导致屏幕闪烁。
$apply
$apply()
函数可以从Angular框架的外部让表达式在Angular上下文内部执行。例如,假设你实现了一个setTimeout()
或者使用第三方库并且想让事件运行在Angular上下文内部时,就必须使用$apply()
。
$apply()
函数接受一个可选的参数:expression
(字符串/函数)。
这个表达式可选地接受一个字符串或函数,并且是在当前作用域内执行。
如果传入一个字符串,$apply()
首先会在这个字符串上调用$eval()
,以强制Angular在局部作用域上下文中使用$eval()
运行字符串表达式。
如果传入一个函数,这个函数将会在所传入的函数作用域上执行。
$exceptionHandler
服务会捕获和处理$eval()
方法抛出的所有异常。最后,$apply()
方法还会直接调用$digest
循环。
// 使用要eval的字符串调用$apply示例
$scope.$apply('message = "Hello World"');
// 使用函数的方式并给函数传入一个作用域
$scope.apply(function(scope) {
// 然后在函数中使用传入作用域
scope.message = "Hello World";
});
// 使用函数时忽略作用域
$scope.$apply(function() {
$scope.message = "Hello World";
});
// 或者通过在操作的尾部调用$apply()以强制运行$digest循环
$scope.apply();
简而言之,使用$scope.$apply()
时可以从外部进入上下文。
如果在事件被触发时调用$apply()
,就会使用Angular事件循环来运行它。如果没有调用$apply()
,就不会在事件循环内执行这个函数,而它会运行在Angular上下文外部。
何时使用$apply
通常可以依赖于Angular提供的可用于视图中的任意指令来调用$apply()
。所有ng-[event]
指令(比如ng-click
、ng-keypress
)都会调用$apply()
。
此外还可以依赖于一系列Angular内置的服务来调用$digest()
。比如$http
服务会在XHR请求完成并触发更新返回值(promise
)之后调用$apply()
。
无论何时我们手动处理事件,使用第三方框架(比如jQuery
),或者调用setTimeout()
,都可以使用$apply()
函数让Angular返回$digest
循环。
一般不建议在控制器中使用$apply()
,因为这样会导致难以测试,而且如果不得不在控制器中使用$apply()
或者$digest()
,很可能让事情变得更加难以理解。
当我们将jQuery和Angular集成在一起时,就需要使用$apply()
,因为Angular不会察觉到执行在Angular上下文外部的事件。例如,在使用jQuery插件时,就需要使用$apply()
将来自jQuery的值传递到Angular应用中。
在这里,我们构建了一个简单的指令,指令中我们在元素上使用了datepicker
这个jQuery插件方法。datepicker
插件暴露了一个onSelect
事件,这个事件会在用户选择日期时触发。为了在Angular应用内部获取用户选择的日期,我们需要在$apply()
函数内运行datepicker
的回调函数。
ele.datepicker()
函数是由jQuery datepicker
插件提供的可用于DOM元素的属性方法。ctrl.$setViewValue()
函数是在DOM元素上使用ng-model
时提供的指令。
app.directive('myDatepicker',function() {
return function(scope,ele,attrs,ctrl) {
$(function() {
// 在元素上调用datepicker方法
ele.datepicker({
dateFormat: 'mm/dd/yy',
onSelect: function(date) {
scope.$apply(function() {
ctrl.$setViewValue(date);
});
}
});
});
}
});