AngularJS中双向数据绑定的实现

angular源码解析: $apply, $watch, $digest

Posted by AllocatorXy on March 12, 2017

AngularJS的核心特色是双向数据绑定给我们的MV*开发带来了很大方便,特别是前台数据逻辑,那么双向数据绑定是如何实现的呢?
看了看源码,发现angular源码写的还是比较干净的。

脏检查(Dirty Checking)

angular中有一个$digest方法,它的作用就是检查数据改变,如果数据被改变,则「消化」这些已被改变的「脏数据」
这个过程被称为脏检查(Dirty Checking)


$digest是如何检测到数据改变的呢?

每当有一个变量用angular进行数据绑定,angular会自动$watch方法为其添加一个watcher, 这个watcher将变量的当前值,回调方法listener等参数作为属性保存。
当执行$digest方法,它会遍历范围内的watchers, 每当某个watcher$digest检测出数据有改变时,执行watcher的回调方法listener;

{
  /**
   * method $watch
   * @param  {exp}     watchExp              // 要监测的变量
   * @param  {fn}      listener              // 改变时的回调函数
   * @param  {boolean} objectEquality        // 比较变量的方式
   * @param  {exp}     prettyPrintExpression // trim后的表达式
   * @return {fn}      deregisterWatch       // 用于销毁watcher的闭包
   */
  $watch: function(watchExp, listener, objectEquality, prettyPrintExpression) {
    var get = $parse(watchExp); // 将表达式转换为域内函数用来获取当前值

    if (get.$$watchDelegate) {
      return get.$$watchDelegate(this, listener, objectEquality, get, watchExp);
    }
    var scope = this,
        array = scope.$$watchers,
        watcher = { // 按照参数定义watcher
          fn: listener,                           // 数据改动时的回调函数
          last: initWatchVal,                     // 上次的记录值
          get: get,                               // 当前值
          exp: prettyPrintExpression || watchExp, // trimmed exp(angular会自动trim表达式)
          eq: !!objectEquality                    // 指定比较方式(默认为按引用比较)
        };

    lastDirtyWatch = null;

    if (!isFunction(listener)) { // 回调函数有误
      watcher.fn = noop;
    }

    if (!array) { // 当没有watcher时,避免$digest遍历该scope
      array = scope.$$watchers = [];
      array.$$digestWatchIndex = -1;
    }
    // we use unshift since we use a while loop in $digest for speed.
    // the while loop reads in reverse order.
    array.unshift(watcher); // 将定义好的watcher放入监视器数组$$watchers
    array.$$digestWatchIndex++;
    incrementWatchersCount(this, 1);

    return function deregisterWatch() { // 返回闭包,用于销毁watcher
      var index = arrayRemove(array, watcher);
      if (index >= 0) {
        incrementWatchersCount(scope, -1);
        if (index < array.$$digestWatchIndex) {
          array.$$digestWatchIndex--;
        }
      }
      lastDirtyWatch = null;
    };
  },
}

显然,脏检查只检查有watcher的对象,每当有数据被绑定,它会在初始化时被添加一个对应的watcher;
除此之外,我们也可以手动调用$watch方法,为某个变量添加watcher, 让它处于脏检查队列中;

/**
 * 这是源码注释中的栗子,手动添加watcher
 * @param  {exp} watchExp        // 要监控的变量
 * @param  {fn}  listener        // 回调函数
 * @return {fn}  deregisterWatch // 用于销毁watcher的闭包
 */
scope.$watch(
  // This function returns the value being watched. It is called for each turn of the $digest loop
  function() { return food; },
  // This is the change listener, called when the value returned from the above function changes
  function(newValue, oldValue) {
    if ( newValue !== oldValue ) {
      // Only increment the counter if the value changed
      scope.foodCounter = scope.foodCounter + 1;
    }
  }
);

何时触发$digest

当使用AngularJS某些内置语句(ng-model, ng-click, …)修改model时,$digest会被自动调用,但有些时候,我们需要手动调用它;
例如,当我们在使用一些非AngularJS内置语句时,$digest并不会被触发:

<!-- 用jq的ajax获取数据生成dom -->
<!-- ajax可以成功获取数据,$scope.arr也被成功赋值,但view没有更新 -->
<body ng-app="mod1" ng-controller="ctr1">
    <ul>
        <li ng-repeat="item in arr"></li>
    </ul>
</body>
<script>
    angular.module('mod1', []).controller('ctr1', ($scope) => {
        $.ajax({
            url: 'data/a.txt',
            dataType: 'json',
            success: r => {
                console.log(r);          // ajax成功获取数据
                $scope.arr = r;
                console.log($scope.arr); // $scope.arr已被赋值,控制台已显示数据
            }
        });
    });
</script>

这时候我们如果偏要用非angular内置方法,就需要手动来触发$digest了,但一般我们不会直接调用$digest, 而是调用$apply方法, 由它调用全局脏检查$rootScope.$digest();

它有两种常用的调用形式,一种是不加参数直接在需要脏检查时调用$apply(), 另一种是,用函数作为$apply的参数,将要执行的代码包裹在其中:

<!-- 刚才的栗子,可以成功在view中展现了 -->
<body ng-app="mod1" ng-controller="ctr1">
    <ul>
        <li ng-repeat="item in arr"></li>
    </ul>
</body>
<script>
    angular.module('mod1', []).controller('ctr1', ($scope) => {
        $scope.$apply(() => {   // 调用$apply并传入参数
            $.ajax({
                url: 'data/a.txt',
                dataType: 'json',
                success: r => {
                    console.log(r);
                    $scope.arr = r;
                    console.log($scope.arr);
                }
            });
        });
    });
</script>

为什么要大费周章不直接调用$digest?

找出$apply方法的源码,可以看出这样做的好处有两点:

  1. 当需要执行的代码出错时,可以抛出异常
  2. 当需要执行的代码出错时,不影响脏检查继续执行,以保证最大限度的用户体验;

所以我们不仅要尽量使用$apply, 而且要带参数,这样才能用到它的错误处理;

{
  /**
   * method $apply
   * @param  {exp} expr  表达式,一般用函数,可以为空
   */
  $apply: function(expr) {
    try {
      beginPhase('$apply');
      try {
        return this.$eval(expr);
      } finally {
        clearPhase();
      }
    } catch (e) { // 错误处理
      $exceptionHandler(e);
    } finally {
      try {
        $rootScope.$digest();
      } catch (e) {
        $exceptionHandler(e);
        throw e;
      }
    }
  },
}

如果回调函数listener改变了model会发生什么?

如果在一个$digest中,某个watcher的回调函数listener改变了model值,会增加一次脏检查的循环次数,如果又有某个listener改变了model值,会再次增加循环…直到不再有model改变,或者到达$digest的迭代上限10次(默认值)为止。

  1. 触发一次$digest,要检查listener是否更改model, 所以至少会进行两次遍历;
  2. 当没有新的脏数据产生时,迭代才会被终止;
  3. 如果迭代超过限度(默认10次),会抛出异常;

另外,还有两个异步方法$applyAsync, $evalAsync;
$applyAsync用来解决,某一时刻大量触发$digest产生的性能问题,例如同时有很多ajax请求修改了model, 它会将$digest延迟处理;
$evalAsync用来在某个$digest执行中插队到下一个循环执行一段代码;