这篇博文主要是写给新手的,是给那些刚刚开始接触Angular,并且想了解数据帮定是如何工作的人。如果你已经对Angular比较了解了,那强烈建议你直接去阅读源代码。
Angular用户都想知道数据绑定是怎么实现的。你可能会看到各种各样的词汇:$watch
,$apply
,$digest
,dirty-checking
... 它们是什么?它们是如何工作的呢?这里我想回答这些问题,其实它们在官方的文档里都已经回答了,但是我还是想把它们结合在一起来讲,但是我只是用一种简单的方法来讲解,如果要想了解技术细节,查看源代码。
剧情开始之前,先介绍一下重要背景~三个概念~
Dirty Checking – AngularJS内部比较value现在的值和之前的值,如果发生了改变,就触发change事件。
Digest – 执行
Dirty Checking
的机制,由$digest()触发。Apply – 当dom事件在AngularJS机制外被触发时,需要通知AngularJS进行
Digest
。由$apply()
触发。
每次你绑定一些东西到你的UI上时你就会往$watch队列里插入一条$watch
。想象一下$watch
就是那个可以检测它监视的model里时候有变化的东西。例如你有如下的代码
index.html
User: <input type="text" ng-model="user" />Password: <input type="password" ng-model="pass" />
在这里我们有个$scope.user
,他被绑定在了第一个输入框上,还有个$scope.pass
,它被绑定在了第二个输入框上,然后我们在$watch list
里面加入两个$watch
:
在这里我们有个$scope.user
,他被绑定在了第一个输入框上,还有个$scope.pass
,它被绑定在了第二个输入框上,然后我们在$watch list
里面加入两个$watch
:
controllers.js
app.controller('MainCtrl', function($scope) { $scope.foo = "Foo"; $scope.world = "World";});
index.html
Hello, {{ World }}
这里,即便我们在$scope
上添加了两个东西,但是只有一个绑定在了UI上,因此在这里只生成了一个$watch
.
再看下面的例子:controllers.js
app.controller('MainCtrl', function($scope) { $scope.people = [...];});
index.html
<ul> <li ng-repeat="person in people"> {{person.name}} - {{person.age}} </li></ul>
这里又生成了多少个$watch
呢?每个person有两个(一个name,一个age),然后ng-repeat又有一个,因此10个person一共是(2 * 10) +1
,也就是说有21个$watch
。
因此,每一个绑定到了UI上的数据都会生成一个$watch
。对,那这写$watch
是什么时候生成的呢?
当我们的模版加载完毕时,也就是在linking阶段(Angular分为compile阶段和linking阶段---译者注),Angular解释器会寻找每个directive,然后生成每个需要的$watch
。听起来不错哈,但是,然后呢?
$digest()
Digest就像AngularJS的心跳一样~
它每次跳动的时候会触发所属的scope和其所有子scope的dirty checking,dirty checking又会触发$watch()(马上会介绍$watch()
),整个Angular双向绑定机制就活了起来,页面也会随之更新~
注意:不建议直接调用$scope.$digest()
,而应该使用$scope.$apply()
,原因一会儿细说~
(曾经在这里写过Digest是每50ms“跳动”一次,网上很多文章也是这么写的,其实这样的说法并不全面严谨,原因在这个文章后面会细细说明~)
浏览器事件循环和Angular.js扩展
我们的浏览器一直在等待事件,比如用户交互。假如你点击一个按钮或者在输入框里输入东西,事件的回调函数就会在javascript解释器里执行,然后你就可以做任何DOM操作,等回调函数执行完毕时,浏览器就会相应地对DOM做出变化。 Angular拓展了这个事件循环,生成一个有时成为angular context
的执行环境(记住,这是个重要的概念),为了解释什么是context
以及它如何工作,我们还需要解释更多的概念。
还记得我前面提到的扩展的事件循环吗?当浏览器接收到可以被angular context
处理的事件时,$digest
循环就会触发。这个循环是由两个更小的循环组合起来的。一个处理evalAsync
队列,另一个处理$watch
队列,这个也是本篇博文的主题。
这个是处理什么的呢?$digest
将会遍历我们的$watch
,然后询问:
嘿,
$watch
,你的值是什么?是9。
好的,它改变过吗?
没有,先生。
(这个变量没变过,那下一个)
你呢,你的值是多少?
报告,是
Foo
。刚才改变过没?
改变过,刚才是
Bar
。(很好,我们有DOM需要更新了)
继续询问知道
$watch
队列都检查过。
这就是所谓的dirty-checking
。既然所有的$watch
都检查完了,那就要问了:有没有$watch
更新过?如果有至少一个更新过,这个循环就会再次触发,直到所有的$watch
都没有变化。这样就能够保证每个model都已经不会再变化。记住如果循环超过10次的话,它将会抛出一个异常,防止无限循环。
当$digest
循环结束时,DOM相应地变化。
index.html
{{ name }}<button ng-click="changeFoo()">Change the name</button>
这里我们有一个$watch
因为ng-click不生成$watch
(函数是不会变的)。
我们按下按钮
浏览器接收到一个事件,进入
angular context
(后面会解释为什么)。$digest
循环开始执行,查询每个$watch
是否变化。由于监视
$scope.name
的$watch
报告了变化,它会强制再执行一次$digest
循环。新的
$digest
循环没有检测到变化。浏览器拿回控制权,更新与
$scope.name
新值相应部分的DOM。
$watch()
每个成功的digest背后都有一群好watch~
在digest执行时,如果
watch
观察的value与上次执行时不一样时,就会被触发AngularJS内部的watch实现了页面随model的及时更新,其实我们每创建一个model,比如“,AngularJS都会在后台悄悄的为这个model创建一个watch去监听它的变化
也可手动调用~
参数1:待观察的value
参数2:value改变时想执行的操作,两个参数分别是改变前后的值
参数3:默认是false
,使用的是JavaScript本身提供的比较方式,true
表示比较的是真实的值,会有这个区别是由于JavaScript里对对象的比较是比较的引用地址(可参考这篇blog),所以如果watch的是一个对象类型的数据,即使重新赋值了相同的内容,也会触发change事件,比如watch的变量对应的值是一个数组[1, 2]
,如果再次给这个变量赋值[1, 2]
,是会触发watch
里面的参数2函数的,而大部分时候,对于相同的内容,我们不希望执行watch
里的操作,所以可以把第三个参数设置成true,这个时候就会调用angular.equals
来进行比较,angular.equals
是会比较对象里每一个属性的值是否一样的。当然,如果watch的是五种基本类型(Undefined, Null, Boolean, Number和String)就不需要设置了,因为它们不会发生这种值相同却不相等的情况。
$apply()
我们可以把apply
看成个给AngularJS送信的~$scope.$apply()
会触发digest,如果有一个function参数,function会先被执行,再digest~
应该啥时候自己调用呢?
当dom事件在AngularJS机制外被触发时~
什么样的情况算机制外呢?
喂,jQuery,你就别看别人了~!!
现在到这个问题了,为啥推荐使用$apply
而不是$digest
?
因为$apply
其实不能把信直接送给$digest
,之间还有$eval
门卫把关,如果$apply
带的表达式不合法,$eval
会把错误送交$exceptionHandler service
,合法才触发digest,所以更安全~
举个栗子~
使用$watch
来监视你自己的东西
原文地址:http://angular-tips.com/blog/2013/08/watch-how-the-apply-runs-a-digest/
你已经知道了我们设置的任何绑定都有一个它自己的$watch
,当需要时更新DOM,但是我们如果要自定义自己的watches呢?简单
来看个例子:
app.js
app.controller('MainCtrl', function($scope) { $scope.name = "Angular"; $scope.updated = -1; $scope.$watch('name', function() {$scope.updated++; });});
index.html
<body ng-controller="MainCtrl"> <input ng-model="name" /> Name updated: {{updated}} times.</body>
这就是我们创造一个新的$watch
的方法。第一个参数是一个字符串或者函数,在这里是只是一个字符串,就是我们要监视的变量的名字,在这里,$scope.name
(注意我们只需要用name
)。第二个参数是当$watch
说我监视的表达式发生变化后要执行的。我们要知道的第一件事就是当controller执行到这个$watch
时,它会立即执行一次,因此我们设置updated为-1。
试试看:http://jsbin.com/ucaxan/1/edit
例子2:
app.js
app.controller('MainCtrl', function($scope) { $scope.name = "Angular"; $scope.updated = 0; $scope.$watch('name', function(newValue, oldValue) {if (newValue === oldValue) { return; } // AKA first run$scope.updated++; });});
index.html
<body ng-controller="MainCtrl"> <input ng-model="name" /> Name updated: {{updated}} times.</body>
watch的第二个参数接受两个参数,新值和旧值。我们可以用他们来略过第一次的执行。通常你不需要略过第一次执行,但在这个例子里面你是需要的。灵活点嘛少年。
例子3:
app.js
app.controller('MainCtrl', function($scope) { $scope.user = { name: "Fox" }; $scope.updated = 0; $scope.$watch('user', function(newValue, oldValue) {if (newValue === oldValue) { return; }$scope.updated++; });});
index.html
<body ng-controller="MainCtrl"> <input ng-model="user.name" /> Name updated: {{updated}} times.</body>
我们想要监视$scope.user
对象里的任何变化,和以前一样这里只是用一个对象来代替前面的字符串。
试试看:http://jsbin.com/ucaxan/3/edit
呃?没用,为啥?因为$watch
默认是比较两个对象所引用的是否相同,在例子1和2里面,每次更改$scope.name
都会创建一个新的基本变量,因此$watch
会执行,因为对这个变量的引用已经改变了。在上面的例子里,我们在监视$scope.user
,当我们改变$scope.user.name
时,对$scope.user
的引用是不会改变的,我们只是每次创建了一个新的$scope.user.name
,但是$scope.user
永远是一样的。
例子4:
app.js
app.controller('MainCtrl', function($scope) { $scope.user = { name: "Fox" }; $scope.updated = 0; $scope.$watch('user', function(newValue, oldValue) {if (newValue === oldValue) { return; }$scope.updated++; }, true);});
index.html
<body ng-controller="MainCtrl"> <input ng-model="user.name" /> Name updated: {{updated}} times.</body>
试试看:http://jsbin.com/ucaxan/4/edit
现在有用了吧!因为我们对$watch
加入了第三个参数,它是一个bool类型的参数,表示的是我们比较的是对象的值而不是引用。由于当我们更新$scope.user.name
时$scope.user
也会改变,所以能够正确触发。
关于$watch
还有很多tips&tricks,但是这些都是基础。
jQuery对blur事件的绑定就属于AngularJS机制外触发,必须使用$apply
才能生效。(新版本的AngularJS已经提供ng-blur
这个directive了,这里作为例子看一下就好~)
再细细说一下$digest
和&apply
通过刚才的描述,我们已经知道了Angular内部会自动为页面显示的model创建watcher,然后我们也可以自定义一些watcher去监听model,然后在model变化时做一些自己想做的事情~
然后$watch()
是由谁来触发的呢?就是$digest()
【恭喜我自己都会抢答了。。。】
那么问题来了,咳咳,$digest()是由谁触发的呢?
其实有两种角色:
angular提供的directive之类的,比如你用到了angular的
ng-click
这个directive,在它对应的函数或表达式里你改变了某个model的值,这时候Angular觉察到“我擦不好有变动~”,就会自动触发一个$scope.$apply()
,而这个apply又会调用$rootScope.$digest()
,于是一轮由顶至下的dirty checking就轰轰烈烈的荡漾开了~页面也会随之更新了~Angular体制外对model修改后手动调用了
$apply
,也会调用$rootScope.$digest()
,刚刚介绍过~
然后问题又来了,就这么一轮dirty checking也好意思叫自己心跳?
-_-确实勉强了点儿,可是就一轮儿多省事啊,为啥不行呢,回想一下$watch
方法,我们在监听model改变的时候可以传入一个回调函数,如果我们在这个回调函数里又改变了其它model的值怎么办。。。不怕,Angular也想到了,所以它会一遍一遍的由顶至下的dirty checking,直到所有的model都没有改动了,至少为两轮,但是这样也有问题啊,万一有什么死循环或者过多的互相修改,这性能多差啊,得循环到啥时候去啊,没完没了了怎么办?没关系!Angular也想到了,所以设置了一个默认值为10的TTL(Time to Live),简单的说就是我就给你循环十次,即使循环到第十次model还有不一样,爷也不陪你玩儿了~
Performance
下面开始说性能了,动不动就循环个2到10次,受不受得了呢?
AngularJS的创建者曾经在stackoverflow上回过一篇巨火的答复,里面提到了他做了一个实验,他在一个页面搞了10,000个watcher,在流行的浏览器里dirty checking用了不到6ms,而巨慢的IE8也只用了40ms,他也列出了下面的科学研究结果:
人对变化的反应是慢的:任何比50ms还快的变化都是不可被察觉的~
人对信息的处理能力是有限的:在一页处理2000条信息已经算是极限了,再多的信息量往一页上堆只能说是不好的设计了,而且人也无法处理了~
所以问题就演变为:我们能不能在50ms里做2000次比较呢?
以现在的技术来说,即使是很慢的浏览器也没问题的。当然如果每个比较都写的特别复杂就另说了~而且在watch里过多的去改变其它的model值也绝不是个好习惯啊,所以当遇到这种情况的时候,就是一种代码的坏味道了,看看是不是可以重构简化一下啦~