双向绑定确实是目前主流框架比较常见的功能。backbone,angular以及vue三者在双向绑定的实现原理上有所不同。这篇文章主要就angular的双向绑定实现做深入分析。
angular使用dirty(脏值)检查机制来实现了双向绑定,大体思路为:通过watch一遍又一遍地监听脏值也就是所说的脏值检查,当某个dirty由false变为true时,触发view重新渲染。
怪异现象:有些model改变了,但是view并没有能体现出来,没有做到双向绑定,此时需要手动触发apply方法才能OK。
上述只是思路,不能知道具体需要做什么。watch如何工作的?apply又是怎样实现?双向分别如何实现?何时需要手动apply?技术细节如何体现优雅?下面我就这些问题从源码的层面一一深入,并最终真正理解angular双向绑定的原理以及angular的watch/apply/digest。
$scope下$watch方法介绍:
//$watch使用
$scope.value = 0; $scope.$watch( function( ) { return $scope.value; }, function( newValue, oldValue ) { console.log('newValue id updated!'); } );
代码-1
$watch的第一个参数实际就$scope.value,是被监听的对象,也可以写成字符串如'value'。
此处有个细节,第一个参数没有直接写$scope.value而是用的匿名函数return了该变量。通过一个直观的举例来说明这种写法目的:
<body> <a href="www.a.com" οnclick="return newPage()"></a> <a href="www.a.com" οnclick="newPage()"></a> <script> var newPage=function (){ return false; }; </script> </body>
代码-2
通过对两个<a>点击会发现,第一个没有跳转a.com而第二个正常跳转了。可以理解这里return的作用在于当事件中发生一些特定情况(例如此处可以理解为不满足跳转条件返回false)时,希望之后的事件方法停止执行。
继续看$watch。
$watch 的第二个参数是监听函数,当第一个参数值发生变化时,监听函数将会执行。其实$watch还有第三个参数是个boolean类型,作用是是否进行深度值检测。(需要注意的是【代码-1】中会看到我们没有改变$scope.value的值但是console里打印出“newValue is updated!”这是因为初始化时,watch认为$scope.value的值由undefined变成0,所以触发了监听事件)。
下面我们直接看angular中关于$watch的源码,已添加详细的注释辅助大家理解。
$watch: function(watchExp, listener, objectEquality) { var scope = this; //compileToFn可以判断watchExp并返回一个执行表达式的函数 var get = compileToFn(watchExp, 'watch'); var array = scope.$$watchers; //初始化一个监听对象 var watcher = { fn: listener,//监听事件 last: initWatchVal,//最后监听结果 get: get,//调用$parse来返回执行表达式的函数可以获得表达式对于的需要监听的值 exp: watchExp,//备份最初监听表达式 eq: !!objectEquality//是否深度对比//两个!来处理空值或者未定义的行为远比if判断优雅很多 }; lastDirtyWatch = null; // 判断listener是否是函数,不是函数时:如果是可以被识别成方法名的字符串通过compileToFn根据字符串返回函数否则使用noop占位 if (!isFunction(listener)) { var listenFn = compileToFn(listener || noop, 'listener'); //绑定监听事件 watcher.fn = function(newVal, oldVal, scope) {listenFn(scope);}; } //当表达式是字符串时,并且compileToFn(watchExp, 'watch')返回结果的constant为true(说明该字符串为常量) if (typeof watchExp == 'string' && get.constant) { //绑定新监听事件,新监听事件添加了移除scope.$$watchershe和watcher // 至于angular为何要这么做,原因很简单,监听一个常量的意义何在?常量就是一直都不会被改变的变量。 var originalFn = watcher.fn; watcher.fn = function(newVal, oldVal, scope) { originalFn.call(this, newVal, oldVal, scope); //移除scope.$$watchershe和watcher arrayRemove(array, watcher); }; } //初始化 array & scope,$$watchers if (!array) { array = scope.$$watchers = []; } //watcher添加进array array.unshift(watcher); //闭包思想返回了一个可以移除监听的方法 return function deregisterWatch() { //移除scope.$$watchershe和watcher arrayRemove(array, watcher); lastDirtyWatch = null; }; }
代码-3
上述源码可以看到添加的监听事件被放入了scope.$$watchers队列中,该队列放着所有自定义的监听和自动生成的监听(如对插值的监听)。
watch总结:
通过监听,一旦数据变化,引起监听事件执行,我们就可以通过监听函数来更新双向数据中的任何一方。现在问题就变成了:如何知道在哪个时刻当前所有监听中有值变化呢?变化则触发相应事件。
$digest引入:
那么angular是如何实现所有监听事件的遍历的呢?答案是:通过$digest。
还有一个问题:angular需要时时刻刻重复监听$$watchers队列吗?显然不是,那样太不理智了,而且很多时候是没有值变化的。
angular认为以下四种情况:DOM事件,XHR响应事件,Location变更,定时器$timeout, $interval和执行$digest()或$apply()需要调用$digest实现遍历监听队列,当有值变化时就更新数据。
$apply引入:
这里又出现了一个$apply,关于$apply的理解我们先放在本文最后,这里我们先知道:每次调用$apply都会调用$digest。
也就是说,上述四种情况下,angular无论触发$apply或者$digest都可以实现双向绑定的目的?这么简单我们就没有必要这么着急引入$apply了。
事实上,$digest有次数的限制,不能同时发起10次以上。否则会抛出异常。而$apply在调用$digest之前先做了异常处理,也就是,使用$apply来达到使用$digest的目的将会更加安全和合理。这也就是angular在上述的四种情况下都会自动调用$apply而不是直接调用$digest的原因。
一直在说$digest是实现遍历监听队列的方法,到底是如何实现的呢,下面从源码层面介绍$digest:
$digest方法源码介绍:
源码已经详细注释,如果仍无法阅读清楚,建议先看源码结束的源码简介
$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表示这一段功能开始//对应着clearPhase表示结束 beginPhase('$digest'); lastDirtyWatch = null; // 最外层循环,脏值检查循环开始;当不存在脏值,且异步队列为O时终止循环。//这里重点介绍脏值的循环检查。 do { dirty = false; //current指向当前对象 current = target; // 循环添加异步队列 while(asyncQueue.length) { try { asyncTask = asyncQueue.shift(); asyncTask.scope.$eval(asyncTask.expression); } catch (e) { clearPhase(); $exceptionHandler(e); } lastDirtyWatch = null; } //scope循环保证所有的model都能被遍历到 traverseScopesLoop: do { if ((watchers = current.$$watchers)) { length = watchers.length; //循环当前scope中的每个watchers while (length--) { try { //遍历到的当前watch watch = watchers[length]; if (watch) { // watch.get(current)获取当前最新值, last = watch.last旧值 //watch.eq ?作用是判断当前被监听的元素是否是对象,当不是对象且是number类型r时排除NaN!==NaN的判断,因为该判断会返回true if ((value = watch.get(current)) !== (last = watch.last) && !(watch.eq ? //equals深度对比两个对象是否相等 equals(value, last) : (typeof value === 'number' && typeof last === 'number' && isNaN(value) && isNaN(last)) ) ) { //不相等则认为是脏值 dirty = true; // 把发生变化的watch登记在册 lastDirtyWatch = watch; //watch.eq ?作用是判断当前监听的元素是否是对象 //如果不是对象,直接把数值更新到watch.last//如果是对象,则通过深度拷贝该对象到watch.last中 watch.last = watch.eq ? copy(value, null) : value; //此处正式调用监听函数 watch.fn(value, ((last === initWatchVal) ? value : last), current); // watchLog记录日志//ttl最大是10,此处记录最后五条记录 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); } } // lastDirtyWatch上一轮循环中最后一个发生值变化的监听 //如果当前watch没有变化,且当前watch正是上一轮的最后一个变化的watch,说明此次循环中所有的脏值都已更新,循环可以结束。 else if (watch === lastDirtyWatch) { dirty = false; //结束循环 break traverseScopesLoop; } } } //异常处理,通过 $exceptionHandler处理异常 catch (e) { //这一段功能结束 clearPhase(); $exceptionHandler(e); } } } //下面的几行注释需要一行一行理解,否则容易被搞得模糊不清 // current.$$childHead判断当前是否有child,如果没有:判断(current !== target && current.$$nextSibling) //current !== target判断当前元素是否是最顶层对象如果不是:判断current.$$nextSibling是否存在兄弟元素 //当前如果没有child且不是顶层元素且存在兄弟元素,那么next为true表示current可以指向当前元素下一个元素 //此处if(!next)指的是希望:当前元素没有子元素,且 (当前元素没有兄弟元素 或者当前元素为最顶层元素)时,进入循环进而实现current指向父元素 if (!(next = (current.$$childHead || (current !== target && current.$$nextSibling)))) { //顶层元素已经不存在父元素,所以current !== target是排除当前元素为顶层元素的情况 //!(next = current.$$nextSibling)指当前元素无兄弟元素 //(current !== target && !(next = current.$$nextSibling))指的就是爸爸在世的小儿子,这种情况就交给爸爸处理吧 while(current !== target && !(next = current.$$nextSibling)) { //交给爸爸了 //此时while并不一定会终止循环。如果爷爷在世,并且你还有叔叔的话,那就终止循环处理叔叔吧。但如果没有叔叔,爷爷在世,那就得交给爷爷玩了。 //总之就是想把整个家里都打个遍,一个都不能放过。为达目的,仇深似海。 current = current.$parent; } } } while ((current = next)); // 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, toJson(watchLog)); } } while (dirty || asyncQueue.length); // 循环结束 // 标记退出digest循环 clearPhase(); while(postDigestQueue.length) { try { postDigestQueue.shift()(); } catch (e) { $exceptionHandler(e); } } }
代码-4
【代码-4】简述原理:
先看最外层的循环:
do{
}while(dirty||asynvQueue.length)
这是整个循环的最外层,当不存在dirty(也就是脏值)并且当且$digest队列(该队列最大长度为10)为0了就终止循环。
第二层的循环:
traverseScopesLoop
这个循环体是实现了遍历所有的scope,假设所有scope关系为:scope1[scope1.1,scope1.2[scope1.2.1,scope1.2.2]],scope2[scope2.1],则遍历的顺序是1 --> 1.1 --> 1.2 --> 1.2.1 --> 1.2.2 --> 2 --> 2.1
对于每个scope又会在其作用域下遍历所有的watch。
对于每个watch,$digest会获取监听对象当前值对比该监听对象的当前值和旧值,如果值有变化,则标记dirty=true,调用当前监听函数实现数据双向更新。并用变量A记录当前的监听对象,同时新值作为旧值存放。一次遍历完成后,有任何一个值变化都会使得dirty=true。如果dirty为true则重新遍历所有scope下的所有watch。新的遍历之前先初始化dirty=false,直到dirty为false停止遍历。
疑问:
既然每次有值变化,当场就已经做了双向值更新,一次遍历之后,所有的脏值就都被更新了,为何还要标记dirty=true然后再去做一次大循环呢,如果作用域嵌套复杂岂不是很浪费性能?这样做主要是考虑以下情况出现:
监听顺序:A监听,B监听,C监听;
改变值的顺序:A改变 ,B改变,C改变,且B的改变会改变A的值。此时按照监听顺序,A改变且监听更新了数据,B改变,B也相应更新了数据,B的改变也导致A又发生了变化,而此时如果不重新遍历监听,则无法获得最新的A的数值。这只是最简单的情况。如果C的改变导致了B的改变,B又导致A的改变了呢?
以上是$digest的源码分析。
总结:
至此我们就清楚了:angular通过watch绑定了每一个双向数据,并给他配置了一个值改变时需要触发的监听函数。通过$digest遍历监听队列,每个监听都取当前最新值和旧值对比,如果不同,则执行监听函数,进行model中数据的更新,每次遍历有值变化执行一次render更新view数据。四种情况下会自动执行$digest,而调用$digest方法是通过$apply进行调用的。双向绑定原来如此。
所有疑问都得到解答了吗?不是。上面提到$apply还没有分析。$apply的要远远比上面俩哥们好理解多了,所以把这哥们放在最后,目的就是让大家以一个轻松舒缓的情绪结束本文的阅读。
直接上源码:
$apply方法介绍:
$apply: function(expr) { try { beginPhase('$apply'); //通过eval排除异常,当有异常时会被catch处理 return this.$eval(expr); } catch (e) { //异常处理 $exceptionHandler(e); } finally { clearPhase(); try { //就在此处apply调用了digest $rootScope.$digest(); } catch (e) { $exceptionHandler(e); throw e; } } }
有没有发现,通过理解了$watch和$digest,再来看$apply简直so easy!所以就不多解释了。
注意通过$digest的理解,我们知道是存在DOM事件,XHR响应事件,Location变更,定时器$timeout, $interval的情况下angular认为需要检查数据有无变化时才会调用$apply进而调用了$digest遍历监听。当一些变量被改变时并不在上述几种情况下,如使用原生的setTimeout()而非$timeout时,因为angular没有自动调用$apply所以我们需要手动调用以下$apply
最后致大家:
既然这篇文章写了angular双向绑定,那下一次就要找个时间来写一下vue和backbone的双向绑定了,这样可以形成对比。
写得过于匆忙,如果有些地方有问题还请大神指正。
by yezhen 2017/03/13