AngularJs双向绑定的研究
发布在AngularJs2014年8月18日view:7613
在文章任何区域双击击即可给文章添加【评注】!浮到评注点上可以查看详情。

其实关于angularJS双向绑定,已经有很多人写过了,但是还是想分享一下我的理解可能比较适合初学者。

众所周知,Angularjs的一个最大的特点就是双向绑定,简单来说就是你在js里写一个对象,同时在dom里面给某个元素绑定这个对象,然后当你在js里面修改这个对象的值,随之dom里那个元素也会随之改变,这样省了不少代码来修改dom,对维护性和扩展性都比较有帮助。那么,为什么要对这个双向绑定进行研究呢?

双向绑定的三个重要方法:

  • $scope.$apply()
  • $scope.$digest()
  • $scope.$watch()

在angularjs双向绑定中,有2个很重要的概念叫做dirty check,digest loop,这个dirty check是用来检查绑定的scope中的对象的状态的,例如,我在js里创建了一个对象,并且把这个对象绑定在scope下,这样这个对象就处于digest loop中,loop通过遍历这些对象来发现他们是否改变,如果改变就会调用相应的处理方法来实现双向绑定,让我们来看看源码:

$digest: function() {
        var watch, value, last,
            watchers,
            asyncQueue = this.$$asyncQueue,
            postDigestQueue = this.$$postDigestQueue,
            length,
            dirty, ttl = TTL,
            next, current, target = this,
            watchLog = [],
            logIdx, logMsg, asyncTask;

        beginPhase('$digest');

        do { // "while dirty" loop
          dirty = false;
          current = target;

          while(asyncQueue.length) {
            try {
              asyncTask = asyncQueue.shift();
              asyncTask.scope.$eval(asyncTask.expression);
            } catch (e) {
              $exceptionHandler(e);
            }
          }

          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 && (value = watch.get(current)) !== (last = watch.last) &&
                      !(watch.eq
                          ? equals(value, last)
                          : (typeof value == 'number' && typeof last == 'number'
                             && isNaN(value) && isNaN(last)))) {
                    dirty = true;
                    watch.last = watch.eq ? copy(value) : value;
                    watch.fn(value, ((last === initWatchVal) ? value : last), current);
                    if (ttl < 5) {
                      logIdx = 4 - ttl;
                      if (!watchLog[logIdx]) watchLog[logIdx] = [];
                      logMsg = (isFunction(watch.exp))
                          ? 'fn: ' + (watch.exp.name || watch.exp.toString())
                          : watch.exp;
                      logMsg += '; newVal: ' + toJson(value) + '; oldVal: ' + toJson(last);
                      watchLog[logIdx].push(logMsg);
                    }
                  }
                } 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.$$childHead || (current !== target && current.$$nextSibling)))) {
              while(current !== target && !(next = current.$$nextSibling)) {
                current = current.$parent;
              }
            }
          } while ((current = next));

          if(dirty && !(ttl--)) {
            clearPhase();
            throw $rootScopeMinErr('infdig',
                '{0} $digest() iterations reached. Aborting!\nWatchers fired in the last 5 iterations: {1}',
                TTL, toJson(watchLog));
          }
        } while (dirty || asyncQueue.length);

        clearPhase();

        while(postDigestQueue.length) {
          try {
            postDigestQueue.shift()();
          } catch (e) {
            $exceptionHandler(e);
          }
        }
      },

代码这么多,肯定一下看不过来,不过没关系,注意到这个方法里面有许多的do while,其实这个do while便形成了digest loop,通过不断的检查和dirty check来检查对象的值,并且实现相应的方法。

那么apply方法又是有什么用呢?来看源码:

$apply: function(expr) {
        try {
          beginPhase('$apply');
          return this.$eval(expr);
        } catch (e) {
          $exceptionHandler(e);
        } finally {
          clearPhase();
          try {
            $rootScope.$digest();
          } catch (e) {
            $exceptionHandler(e);
            throw e;
          }
        }
      },

可以看到,在apply方法里其实是调用了digest方法的,那么为什么要多增加一个apply来调用digest呢,可以看到这段代码中并没有直接调用digest而是首先进行了对expr的检验,也就是eval方法,这个方法如果校验不通过,是会抛出异常的,而angular并不推荐外部直接调用digest,随意就增加了apply方法来间接调用。

那么问题又来了,这个digest loop是在什么时候被调用的呢?换句话说,js对象的值改变,digest是怎么知道的呢?下面来做一个实验:

在html页面中

<span class="test" ng-click="test()">click</span>
<span>{{test}}</span>

在对应的js里面写上对应的方法:

$scope.test = function(){
    $scope.test = "you clicked";
}

在源码中的digest增加一个console.log(1), 当我点击span时,test变成了you click,同时也打印了console.log(1),这说明digest被调用了!那是为什么呢,于是又翻源码,终于让我找到了:

var ngEventDirectives = {};
forEach(
  'click dblclick mousedown mouseup mouseover mouseout mousemove mouseenter mouseleave keydown keyup keypress submit focus blur copy cut paste'.split(' '),
  function(name) {
    var directiveName = directiveNormalize('ng-' + name);
    ngEventDirectives[directiveName] = ['$parse', function($parse) {
      return function(scope, element, attr) {
        var fn = $parse(attr[directiveName]);
        element.on(lowercase(name), function(event) {
          scope.$apply(function() {
            fn(scope, {$event:event});
          });
        });
      };
    }];
  }
);

