angular的数据双向绑定秘密

  Angular用户都想知道数据绑定是怎么实现的。你可能会看到各类各样的词汇:$watch,$apply,$digest,dirty-checking... 它们是什么?它们是如何工做的呢?这里我想回答这些问题,其实它们在官方的文档里都已经回答了,可是我仍是想把它们结合在一块儿来说,可是我只是用一种简单的方法来说解,若是要想了解技术细节,查看源代码。javascript

让咱们从头开始吧。html

浏览器事件循环和Angular.js扩展

  咱们的浏览器一直在等待事件,好比用户交互。假如你点击一个按钮或者在输入框里输入东西,事件的回调函数就会在javascript解释器里执行,而后你就能够作任何DOM操做,等回调函数执行完毕时,浏览器就会相应地对DOM作出变化。 Angular拓展了这个事件循环,生成一个有时成为angular context的执行环境(记住,这是个重要的概念),为了解释什么是context以及它如何工做,咱们还须要解释更多的概念。java

$watch 队列($watch list)

  每次你绑定一些东西到你的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:app

controllers.js函数

app.controller('MainCtrl', function($scope) {
  $scope.foo = "Foo";
  $scope.world = "World";
});

 

index.htmlspa

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不知道怎么翻译)

  还记得我前面提到的扩展的事件循环吗?当浏览器接收到能够被angular context处理的事件时,$digest循环就会触发。这个循环是由两个更小的循环组合起来的。一个处理evalAsync队列,另外一个处理$watch队列,这个也是本篇博文的主题。 这个是处理什么的呢?$digest将会遍历咱们的$watch,而后询问:

  • 嘿,$watch,你的值是什么?
    • 是9。
  • 好的,它改变过吗?
    • 没有,先生。
  • (这个变量没变过,那下一个)
  • 你呢,你的值是多少?
    • 报告,是Foo
  • 刚才改变过没?
    • 改变过,刚才是Bar
  • (很好,咱们有DOM须要更新了)
  • 继续询问知道$watch队列都检查过。

这就是所谓的dirty-checking。既然全部的$watch都检查完了,那就要问了:有没有$watch更新过?若是有至少一个更新过,这个循环就会再次触发,直到全部的$watch都没有变化。这样就可以保证每一个model都已经不会再变化。记住若是循环超过10次的话,它将会抛出一个异常,防止无限循环。 当$digest循环结束时,DOM相应地变化。

例如: controllers.js

app.controller('MainCtrl', function() {
  $scope.name = "Foo";

  $scope.changeFoo = function() {
      $scope.name = "Bar";
  }
});

 

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。

  这里很重要的(也是许多人的很蛋疼的地方)是每个进入angular context的事件都会执行一个$digest循环,也就是说每次咱们输入一个字母循环都会检查整个页面的全部$watch

经过$apply来进入angular context

  谁决定什么事件进入angular context,而哪些又不进入呢?$apply

  若是当事件触发时,你调用$apply,它会进入angular context,若是没有调用就不会进入。如今你可能会问:刚才的例子里我也没有调用$apply啊,为何?Angular为了作了!所以你点击带有ng-click的元素时,时间就会被封装到一个$apply调用。若是你有一个ng-model="foo"的输入框,而后你敲一个f,事件就会这样调用$apply("foo = 'f';")

Angular何时不会自动为咱们$apply呢?

  这是Angular新手共同的痛处。为何个人jQuery不会更新我绑定的东西呢?由于jQuery没有调用$apply,事件没有进入angular context$digest循环永远没有执行。

咱们来看一个有趣的例子:

假设咱们有下面这个directive和controller

app.js

app.directive('clickable', function() {

return {
  restrict: "E",
  scope: {
    foo: '=',
    bar: '='
  },
  template: '<ul style="<li>{{foo}}</li><li>{{bar}}</li></ul>',
  link: function(scope, element, attrs) {
    element.bind('click', function() {
      scope.foo++;
      scope.bar++;
    });
  }
}

});

app.controller('MainCtrl', function($scope) {
  $scope.foo = 0;
  $scope.bar = 0;
});

 

  它将foobar从controller里绑定到一个list里面,每次点击这个元素的时候,foobar都会自增1。

  那咱们点击元素的时候会发生什么呢?咱们能看到更新吗?答案是否认的。由于点击事件是一个没有封装到$apply里面的常见的事件,这意味着咱们会失去咱们的计数吗?不会

  真正的结果是:$scope确实改变了,可是没有强制$digest循环,监视foobar$watch没有执行。也就是说若是咱们本身执行一次$apply那么这些$watch就会看见这些变化,而后根据须要更新DOM。

试试看吧:http://jsbin.com/opimat/2/

  若是咱们点击这个directive(蓝色区域),咱们看不到任何变化,可是咱们点击按钮时,点击数就更新了。如刚才说的,在这个directive上点击时咱们不会触发$digest循环,可是当按钮被点击时,ng-click会调用$apply,而后就会执行$digest循环,因而全部的$watch都会被检查,固然就包括咱们的foobar$watch了。

  如今你在想那并非你想要的,你想要的是点击蓝色区域的时候就更新点击数。很简单,执行一下$apply就能够了:

element.bind('click', function() {
  scope.foo++;
  scope.bar++;

  scope.$apply();
});

 

  $apply是咱们的$scope(或者是direcvie里的link函数中的scope)的一个函数,调用它会强制一次$digest循环(除非当前正在执行循环,这种状况下会抛出一个异常,这是咱们不须要在那里执行$apply的标志)。

试试看:http://jsbin.com/opimat/3/edit

  有用啦!可是有一种更好的使用$apply的方法:

element.bind('click', function() {
  scope.$apply(function() {
      scope.foo++;
      scope.bar++;
  });
})

 

  有什么不同的?差异就是在第一个版本中,咱们是在angular context的外面更新的数据,若是有发生错误,Angular永远不知道。很明显在这个像个小玩具的例子里面不会出什么大错,可是想象一下咱们若是有个alert框显示错误给用户,而后咱们有个第三方的库进行一个网络调用而后失败了,若是咱们不把它封装进$apply里面,Angular永远不会知道失败了,alert框就永远不会弹出来了。

  所以,若是你想使用一个jQuery插件,而且要执行$digest循环来更新你的DOM的话,要确保你调用了$apply

  有时候我想多说一句的是有些人在不得不调用$apply时会“感受不妙”,由于他们会以为他们作错了什么。其实不是这样的,Angular不是什么魔术师,他也不知道第三方库想要更新绑定的数据。

使用$watch来监视你本身的东西

  你已经知道了咱们设置的任何绑定都有一个它本身的$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,可是这些都是基础。

总结

  好吧,我但愿大家已经学会了在Angular中数据绑定是如何工做的。我猜测你的第一印象是dirty-checking很慢,好吧,实际上是不对的。它像闪电般快。可是,是的,若是你在一个模版里有2000-3000个watch,它会开始变慢。可是我以为若是你达到这个数量级,就能够找个用户体验专家咨询一下了

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

相关文章
相关标签/搜索