浅谈 Angular 脏检查

Angular 的脏值检查机制一直是 Angular 被人诟病的地方,但瑕不掩瑜,Angular 还是一个非常优秀的框架,并且 Angular2 也已经抛弃了这个脏值检查的算法。
最近在看《AngularJS 深度剖析与最佳实践》,不得不说是一本很好的书籍,作者在第三章开始讲背后的原理,这里分析了 Angular 的 $digest 函数,即脏检查机制。所以自己也去下载了 Angular 最新的源码去瞧了下,然后做下笔记吧。

首先要注意,Angular 的 digest 的触发不是定时的,只有在指定的事件触发之后才会进入 $digest。基本上我们用的带 $ 的东西调用之后都可能会触发 digest。比如我们使用 setTimeout 就不会触发 digest,即当你使用 setTimeout 更改 viewmodel 的值后,它不会同步的反映到用户的视图中去,解决方法有两个,一个是使用 Angular 提供的 $timeout 替代 setTimeout$timeout 会在执行结束之后自动触发 digest; 另一个方法是手动调用 $apply,$apply 是 Angular 对 digest 的一层封装,我们一般不会直接调用 digest 而是通过使用 $apply 方法。比如对于 setTimeout,我们就可以这样触发 digest。

setTimeout(() => {
  $scope.$apply(() => {
    $scope.test = 123;
  })    
}, 500);

我们看一个例子,这也是 Angular 源码 $digest 部分的一个示例。

var scope = ...;
scope.name = 'misko';
scope.counter = 0;

expect(scope.counter).toEqual(0);
scope.$watch('name', function(newValue, oldValue){
  scope.counter = scope.counter + 1;
});
expect(scope.counter).toEqual(0);

// 执行第一次 digest,第一次 digest 会遍历全部的 watcher,并触发上面的方法,从而使的 count+1
scope.$digest();
expect(scope.counter).toEqual(1);

// 第二次调用时,由于上一次调用检查 name 不脏,所以不会再去处理
scope.$digest();
expect(scope.counter).toEqual(1);

// 第三次调用时,由于 name 发生了变化,使得当前值和上一次保存的值不同,所以会触发起 $watch 方法
scope.name = 'adam';
scope.$digest();
expect(scope.counter).toEqual(2);

Angular 的脏值检查过程大致如下:
对当前作用域和子作用域上的 $$watchers 进行遍历,$$watches 保存着 scope 上的所有变量以及其 $watch 方法,调用时会取当前值和上一次值进行比较,如果不相等则会调用 $watch 方法,同时会保存当前的值以在下一次进行比较,并且记录此次检查结果为脏。然后重复进行直到数据不脏为止,因此至少要 digest 两次,超出 10 次会报错,可以调高这个次数限制。当数据不再脏即 model 稳定下来之后, Angular 才会开始一次性批量更新 UI。从而减少了浏览器的 repaint 次数,提升性能。

深入到源码来看:

$digest: function() {
  var watch, value, last, fn, get,
      watchers,
      length,
      dirty, ttl = TTL,
      next, current, target = this,
      watchLog = [],
      logIdx, asyncTask;
      
  beginPhase('$digest');
  $browser.$$checkUrlChange();
  
  if (this === $rootScope && applyAsyncId !== null) {
    $browser.defer.cancel(applyAsyncId);
    flushApplyAsync();
  }
  
  lastDirtyWatch = null;
  
  do {
    dirty = false;
    current = target;
    
    for (var asyncQueuePosition = 0; asyncQueuePosition < asyncQueue.length; asyncQueuePosition++) {
      try {
        asyncTask = asyncQueue[asyncQueuePosition];
        asyncTask.scope.$eval(asyncTask.expression, asyncTask.locals);
      } catch (e) {
        $exceptionHandler(e);
      }
      lastDirtyWatch = null;
    }
    asyncQueue.length = 0;

    // 脏值检查开始
    traverseScopesLoop:
    do {
      // 获取当前 scope 的 $$watchers
      if ((watchers = current.$$watchers)) {
        // process our watches
        // 遍历执行这些 watches
        length = watchers.length;
        while (length--) {
          try {
            watch = watchers[length];
            if (watch) {
              get = watch.get;
              if ((value = get(current)) !== (last = watch.last) &&
                  !(watch.eq
                      ? equals(value, last)
                      : (typeof value === 'number' && typeof last === 'number'
                         && isNaN(value) && isNaN(last)))) {
                           // 优先使用 === 判断 value 和 last,其次再是根据他们是否为数字做 ng 的深度相等判断或者 isNaN 判断
                dirty = true;
                lastDirtyWatch = watch;
                // 如果 watch.eq 为 true,表示该 watch 的目标为对象,所以把该对象克隆到 watch.last 上面以下一次 digest 时来判断
                // 如果 watch.eq 为 false,表示该 watch 的目标为数字,所以直接赋值就可以了
                // 这里和上面一样都是为了提高速度和性能用
                watch.last = watch.eq ? copy(value, null) : value;
                // 获取该 watch 的表达式并执行
                fn = watch.fn;
                // 如果 last 和最开始的值相同则使用后者,否则使用前者。
                fn(value, ((last === initWatchVal) ? value : last), current);
                if (ttl < 5) {
                  logIdx = 4 - ttl;
                  if (!watchLog[logIdx]) watchLog[logIdx] = [];
                  watchLog[logIdx].push({
                    msg: isFunction(watch.exp) ? 'fn: ' + (watch.exp.name || watch.exp.toString()) : watch.exp,
                    newVal: value,
                    oldVal: last
                  });
                }
              } else if (watch === lastDirtyWatch) {
                dirty = false;
                break traverseScopesLoop;
              }
            }
          } catch (e) {
            $exceptionHandler(e);
          }
        }
      }

      // Insanity Warning: scope depth-first traversal
      // yes, this code is a bit crazy, but it works and we have tests to prove it!
      // this piece should be kept in sync with the traversal in $broadcast
      // 对当前 scope 的子 scope 做遍历
      if (!(next = ((current.$$watchersCount && current.$$childHead) ||
          (current !== target && current.$$nextSibling)))) {
        while (current !== target && !(next = current.$$nextSibling)) {
          current = current.$parent;
        }
      }
    } while ((current = next));
    
    // 脏值检查未结束但此时 ttl 为 0,则抛出错误
    if ((dirty || asyncQueue.length) && !(ttl--)) {
      clearPhase();
      throw $rootScopeMinErr('infdig',
          '{0} $digest() iterations reached. Aborting!\n' +
          'Watchers fired in the last 5 iterations: {1}',
          TTL, watchLog);
    }

    // 循环遍历直到 dirty 为 false 并且 asyncQueue.length = 0
  } while (dirty || asyncQueue.length);

  clearPhase();

  // 执行 postDigest 序列
  while (postDigestQueuePosition < postDigestQueue.length) {
    try {
      postDigestQueue[postDigestQueuePosition++]();
    } catch (e) {
      $exceptionHandler(e);
    }
  }
  postDigestQueue.length = postDigestQueuePosition = 0;
}

不过这段代码我也不是全都理解了,但是核心的算了解了。总体来看这个算法还是很简单粗暴的,这里保留了一段注释,有意思,官方吐槽的感觉。

由于脏检查的性能问题,在页面绑定数据较多的时候,我们应该尽量减少双向绑定的数量,比如使用 ngInfiniteScroll 这样的插件,适当使用单向绑定,甚至是取消一些变量的 watch 方法。