AngularJS双向绑定背后的秘密

版权声明:本文为博主原创文章,未经博主允许不得转载。 https://blog.csdn.net/qq_36407875/article/details/83306170

AngularJS的双向绑定

    AngularJS为我们提供了非常酷炫的双向绑定,意味着View中任何数据的变化会自动的同步Scope当中,同样的scope模型中数据的变化也会同步到View上去。当我们写下表达式{{aModel}}的时候,Angular会在幕后在我们的scope中设置一个watcher,它用来监控数据的变化,达到更新view的目的,这里的watcher和和我们在scope中设置的watcher是一样的。$watch接收的第2个参数是一个回调函数,用于更新View的操作,问题在于AngularJS是如何知道aModel发生了变化,然后去调用对应的回调函数?这就需要用到AngularJS中的$digest循环了。

$digest循环

    在调用了$digest函数之后,就会开始$digest循环,假如我们在一个ng-click指令的对应handle函数中更改的了scope的某条数据,此时AngularJS就会自动的调用$digest函数来触发一轮$digest循环$digest循环开始之后会触发每一个watcher,这些watchers会检查scope中的当前model值是否和上一次计算的到的model值是否相同,不同的话对应的回调函数就会执行,然后在View中就会相应更新。除了ng-click指令,还有一些其他的内置指令、服务可以来更改models(比如ng-model$timeout等)会自动触发一次$digest循环

    但是AngularJS并不会直接调用$digest() 而是调用$scope.$apply(),然后$scope.$apply()会调用$rootScope.$digest(),因此$digest循环$rootScope开始,随后访问到所有的子scope
中的watchers。

$apply

    $scope.$apply()会自动调用$rootScope.$digest()$apply()方法有两种形式:
    1.接受一个 函数 作为参数,执行函数并触发一轮$digest循环
    2.不接受任何参数,触发一轮$digest循环

什么时候需要手动调用$apply?

    AngularJS只负责发生在AngularJS上下文环境中的变更做出自动地相应,也就是说在$apply() 方法中发生对于models的更改,angularJS的内置指令就是这么做的,所以任何的models的更改都会反应到view上面去。但是如果我们在AngularJS上下文以外的任何地方修改了model,那么就需要手动调用$apply()来通知AngularJS。
    比如我们使用了原生JavaScript中的setTimeout()来更新scope模型中的数据,AngularJS是没办法知道我们更改了什么,这时候就必须我们手动调用$apply()主动触发一轮$digest循环
例如下面的Demo使用setTimeout()更新scope模型中的数据

angular.module("myApp", []).controller("appController", ["$scope", function($scope) {
    $scope.aModel = "Angular";
    setTimeout(() => {
        $scope.aModel = "changed";
        console.log("after 2 seconds, and aModel is " + $scope.aModel);
    }, 2000);
}]);

    运行上面的Demo,在控制台上面,我们看到model已经更新了,但是View上面没有同步这个更新,原因就是因为在这里需要我们手动去调用$apply()方法,修改如下:

angular.module("myApp", []).controller("appController", ["$scope", function($scope) {
    $scope.aModel = "Angular";
    setTimeout(() => {
        $scope.$apply(() => {
            $scope.aModel = "changed";
            console.log("after 2 seconds, and aModel is " + $scope.aModel);
        });
    }, 2000);
}]);

    运行上面的例子,可以看到model和view都更新了,因为我们把我们的代码放到了$apply()中,它会自动帮我们调用$rootScope.$digest(),从而触发watchers并更新View
    我们 也可以 使用第二种形式,也就是不带任何参数的apply,我们只需要在修改过后,手动调用$apply()即可。但是官方更推荐 使用第一种形式,因为我们将function传入到$apply()中时,这个function会被塞到一个try...catch中,一旦有异常发生,AngularJS会帮我们处理。

$apply是如何工作的?

贴出官方源码:

$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;
    }
  }
}

    首先从方法的定义上来看,$apply()方法接收一个Angular的表达式,然后通过一个beginPhase方法设置当前阶段状态,紧接着通过$eval方法将表达式解析为函数并返回。最后调用clearPhase方法清理阶段状态,同时,$eval方法的try代码块也对应了一个finally代码块。目的是保证最关键的digest循环会被触发:$rootScope.$digest()
    这上面不熟悉的三个方法就是:beginPhaseclearPhase$eval

beginPhase/clearPhase的实现

贴出官源码:

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

  $rootScope.$$phase = phase;
}

function clearPhase() {
  $rootScope.$$phase = null;
}

我们在调用$apply()时,$rootScope.$$phase字段会被设置成 "$apply" 如果当$$phase已经被设置为某个值了,Angular会直接抛出一个异常,所以一般我们不需要重复去调用$apply()方法,当$apply()方法完成后,会调用clearPhase方法 清除掉这个状态,方便下一次的调用。

$eval实现

贴出官方源码:

$eval: function(expr, locals) {
  return $parse(expr)(this, locals);
}

其实$eval方法是基于$parse方法的,$parse方法就是将一个表达式解析成为函数

angular.module("myApp",[]).controller("appController",["$scope", function($scope) {
     $scope.a = 1;
     $scope.b = 2;

     console.log($scope.$eval('a+b'));
     console.log($scope.$eval(function($scope){ return $scope.a + $scope.b;}));          
}]);

