手记

为什么你的Angular双向数据绑定会失效?

Angular双向数据绑定原理探究。

文章源码引用较多,觉得难以理解可以直接跳到末尾总结处。

接触过Angular的人一定会对其“双向数据绑定”的特性印象深刻,而使用过的人更会对莫名其妙出现的双向数据绑定失效的“坑”所困扰。例如下面一段代码:

<!--ctrl控制器下引入com指令-->
<body ng-app="app">
    <div ng-controller="ctrl">
        <input id="ipt" type="text" ng-model="value">
        <button com>increase</button>
        <span id="span" ng-bind="value"></span>
    </div>
</body>

var app = angular.module("app", [])

app.directive("com", function() {
    return function (scope, element) {
        element.on("click", function() {
            //修改scope.value模型的值,观察视图变化
            scope.value="yalishizhude"
            //疑问1:执行结果怎么是 "" ?
            console.log(document.getElementById('span').textContent)
        });
    };
});

app.controller("ctrl", function($scope) {
    var e = angular.element(document.querySelector('#ipt'))
    setTimeout(function() {
        //修改视图元素的值,观察$scope.value模型的值变化
        e.val('100')
        //疑问2:执行结果是 undefined ?
        console.log($scope.value)
    }, 1000)
}); 

源码地址:http://jsbin.com/xogosim/edit?html,js,console,output

如果上面代码中的两个问题你都知道答案,那么你可以跳过下面的内容,如果并不完全清楚,那么我们接着往下说~

双向数据绑定,指的是视图和模型之间的映射关系。双向即 视图 ==> 模型模型 ==> 视图 两个方向。

我们以Angular1.3为例,探究一下这个问题。

视图 ==> 模型

抛开Angular不说,如果我们要实现视图修改时触发模型的修改,很简单,事件(键盘事件、鼠标事件、UI事件)监听就能实现。而Angular会不会也是这么实现的?

最常用的场景便是表单元素的数据绑定,当元素的值发生变化时我们要通知模型层(比如校验、联动),例如用于实现这一功能的 ngModel 指令。

但是我们如果直接找到ngModel的源码,并没有找到直接的事件绑定,依赖ngModelOptions指令倒是有一段代码绑定了事件

//第23769行 
if (modelCtrl.$options && modelCtrl.$options.updateOn) {
    element.on(modelCtrl.$options.updateOn, function(ev) {
        modelCtrl.$$debounceViewValueCommit(ev && ev.type);
    });
}

可是平常没使用ngModelOptions的时候也能同步元素的修改,难道是一开始就想错了?

回忆一下Angular定义指令的时候,不光有像ngModel这样通过属性定义,也有直接定义成元素的,例如form就是一个指令。而最常用最简单的就是把ngModel用在input元素上,不,应该是input指令。

于是找到input指令的代码

//20436行
var inputDirective = ['$browser', '$sniffer', '$filter', '$parse',
    function($browser, $sniffer, $filter, $parse) {
  return {
    restrict: 'E',
    require: ['?ngModel'],
    link: {
      pre: function(scope, element, attr, ctrls) {
        if (ctrls[0]) {
          (inputType[lowercase(attr.type)] || inputType.text)(scope, element, attr, ctrls[0], $sniffer,
                                                              $browser, $filter, $parse);
        }
      }
    }
  };
}];

发现只要nhgModel指令存在的时候,它就会根据type属性执行一段函数。

我们找到inputType.text这个函数之后,层层追寻...

//19928行
  if ($sniffer.hasEvent('input')) {
    element.on('input', listener);
  } else {
    var timeout;

    var deferListener = function(ev, input, origValue) {
      if (!timeout) {
        timeout = $browser.defer(function() {
          timeout = null;
          if (!input || input.value !== origValue) {
            listener(ev);
          }
        });
      }
    };

    element.on('keydown', function(event) {
      var key = event.keyCode;

      // ignore
      //    command            modifiers                   arrows
      if (key === 91 || (15 < key && key < 19) || (37 <= key && key <= 40)) return;

      deferListener(event, this, this.value);
    });

    // if user modifies input value using context menu in IE, we need "paste" and "cut" events to catch it
    if ($sniffer.hasEvent('paste')) {
      element.on('paste cut', deferListener);
    }
  }

  // if user paste into input using mouse on older browser
  // or form autocomplete on newer browser, we need "change" event to catch it
  element.on('change', listener);

终于找到了它在绑定事件的证据,而且还很智能,根据浏览器对事件的支持情况来进行绑定。

发现绑定的事件都执行了一个函数:$setViewValue。继续查找,发现调用ngModelSet函数来修改模型。

模型 ==> 视图

我们再次抛开Angular,回到原生实现,如果我们想要修改视图也比较简单,获取dom元素并修改对应的属性。

再找一个在Angular中将模型值同步到dom上的指令ngBind

//20583行
var ngBindDirective = ['$compile', function($compile) {
  return {
    restrict: 'AC',
    compile: function ngBindCompile(templateElement) {
      $compile.$$addBindingClass(templateElement);
      return function ngBindLink(scope, element, attr) {
        $compile.$$addBindingInfo(element, attr.ngBind);
        element = element[0];
        scope.$watch(attr.ngBind, function ngBindWatchAction(value) {
          element.textContent = value === undefined ? '' : value;
        });
      };
    }
  };
}];

发现其在scope.$watch回调函数中来修改dom元素的文本内容。那我们可以大胆地推测,应该是在修改了对应的$scope属性值之后,触发了scope.$watch调用了ngBindWatchAction回调函数才导致页面元素文本变化的。

//14000行
$watch: function(watchExp, listener, objectEquality) {
    var get = $parse(watchExp);

    if (get.$$watchDelegate) {
      return get.$$watchDelegate(this, listener, objectEquality, get);
    }
    var scope = this,
        array = scope.$$watchers,
        watcher = {
          fn: listener,
          last: initWatchVal,
          get: get,
          exp: watchExp,
          eq: !!objectEquality
        };

    lastDirtyWatch = null;

    if (!isFunction(listener)) {
      watcher.fn = noop;
    }

    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 deregisterWatch() {
      arrayRemove(array, watcher);
      lastDirtyWatch = null;
    };
}

从源码中可以看到,当我们在调用$watch监控变量的时候,其实是创建了一个watcher对象,并将其放入$scope.$$watchers数组中。

那么谁会用到这个数组,并且其中的回调函数呢?

这个代码有点难找,直到找到一个叫做$digest的函数定义。

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

简单概括一下这段代码,遍历$scope.$$watchers,判断如果需要检测的表达式的值(可以理解为$scope的属性)发生了修改,那么执行对应回调函数(比如ngBindg中的ngBindWatchAction)。

修改$scope对应的属性,并调用$scope.$digest。完成这两个条件即可同步模型数据到视图,修改dom元素。换句话说,这两个条件缺一不可。而调用$scope.digest这一过程,我们一般叫做脏值检测

有人可能会说我调用$scope.$apply也可以啊~

理论上来说,用$scope.$digest完成的手动试图同步都可以用$scope.$apply,但是他们之间还是有区别。

//14666行
$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是对$rootScope及子作用域做脏值检测,意味着性能消耗更大。支持回掉函数算是一个好处。

总结

视图 ==事件绑定==> 模型

模型 <==脏值检测== 模型


作者:亚里士朱德 博客:http://yalishizhude.github.io
跟作者一起成长请follow 订阅博客请star

4人推荐
随时随地看视频
慕课网APP