看到了么,在angular的内置指令中,通过对浏览器内置事件的进一步封装,同时加上apply方法,就使click事件进入到了digest loop中,自然就会触发双向绑定,如果不信,可以在做一个实验:

在html代码中:

<span class="test">click</span>
<span>{{test}}</span>

在js中:

$(".test").click(function(){
$scope.test = "you clicked";
})

这次我们用jquery来绑定,发现代码确实进入了click的回调函数,而页面上的test并没用被赋值,而console.log(1)并没有打印,这就说明没有进入到digest loop,所以自然不会改变,如果把代码修改一下:

$(".test").click(function(){
$scope.test = "you clicked";
$scope.$apply();
})

我们发现,test改变了,同时html上的也被赋值。

但是我曾经看到过一篇文章,讲的是digest就像一个心跳,他每50ms运行一次用来检测dirty check,但是我并没有找到相关的依据来证明这一点,我也很奇怪这50ms是如何算出来的,到底是怎么实现每50ms来调用的?如果有知道的,不吝赐教!! 点这里

那么watch方法又有什么作用呢?

其实watch就是一个Listener ,它用来存储那些需要被监听的对象,可以这么说吧。。

在上面的digest的源码中可以看到:

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 && (value = watch.get(current)) !== (last = watch.last) &&
                      !(watch.eq
                          ? equals(value, last)
                          : (typeof value == 'number' && typeof last == 'number'
                             && isNaN(value) && isNaN(last)))) {
                    dirty = true;
                    watch.last = watch.eq ? copy(value) : value;
                    watch.fn(value, ((last === initWatchVal) ? value : last), current);
                    if (ttl < 5) {
                      logIdx = 4 - ttl;
                      if (!watchLog[logIdx]) watchLog[logIdx] = [];
                      logMsg = (isFunction(watch.exp))
                          ? 'fn: ' + (watch.exp.name || watch.exp.toString())
                          : watch.exp;
                      logMsg += '; newVal: ' + toJson(value) + '; oldVal: ' + toJson(last);
                      watchLog[logIdx].push(logMsg);
                    }
                  }
                } catch (e) {
                  $exceptionHandler(e);
                }

上面这段代码需要和下面这段代码一起来看:

$watch: function(watchExp, listener, objectEquality) {
        var scope = this,
            get = compileToFn(watchExp, 'watch'),
            array = scope.$$watchers,
            watcher = {
              fn: listener,
              last: initWatchVal,
              get: get,
              exp: watchExp,
              eq: !!objectEquality
            };

        // in the case user pass string, we need to compile it, do we really need this ?
        if (!isFunction(listener)) {
          var listenFn = compileToFn(listener || noop, 'listener');
          watcher.fn = function(newVal, oldVal, scope) {listenFn(scope);};
        }

        if (typeof watchExp == 'string' && get.constant) {
          var originalFn = watcher.fn;
          watcher.fn = function(newVal, oldVal, scope) {
            originalFn.call(this, newVal, oldVal, scope);
            arrayRemove(array, watcher);
          };
        }

        if (!array) {
          array = scope.$$watchers = [];
        }
        // we use unshift since we use a while loop in $digest for speed.
        // the while loop reads in reverse order.
        array.unshift(watcher);

        return function() {
          arrayRemove(array, watcher);
        };
      },

我们通过,调用watch方法来传入我们想被监听的对象,这样,这个对象就在digest loop中被检查,当对象发生改变时,就会调用相应的回调方法,就像这样:

$Scope.$watchCollection('test',function(){
      console.log('watch!');
})

在digest中还有一段代码:

// 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.$$childHead || (current !== target && current.$$nextSibling)))) {
              while(current !== target && !(next = current.$$nextSibling)) {
                current = current.$parent;
              }
            }

从注释就可以看出来,这段代码就是对所有scope上的对象进行dirty check,来检测对象是否发生变化。

那么如何跳出digest loop呢?

在digest里还有一段代码:

while(asyncQueue.length) {
            try {
              asyncTask = asyncQueue.shift();
              asyncTask.scope.$eval(asyncTask.expression);
            } catch (e) {
              $exceptionHandler(e);
            }
          }

我想这个asyncQueue就是用来执行loop之外的方法的,这个方法最后调用的是evalAsync方法,在这个方法里面,其实发现:

$evalAsync: function(expr) {
        // if we are outside of an $digest loop and this is the first time we are scheduling async task also schedule
        // async auto-flush
        if (!$rootScope.$$phase && !$rootScope.$$asyncQueue.length) {
          $browser.defer(function() {
            if ($rootScope.$$asyncQueue.length) {
              $rootScope.$digest();
            }
          });
        }

        this.$$asyncQueue.push({scope: this, expression: expr});
      },

在js的settimeout方法里调用digest来达到跳出digest loop的目的,之所以angular要提供这个方法来让我们能跳出loop,我想是为了特殊情况来考虑吧,好吧,知道了双向绑定的原理,我想我们自己也可以写一个类似的demo出来,我会在以后的文章中继续研究!

完!

评论
发表评论
3年前
赞了此文章!
5年前
添加了一枚【评注】:aDS
WRITTEN BY
PUBLISHED IN
AngularJs

大家都知道的

我的收藏