Angular之$watch、$apply、$digest原理深入分析(附带源码分析)

双向绑定可以说angular的一个很强大的功能,而且之前只知道angular有双向绑定,也没仔细学习,其中的原理更是只略知一二,前些天特意花了点时间整理下资料,把自己的理解记录下来。(如果有错误,很乐意接受指正!)

脏值检测(Dirty Checking)

angular正是因为这个脏值检查所以实现了双向绑定,其实大概实现的原理是,通过watch一次又一次监控值,比较现在的值和上次的值变化(脏值检查),如果有变化 (dirty = false),直到值没有变化(dirty = true),脏检查结束,再把值渲染到视图上实现双向绑定。其实双向绑定就是页面上的操作能够实时反映到数据上,数据的改变能够实时反映在页面上显示,Angular会在指定事件触发后才会触发脏值检查,比如:
1.操作DOM事件,列如:输入文本事件,点击事件(ng-click)。
2.XHR响应时间($http)。
3.浏览器Location变更事件($location)。
4.Timer事件($timeout,$interval)。
5.执行$digest()或者$apply() 。
controller初始化的时候,所有的ng-开头的事件执行后,都会触发脏值检查,Angular只能管理它所知的行为触发方式,而不是所有的angualr操作场景都会自动触发脏值检查,这就是我们在用原生的js或者jquery事件不能自动更新视图,需要我们手动更新$scope.$apply()。知道了脏值检查的条件和原理后接下来我们慢慢深入了解下脏值检查的底层实现(包括源码分析)。

1. $watch

Angular 每一个绑定到UI的数据,就会有一个 watch 对象。

      <span>{{name}}</span>
      <span>{{age}}</span>

这里会有2个$watch 对象,使用$watch,可以在Scope上添加一个监听器。当Scope上发生变更时,监听器会收到提示。给$watch指定如下两个函数,就可以创建一个监听器:一个监控函数,用于指定所关注的那部分数据。一个监听函数,用于在数据变更的时候接受提示。下面我们来简单模拟下$watch的实现:
为了实现$watch,我们需要存储注册过的所有监听器。我们在Scope构造函数上添加一个数组

function Scope() {
  this.$$watchers = [];
}

在Angular框架中,双美元符前缀$$表示这个变量被当作私有的来考虑,不应当在外部代码中调用,现在我们可以定义$watch方法了。它接受两个函数作参数,把它们存储在$$watchers数组中。我们需要在每个Scope实例上存储这些函数,所以要把它放在Scope的原型上。

Scope.prototype.$watch = function(watchFn, listenerFn) {
  var watcher = {
    watchFn: watchFn,
    listenerFn: listenerFn
  };
  this.$$watchers.push(watcher);
};

