• home > webfront > ECMAS > angularjs >

    View-Model双向绑定背后的故事~

    Author:zhoulujun@live.cn Date:

    Digest就像AngularJS的心跳一样~ 它每次跳动的时候会触发所属的scope和其所有子scope的dirty checking,dirty checking又会触发$watch()整个Angular双向绑定机制就活了起来,页面也会

    这篇博文主要是写给新手的,是给那些刚刚开始接触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&ldquo;跳动&rdquo;一次,网上很多文章也是这么写的,其实这样的说法并不全面严谨,原因在这个文章后面会细细说明~)

    浏览器事件循环和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,比如&ldquo;,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()是由谁触发的呢?
    其实有两种角色:

    1. angular提供的directive之类的,比如你用到了angular的ng-click这个directive,在它对应的函数或表达式里你改变了某个model的值,这时候Angular觉察到&ldquo;我擦不好有变动~&rdquo;,就会自动触发一个$scope.$apply(),而这个apply又会调用$rootScope.$digest(),于是一轮由顶至下的dirty checking就轰轰烈烈的荡漾开了~页面也会随之更新了~

    2. 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值也绝不是个好习惯啊,所以当遇到这种情况的时候,就是一种代码的坏味道了,看看是不是可以重构简化一下啦~


    总结

    好吧,我希望你们已经学会了在Angular中数据绑定是如何工作的。我猜想你的第一印象是dirty-checking很慢,好吧,其实是不对的。它像闪电般快。但是,是的,如果你在一个模版里有2000-3000个watch,它会开始变慢。但是我觉得如果你达到这个数量级,就可以找个用户体验专家咨询一下了

    无论如何,随着ECMAScript6的到来,在Angular未来的版本里我们将会有Object.observe那样会极大改善$digest循环的速度。同时未来的文章也会涉及一些tips&tricks。

    另一方面,这个主题并不容易,如果你发现我落下了什么重要的东西或者有什么东西完全错了,请指正(原文是在GITHUB上PR 或报告issue


    转载本站文章《View-Model双向绑定背后的故事~》,
    请注明出处:https://www.zhoulujun.cn/html/webfront/ECMAScript/angularjs/2016_0106_383.html