两条语句都会输出3 ,说明除了表达式,也可以传入一个函数,那么$eval的作用其实就是,给定一个表达式,使用当前scope对象作为上下文进行该表达式的求值。

脏检查

脏检查是由$digest执行的,那么$digest会执行多少次呢?假如我们正在执行一次$digest循环,watchers被触发,然后检测model是否改变,如果改变了就会执行对应的listener,但是如果listener本身也会修改scope中的models呢?所以其实Angular并不会只执行一次$digest循环,在当前$digest循环结束后,会再执行一次$digest循环来检查models是否发生了变化。这其实就是脏检查,用于处理在listener函数被执行时可能引起的model变化,因此,$digest循环会持续运行直到model不再发生变化,但是最多十次,所以尽可能地不要在listener函数中修改model。所以$digest循环至少也会执行两次,即使在listener中没有改变models,他会再执行一次确保没有models更新。

$digest实现
      $digest: function() {
        var watch, value, last, fn, get,
            watchers,
            length,
            dirty, ttl = TTL,
            next, current, target = 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 = 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 $applyAsync would
          // lead to a '$digest already in progress' error.
          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 { // "traverse the scopes" loop
            if ((watchers = current.$$watchers)) {
              // process our watches
              length = watchers.length;
              while (length--) {
                try {
                  watch = watchers[length];
                  // 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;
                    if ((value = get(current)) !== (last = watch.last) &&
                        !(watch.eq
                            ? equals(value, last)
                            : (typeof value === 'number' && typeof last === 'number'
                               && isNaN(value) && isNaN(last)))) {
                      dirty = true;
                      lastDirtyWatch = watch;
                      watch.last = watch.eq ? copy(value, null) : value;
                      fn = watch.fn;
                      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) {
                      // 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
            if (!(next = ((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

          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);
          }

        } while (dirty || asyncQueue.length);

        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;
      }

简单分析:
最外层的循环:

do {

} while (dirty || asyncQueue.length);

当脏值不存在并且$digest队列为0的时候终止循环
内层的循环:

traverseScopesLoop

这个循环体去遍历所有的scope,对于每个scope又会去遍历每一个watcher
然后$digest获取获取每个监听对象的当前值旧值,如果有变化,就让dirty = true,然后双向更新。然后将新值作为旧值再存放。一次遍历之后,只要一个监听对象有变化,dirty都会被置为true,如果dirty为true,则重新遍历scope下的所有watchers,遍历之前把dirty置为false,直到dirty为false就停止遍历了。

自己写一个低配版的脏检查

根据$digest的原理,我们可以书写一个自己的低配版的脏检查了。

设置scope

scope可以包含任意我们想要存储的对象,然后可以扩展scope的原型来实现$digest$watch

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

        Scope.prototype.$watch =  function(watchExp, listener) {
        
        };

        Scope.prototype.$digest = function() {
        
        };

Scope中的$$watchers用来存储所有的watchers,然后$watch接受两个参数,当我们的$watch被调用时,就把它push到Scope中的$$watchers数组里面去。

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

        Scope.prototype.$watch =  function(watchExp, listener) {
            this.$$watchers.push({
                watchExp,
                listener: listener || function() {}
            });
        };
        
        Scope.prototype.$digest = function() {
        
        };

然后实现$digest,来检查新旧值是否相等,不相等,监听器就会被触发,然后一直循环,直到相等为止。

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

        Scope.prototype.$watch =  function(watchExp, listener) {
            this.$$watchers.push({
                watchExp,
                listener: listener || function() {}
            });
        };

        Scope.prototype.$digest = function() {
            var dirty;

            do {
                dirty = false;
                for(var i = 0 ; i  <this.$$watchers.length; i++) {
                    var newVal = this.$$watchers[i].watchExp(),
                        oldVal = this.$$watchers[i].last;
                    
                    if (newVal !== oldVal) {
                        this.$$watchers[i].listener(newVal, oldVal);
                        dirty = true;
                        this.$$watchers[i].last = newVal;
                    }
                }
            } while(dirty)
        };

最后我们可以创建一个$scope实例进行测试

var $scope = new Scope();

$scope.name = 'Lan';

$scope.$watch(function(){
    return $scope.name;
}, function( newValue, oldValue ) {
    console.log(newValue, oldValue);
} );

$scope.$digest();

上面的代码会在控制台上输出如下内容:

Lan undefined

因为之前的$scope.nameundefined,现在的值是Lan

现在我们把$digest函数绑定到一个input元素的keyup事件上。这就意味着我们不需要自己去调用$digest。这样就可以实现一个简单的双向数据绑定了。

        var $scope = new Scope();
        $scope.name = "Lan";
        var element = document.querySelectorAll('input');
        var nameElement = document.querySelectorAll('strong');
        element[0].onkeyup = function() {
            $scope.name = element[0].value;
            $scope.$digest();
        };
        $scope.$watch(function() {
            return $scope.name;
        }, function(newVal, oldVal) {
            nameElement[0].innerHTML = $scope.name;
            console.log(newVal, oldVal);
        });

效果如下:
在这里插入图片描述

猜你喜欢

转载自blog.csdn.net/qq_36407875/article/details/83306170
今日推荐