先看看源码:

          $watch: function (watchExp, listener, objectEquality, prettyPrintExpression) {
            //执行表达式的函数
            var get = $parse(watchExp);
            //判断listener是否是函数 不是使用noop占位
            var fn = isFunction(listener) ? listener : noop;
            //当编译得到的watch function中存在$$watchDelegate这个属性时,就会直接返回。这实际上是一个性能优化的措施
            if (get.$$watchDelegate) {
              return get.$$watchDelegate(this, fn, objectEquality, get, watchExp);
            }
            var scope = this,
              array = scope.$$watchers,
              watcher = {
                fn: fn, //监听事件 function(newValue,oldValue){当数据发生改变时需要执行的操作}
                last: initWatchVal, //最后监听结果,初始化为一个空函数
                get: get, //调用$parse来返回执行表达式的函数可以获得表达式对于的需要监听的值
                exp: prettyPrintExpression || watchExp, //备份最初监听表达式
                eq: !!objectEquality //是否深度对比//两个!来处理空值或者未定义的行为
              };
            lastDirtyWatch = null;
            //初始化 array & scope,$$watchers
            if (!array) {
              array = scope.$$watchers = [];
              array.$$digestWatchIndex = -1;
            }
            array.unshift(watcher);
            //$$digestWatchIndex加一
            array.$$digestWatchIndex++;
            //
            incrementWatchersCount(this, 1);
            //闭包返回了一个可以移除监听的方法
            //返回的这个函数是用来注销当前watcher:将当前watcher从数组中删除并减少计数器的值。
            return function deregisterWatch() { 
            var index = arrayRemove(array, watcher);
              if (index >= 0) {
                incrementWatchersCount(scope, -1);
                if (index < array.$$digestWatchIndex) {
                  array.$$digestWatchIndex--;
                }
              }
              lastDirtyWatch = null;
            };

$watch是个方法,里面有5个参数,watchExp为要监控的表达式,比如为一个字符串 $scope.name,通常在数据绑定,其实它被Angular解析和编译成一个监控函数(var get = $parse(watchExp)),listener为监听事件,objectEquality是个布尔值,为了考虑性能,默认是false,为true时,是深度遍历,prettyPrintExpression 如果没有值就是最初监听表达式 $scope.name,为了备份的作用。

 $scope.$watch(function () {
            return $scope.name;
        }, function (newValue, oldValue) {
            //....
        }false);

在当前作用域下创建一个数组 scope.$$watchers,然后把UI数据对应的watch 对象放进这个数组中。scope.$$watchers就包含着当前作用域下所有的watch 对象,那我们怎么进行脏值检查呢,就要引入另外一个$digest函数。

2. $digest

另外一面就是$digest函数。它执行了所有在作用域上注册过的监听器。我们来实现一个它的简化版,遍历所有监听器,调用它们的监听函数。同样我们先模拟下$digest的实现再分析源码就很简单了

Scope.prototype.$digest = function() {
  this.$$watchers.forEach(function(watch) {
    watch.listenerFn();
  });  
};

然后运行$digest了,这将会调用监听函数,其实这些本身没什么大用,我们要的是能检测由监控函数指定的值是否确实变更了,然后调用监听函数,$digest函数的作用是调用这个监控函数,并且比较它返回的值和上一次返回值的差异。如果不相同,监听器就是脏的,它的监听函数就应当被调用,想要这么做,$digest需要记住每个监控函数上次返回的值。既然我们现在已经为每个监听器创建过一个对象,只要把上一次的值存在这上面就行了。下面是检测每个监控函数值变更的$digest新实现。

Scope.prototype.$digest = function() {
  var self = this;
  this.$$watchers.forEach( function(watch) {
    var newValue = watch.watchFn(self);
    var oldValue = watch.last;
    if (newValue !== oldValue) {
      watch.listenerFn(newValue, oldValue, self);
    }
    watch.last = newValue;
  });  
};

对每个监听器,我们调用监控函数,把作用域自身当作实参传递进去,然后比较这个返回值和上次返回值,如果不同,就调用监听函数。方便起见,我们把新旧值和作用域都当作参数传递给监听函数。最终,我们把监听器的last属性设置成新返回的值,下一次可以用它来作比较。不过这样写还存在问题,监听函数自身也修改作用域上的属性,比如先后注册了两个监听器,第二个监听器的listener 改变了 第一个监听器对应数据的值,这样写我们检测不到的,所以我们返回一个布尔值,表示是否还有变更了,如果有变更,布尔值为true,我们用这个布尔值是否为true作为外层循环的条件。

Scope.prototype.$digest = function() {
  var dirty;
  do {
    dirty = this.$$digestOnce();
  } while (dirty);
};
Scope.prototype.$$digestOnce = function() {
  var self  = this;
  var dirty;
  this.$$watchers.forEach(function(watch) {
    var newValue = watch.watchFn(self);
    var oldValue = watch.last;
    if (newValue !== oldValue) {
      watch.listenerFn(newValue, oldValue, self);
      dirty = true;
    }
    watch.last = newValue;
  });
  return dirty;
};

$digest现在至少运行每个监听器一次了。如果第一次运行完,有监控值发生变更了,标记为dirty,所有监听器再运行第二次。这会一直运行,直到所有监控的值都不再变化,整个局面稳定下来了。在我们现在的实现中,还有一个明显的遗漏:如果两个监听器互相改变对方数据的值,是不是一直循环下去,变成一个死循环了,所以我们要设置一个的最大值,迭代的最大值称为TTL,这个值默认是10,我们继续,给外层digest循环添加一个循环计数器。如果达到了TTL,就抛出异常。

Scope.prototype.$digest = function() {
  var ttl = 10;
  var dirty;
  do {
    dirty = this.$$digestOnce();
    if (dirty && !(ttl--)) {
      throw "10 digest iterations reached";
    }
  } while (dirty);
};

我们曾经使用严格等于操作符(===)来比较新旧值,在绝大多数情况下,它是不错的,比如所有的基本类型(数字,字符串等等),也可以检测一个对象或者数组是否变成新的了,当用户调用$watch,没传入第三个参数的时候就是未定义的,在监听器对象里就变成了false,基于值的脏检查意味着如果新旧值是对象或者数组,我们必须遍历其中包含的所有内容。如果它们之间有任何差异,监听器就脏了。如果该值包含嵌套的对象或者数组,它也会递归地按值比较,angular.equals来检测变更,用于检测当对象或者数组内部产生变更的时候。

Scope.prototype.$watch = function(watchFn, listenerFn, objectEquality ) {
  var watcher = {
    watchFn: watchFn,
    listenerFn: listenerFn,
    eq: !!objectEquality 
  };
  this.$$watchers.push(watcher);
};
Scope.prototype.$$isEqual = function(newValue, oldValue, eq) {
  if (eq) {
    return angular.equals(newValue, oldValue);
  } else {
    //这里要注意NaN情况
   return newValue === oldValue || (typeof newValue === 'number' && typeof oldValue === 'number' &&
       isNaN(newValue) && isNaN(oldValue));;
  }
};
Scope.prototype.$$digestOnce = function() {
  var self  = this;
  var dirty;
  this.$$watchers.forEach(function(watch) {
    var newValue = watch.watchFn(self);
    var oldValue = watch.last;
 if (!self.$$isEqual(newValue, oldValue, watch.eq)) {
      watch.listenerFn(newValue, oldValue, self);
      dirty = true;
    }
    watch.last = (watch.eq? _.cloneDeep(newValue) : newValue);
  });
  return dirty;
};

到这里 脏检查底层简单的实现就基本完成了。下面我们来看下angular $digest的源码:

 $digest: function () {
            var watch, value, last, fn, get,
              watchers,
              dirty, ttl = TTL,
              next, current, target = asyncQueue.length ? $rootScope : this,
              watchLog = [],
              logIdx, asyncTask;
            //开始脏值检查循环
            beginPhase('$digest');
            // Check for changes to browser url that happened in sync before the call to $digest
            $browser.$$checkUrlChange();
            if (this === $rootScope && applyAsyncId !== null) {
              // If this is the root scope, and $applyAsync has scheduled a deferred $apply(), then
              // cancel the scheduled $apply and flush the queue of expressions to be evaluated.
              $browser.defer.cancel(applyAsyncId);
              flushApplyAsync();
            }
            lastDirtyWatch = null;
            do { // "while dirty" loop
              dirty = false;
              //current指向当前对象
              current = target;
              // It's safe for asyncQueuePosition to be a local variable here because this loop can't
              // be reentered recursively. Calling $digest from a function passed to $evalAsync would
              // lead to a '$digest already in progress' error.
              //   循环添加异步队列
              for (var asyncQueuePosition = 0; asyncQueuePosition < asyncQueue.length; asyncQueuePosition++) {
                try {
                  asyncTask = asyncQueue[asyncQueuePosition];
                  fn = asyncTask.fn;
                  fn(asyncTask.scope, asyncTask.locals);
                } catch (e) {
                  $exceptionHandler(e);
                }
                lastDirtyWatch = null;
              }
              asyncQueue.length = 0;
              traverseScopesLoop:
                //scope循环保证所有的model都能被遍历到
                do { // "traverse the scopes" loop
                  if ((watchers = !current.$$suspended && current.$$watchers)) {
                    // process our watches
                    //循环当前scope中的每个watchers
                    watchers.$$digestWatchIndex = watchers.length;
                    while (watchers.$$digestWatchIndex--) {
                      try {
                        //遍历到的当前watch
                        watch = watchers[watchers.$$digestWatchIndex];
                        // Most common watches are on primitives, in which case we can short
                        // circuit it with === operator, only when === fails do we use .equals
                        if (watch) {
                          get = watch.get;
                          // watch.get(current)获取当前最新值, last = watch.last旧值
                          if ((value = get(current)) !== (last = watch.last) &&
                           //watch.eq ?作用是判断当前被监听的元素是否是对象,当不是对象且是number类型r时排除NaN!==NaN的判断,因为该判断会返回true 
                            !(watch.eq ?
                              //equals深度对比两个对象是否相等,判断NaN情况
                              equals(value, last) :
                              (isNumberNaN(value) && isNumberNaN(last)))) {
                            //不相等则认为是脏值
                            dirty = true;
                            lastDirtyWatch = watch;
                            //watch.eq ?作用是判断当前监听的元素是否是对象
                            //如果不是对象,直接把数值更新到watch.last//如果是对象,则通过深度拷贝该对象到watch.last中
                            watch.last = watch.eq ? copy(value, null) : value;
                            fn = watch.fn;
                            //此处正式调用监听函数
                            fn(value, ((last === initWatchVal) ? value : last), current);
                            // watchLog记录日志//ttl最大是10,此处记录最后五条记录
                            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
                              });
                            }
                            // lastDirtyWatch上一轮循环中最后一个发生值变化的监听
                            //如果当前watch没有变化,且当前watch正是上一轮的最后一个变化的watch,说明此次循环中所有的脏值都已更新,循环可以结束。
                          } else if (watch === lastDirtyWatch) {
                            // If the most recently dirty watcher is now clean, short circuit since the remaining watchers
                            // have already been tested.
                            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
                  // (though it differs due to having the extra check for $$suspended and does not
                  // check $$listenerCount)
                  if (!(next = ((!current.$$suspended && current.$$watchersCount && current.$$childHead) ||
                      (current !== target && current.$$nextSibling)))) {
                    while (current !== target && !(next = current.$$nextSibling)) {
                      current = current.$parent;
                    }
                  }
                } while ((current = next));
              // `break traverseScopesLoop;` takes us to here
              // TTL默认最大是10,当超过10个digest时会报错,若此时仍存在脏值,则抛出异常并提醒最后五条检测记录
              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(也就是脏值)并且当且$digest队列(该队列最大长度为10)为0了就终止循环
            } while (dirty || asyncQueue.length);
            //beginPhase表示这一段功能开始,对应着clearPhase表示结束
            clearPhase();

            // postDigestQueuePosition isn't local here because this loop can be reentered recursively.
            while (postDigestQueuePosition < postDigestQueue.length) {
              try {
                postDigestQueue[postDigestQueuePosition++]();
              } catch (e) {
                $exceptionHandler(e);
              }
            }
            postDigestQueue.length = postDigestQueuePosition = 0;
            // Check for changes to browser url that happened during the $digest
            // (for which no event is fired; e.g. via `history.pushState()`)
            $browser.$$checkUrlChange();
          }

现在看起来是不是清楚很多了,我们主要关注的是脏检查是怎么实现,都备注了中文注释,有段代码要来解释下:

if (!(next = ((!current.$$suspended && current.$$watchersCount && current.$$childHead) ||
                     (current !== target && current.$$nextSibling)))) {
                   while (current !== target && !(next = current.$$nextSibling)) {
                     current = current.$parent;
                   }
                 }
               } while ((current = next));

这里的意思判断当前作用域下有没有子元素,有子元素就开始对子元素作用下的每个watch对象进行脏检测,知道发现没有子元素了,然后判断当前作用域是不是顶层作用域和有没有兄弟元素,如果有兄弟就开始对兄弟下的每个watch对象进行脏检测,最后发现兄弟元素了也没有,然后再往兄弟的父亲找,一直这样找,所有的子元素和兄弟都能遍历到,这属于深度优先遍历。
在这里插入图片描述
假设从起始点v1开始遍历,在访问了v1后,选择其邻接点v2。因为v2未曾访问过,则从v2出发进行深度优先遍历。依次类推,接着从v4、v8、v5出发进行遍历。在访问了v5后,由于v5的邻接点都已被访问,则遍历回退到v8。同样的理由,继续回退到v4、v2直至v1,此时v1的另一个邻接点v3未被访问,则遍历又从v1到v3,再继续进行下去。于是得到节点的线性顺序为:v1 -> v2 -> v4 -> v8 -> v5 -> v3 -> v6 -> v7,即示例图中红色箭头线为其深度优先遍历顺序。

angular通过watch绑定了每一个双向数据,通过$digest遍历监$$watchers数组 ,每个监听都取当前最新值和旧值对比,如果不同,则执行监听函数,进行model中数据的更新,每次遍历有值变化执行一次render更新视图数据,最后说下apply

3.$apply

首先我们看下源码:

 $apply: function (expr) {
            try {
              beginPhase('$apply');
              try {
                //eval排除异常,
                return this.$eval(expr);
              } finally {
                clearPhase();
              }
            } catch (e) {
            //异常时的处理
              $exceptionHandler(e);
            } finally {
              try {
                $rootScope.$digest();
              } catch (e) {
                $exceptionHandler(e);
                // eslint-disable-next-line no-unsafe-finally
                throw e;
              }
            }
          };
          

         function beginPhase(phase) {
          if ($rootScope.$$phase) {
            throw $rootScopeMinErr('inprog', '{0} already in progress', $rootScope.$$phase);
          };

          $rootScope.$$phase = phase;
        };

        function clearPhase() {
          $rootScope.$$phase = null;
        };
        
        
       $eval: function (expr, locals) {
            return $parse(expr)(this, locals);
          },

关于beginPhase方法 / clearPhase方法:
在调用$apply时,$rootScope中的$$phase字段会被设置为 ‘$apply’,如果当$$phase已经被设置为某个值时,Angular会直接抛出一个异常。所以通常情况下,不需要重复调用$apply方法。在$apply方法完成后,会调用clearPhase方法完成对当前状态的清空,方便下一次的调用。

关于$eval方法:
$eval的作用使用当前scope对象和本地作用域作为上下文给表达式求值。

关于怎么触发脏检查:
$apply方法的运行流程应该很清晰了,通过$eval完成求值并返回,触发一轮$digest循环,$apply是从$rootScope.$digest()是从根作用域下开始进行脏检查,如果你很清楚作用域的情况下,你可以直接调用那层的$scope.$digest(),如果不确定的时候,或者当事件触发影响到父级作用域下的数据时候,还是调用$scope.$apply()比较妥当。$apply()方法有两种形式。第一种会接受一个function作为参数,执行该function并且触发一轮$digest循环。第二种会不接受任何参数,只是触发一轮$digest循环,需要记住的是你总是应该使用接受一个function作为参数的$apply()方法。这是因为当你传入一个function到$apply()中的时候,这个function会被包装到一个try…catch块中,所以一旦有异常发生,该异常会被$exceptionHandler 处理。

  • 1
    点赞
  • 6
    收藏
    觉得还不错? 一键收藏
  • 5
    评论
评论 5
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值