双向绑定---angular之watch、apply、digest原理深入分析(源码分析)

    双向绑定确实是目前主流框架比较常见的功能。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

 

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值