本篇详细介绍:1.angular时如何通过脏检查来实现对$scope对象上变量的双向绑定的。2.实现angular双向绑定的三个重要方法:$digest(),$apply(),$watch().
angular不像Ember.js,通过动态设置setter函数和getter函数来实现双向绑定,脏检查允许angular监听可能存在可能不存在的变量。
$scope.$watch语法糖:$scope.$watch(watchExp,Listener,objectEquality);
监听一个变量何时变化,需要调用$scope.$watch函数,这个函数接受三个参数:需要检测的值或者表达式(watchExp),监听函数,值变化时执行(Listener匿名函数),是否开启值检测,为 true时会检测对象或者数组的内部变更(即选择以===的方式比较还是angular.equals的方式)。举个例子:
1 $scope.name = 'Ryan'; 2 3 $scope.$watch( function( ) { 4 return $scope.name; 5 }, function( newValue, oldValue ) { 6 console.log('$scope.name was updated!'); 7 } );
angular会在$scope对象上注册你的监听函数Listener,你可以注意到会有日志输出“$scope.name was updated!”,因为$scope.name由先前的undefined更新为‘Ryan’。当然,watcher也可以是一个字符串,效果和上面例子中的匿名函数一样,在angular源码中,
1 if(typeof watchExp == 'string' &&get.constant){ 2 var originalFn = watcher.fn; 3 watcher.fn = function(newVal, oldVal, scope) { 4 originalFn.call(this, newVal, oldVal, scope); 5 arrayRemove(array, watcher); 6 }; 7 }
上面这段代码将watchExp设置为一个函数,这个函数会调用带有给定变量名的listener函数。
下面举个应用实例,以插值{{post.title}}为例,当angular在compile编译阶段遇到这个语法元素时,内部处理逻辑如下:
walkers.expression = function( ast ){ var node = document.createTextNode(""); this.$watch(ast, function(newval){ dom.text(node, "" + (newval == null? "": "" + newval) ); }) return node; }
这段代码很好理解,就是当遇到插值时,会新建一个textNode,并把值写入到该nodeContent中.那么angular怎么判断这个节点值改变或者说新增了一个节点?
这里就不得不提到$digest函数。首先,通过$watch接口,会产生一个监听队列$$watchers。$scope对象下的的$$watchers对象下拥有你定义的所有的watchers。如果你进入到$$watchers内部,会发现它这样的一个数组。
$$watchers = [ { eq: false, // whether or not we are checking for objectEquality 是否需要判断对象级别的相等 fn: function( newValue, oldValue ) {}, // this is the listener function we've provided 这是我们提供的监听器函数 last: 'Ryan', // the last known value for the variable$nbsp;$nbsp;变量的最新值 exp: function(){}, // this is the watchExp function we provided$nbsp;$nbsp;我们提供的watchExp函数 get: function(){} // Angular's compiled watchExp function angualr编译过的watchExp函数 } ];
$watch函数会返回一个deregisterWatch function,这意味着如果我们使用scope.$watch对一个变量进行监视,那么也可以通过调用deregisterWatch这个函数来停止监听。
我是萌萌嗒分割线
在angularJs中,当一个controller/directive/etc在运行时,angular内部会先运行$scope.$apply()函数,这个函数接受一个参数,参数为一个函数fn,这个函数就是用来执行fn函数的,执行完fn后才会在$rootScope作用域中运行$scope.$digest这个函数。angular源码中时这样描述$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; } } }
上面的expr这个参数实际上是一个函数,这个函数是你或者angular在调scope.$apply这个函数时传入的。但是大多数时候你可能都不会去使用这个函数,用的时候记得给他传入一个function参数。
ok,说了这么多,让我们看看angular事怎么使用$scope.$apply的,下面以ng-keydown这个指令来举例,为了注册这个指令,且看源码是如何申明的:
var ngDirectives = {}; forEach('click dblclick mousedown mouseup mouseover mouseout mousemove mouseenter mouseleave keydown keyup keypress submit focus blur copy cut paste'.split(','),function(){ var directiveName = directiveNormalize('ng-' + name); ngEventDirectives[directiveName] = ['$parse', function($parse) { return { compile: function($element, attr) { var fn = $parse(attr[directiveName]); return function ngEventHandler(scope, element) { element.on(lowercase(name), function(event) { scope.$apply(function() { fn(scope, {$event:event}); }); }); }; } }; }]; });
上面这段代码遍历了各种不同的可能被触发的event类型,并创建一个叫ng-[EventNameHere](中括号中为事件名),在这个directive的的compile函数中,它在元素上注册了一个事件处理器,事件和对应的directive名字一一对应,比如,cilck事件和ng-click指令对应。当click事件被触发(或者说ng-click指令被触发),angular会执行scope.$apply,执行$apply中的参数(参数为function)。
上面的代码只是改变了和元素(elment)相关联的$scope中的值。这只是单向绑定。这也是这个指令叫做ng-keydown的原因,只有在keydown事件被触发时,能够给与我们一个新值。不是说angular实现了双向数据绑定吗?!
下面看一看ng-model这个directive,当你在使用ng-model时,你可以使用双向数据绑定 – 这正是我们想要的。AngularJS使用$scope.$watch(视图到模型)以及$scope.$apply(模型到视图)来实现这个功能。
ng-model会把事件处理指令(例如keydown)绑定到我们运用的输入元素上 – 这就是$scope.$apply被调用的地方!而$scope.$watch是在指令的控制器中被调用的。你可以在下面代码中看到这一点:
$scope.$watch(function ngModelWatch() { //获取ngModelController中的$scope对象,即数据模型;
var value = ngModelGet($scope); //如果作用域模型值和ngModel值没有同步;$modelValue为模型绑定的值,value为数据模型的真实值,$viewValue为视图中展示的值。ngModel.ngMOdelController.$gormatters属性是为了格式化或者转化ngModel控制器中数据模型,$render函数在$modelValue和$viewValue不相等时,需要调用。 if (ctrl.$modelValue !== value) { var formatters = ctrl.$formatters, idx = formatters.length; ctrl.$modelValue = value; while(idx--) { value = formatters[idx](value); } if (ctrl.$viewValue !== value) { ctrl.$viewValue = value; ctrl.$render(); } } return value; });
如果你在调用$scope.$watch时只为它传递了一个参数,无论作用域中的什么东西发生了变化,这个函数都会被调用。在ng-model中,这个函数被用来检查模型和视图有没有同步,如果没有同步,它将会使用新值来更新模型数据。这个函数会返回一个新值,当它在$digest函数中运行时,我们就会知道这个值是什么!
那么,为什么有时候我们的监听器并没有被触发或者说不起作用?
正如前面所提到的,AngularJS将会在每一个指令的控制器函数中运行$scope.$apply。如果我们查看$scope.$apply函数的代码,我们会发现它只会在控制器函数已经开始被调用之后才会运行$digest函数 – 这意味着如果我们马上停止监听,$scope.$watch函数甚至都不会被调用!因此当$scope.$apply运行的时候,$digest也会运行,它将会循环遍历$$watchers,只要发现watchExp和最新的值不相等,变化触发事件监听器。在AngularJS中,只要一个模型的值可能发生变化,$scope.$apply就会运行。这就是为什么当你在AngularJS之外更新$scope时,例如在一个setTimeout函数中,你需要手动去运行$scope.$apply():这能够让AngularJS意识到它的作用域发生了变化。
但是digest过程究竟是怎样运行的呢?(下面仔细探索源码中$digest函数执行流程,可以不看。。。)
1.首先,标记dirty = false ;
2.遍历当前作用域中的监听对象(current.$$watchers),并且通过判断当前监听对象数组中值watch.get(current)和老值watch.last是否相等:如果不相等,将标记dirty设置成true,将上一个监听对象lastDirtyWatch赋值为当前监听对象,并且将监听对象的老值watch.last赋值为新值,最后,调用watch对象绑定的Listener函数wantch.fn。
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) { 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); } } }
3.进入下一个watch的检查,遍历检查一轮后,如果dirty===true
,我们重新进入步骤1. 否则进入步骤4.
4.完成脏检查。
最后,表达一下个人对这块的看法。作为初学的话,不需要去理解他具体事如何实现数据双向绑定的。只要知道他通过脏检查来实现的,需要主动去触发一些事件才能产生。要想进入$digest cycle:
要满足:
- DOM事件,譬如用户输入文本,点击按钮等。(ng-click)
- XHR响应事件 ($http)
- 浏览器Location变更事件 ($location)
- Timer事件($timeout, $interval)
- 执行$digest()或$apply()
到此为止,说了很多不需要了解的东西,下面的篇章不会这么废话了。