AngularJS Digest 过程解析

本文结合相关资料剖析Angular中的Digest过程。

一、Digest 基本概念和原理

Digest过程是Angular实现双向数据绑定的基础。由于对scope对象的改动需要及时反映的到HTML元素的属性上,Angular要不时地检查每个scope变量的变化,这个检查过程就是Digest(翻译中文为’消化‘,顾名思义,消化掉新的改变)。该过程由scope对象的$digest方法完成,通常不需要自己调用,Angular在每一轮JS执行后会自动调用每个scope的$digest方法,这类场景包括响应Angular内置的directive事件(如ng-click,ng-change等),controller的初始化,或者使用Angular内置的service的回调函数(如$timeout,$http等)。这些工作保证了每个scope的更改都能被及时发现。


简单来说,$digest所做的事情就是脏数据检查(dirty-checking),即检查所有监视的数据是否有改动。这就需要为每个变量设置一个监视器(watcher)。每个监视器由两部分组成,一是监视函数(watch function),用于获得当前变量的最新值;一是监听函数(listener function),由于在检测到数据改动时回调。dirty-checking认为数据发生改变的条件是当前数据与保存的历史数据不一致。Angular使用scope对象的$watch方法为scope定义的变量添加watcher。注意,如果一个变量没有用于数据绑定(如使用ng-model),则Angular不会自动为其添加watcher。例如:

define(['angular'], function(angular) {
    angular.module('myApp', [])
      .controller('MyController', ['$scope', function ($scope) {
        $scope.name = 'Change the name';
      }]);
});

如果$scope.name在HTML的某个ng-controller下被绑定,那么Angular为其添加一个watcher,反之则不会,除非自己手动添加:

scope.$watch(
	function(scope) {return scope.name;},
	function(newValue, oldValue) { }
);
 

上述代码为name定义了一个监听器,指定两个函数为参数。watch函数用于获取需要监视的变量的当前值;listener函数以name的新旧值为参数,作为回调。其实,通常我们习惯将watch函数简写成变量的名字,Angular会在内部将其转换为watch函数:

scope.$watch(
	'name',
	function(newValue, oldValue) { scope.name = 'Cat';}
);

$watch函数的实现逻辑很简单,根据参数定义一个watcher对象,将watcher对象加入内部维护的watcher列表($$watchers)中,最后返回一个闭包用于销毁当前watcher。例如:

scope.prototype.$watch = function(watchFn, listenerFn, valueEq){
	var self = this;
	var watcher = {
		watchFn: watchFn,
		listenerFn: listenerFn,
		valueEq : valueEq,
  		last : null
        };
	this.$$watchers.push(watcher);
	return function(){//remove current watcher from watcher list.
	};
};


注意,$watch函数还有第三个常用参数valueEq,用于指定在比较新值与旧值时,是否按照对象值来比较,默认是按照引用来比较。比如,watcher监视的对象是一个数组,默认情况下,该watcher不会逐一检查数组每个元素是否变化,而只是检查该数组的引用是否变化。$watch的返回值可以在适当的情况下调用以注销某个watcher。last用于记录上一次监视时该变量的值。

$digest过程的逻辑就是检查watcher列表中的每一项,看当前值与上次的值是否相同,如果不同则调用listener回调函数。这就是dirty-checking的核心逻辑,例如:

	var self = this;
	var newValue, oldValue, dirty;
	this.$$watchers.forEach(function(watcher){
		
		newValue = watcher.watchFn(self);
		oldValue = watcher.last;
		if(!self.isEqual(newValue, oldValue, watcher.valueEq)){
			self.$$lastDirtyWatch = watcher;
			watcher.last = newValue;
			watcher.listenerFn(newValue, oldValue);
			dirty = true;
		}else{
			return false;
		}
	});
	return dirty;

这里使用isEqual工具函数检查newValue和oldValue是否相等,如果valueEq为true,则要按值进行比较。dirty作为dirty-checking的返回值,表示变量是否有变化。

然而,有时只对watcher列表检查一遍是不够的,因为开发者可能在某个watcher的listener函数中修改了scope的某个变量(如前面的例子),这个改变在第一轮检查时无法发现。因此,Angular在每次调用$digest时,对watcher列表进行了多次检查,直到没有变量变化为止,如果总是有变量发生变化,Angular限制检查watcher列表的最多次数为10,这个值是默认值。如果超过10次,$digest会抛出异常’Maximum iteration limit exceeded.‘。例如:

	var times = 10;//default of Angular
	var dirty;
	beginPhase("$digest");

	do {
		dirty = this._digest();
		if(dirty && !(times--)){
			this.$clearPhase();
			throw "Maximum iteration limit exceeded.";
		}
	}while(dirty);
	clearPhase();

上述代码将dirty-checking的具体逻辑封装到_digest方法中,在外层通过while循环限制dirty-checking的次数。这里面的beginPhase和clearPhase方法用于记录当前的digest阶段,后面有解释。在实际的Angular实现中,对上述过程有进一步的优化。考虑这个场景,加入watcher列表很长,而只有其中少量变量发生改变,Angular能够将检查watcher的次数平均减少一半,详细实现可以参考Angular源代码。


二、Digest 的应用场景

在使用AngularJS开发过程中,我们很少自己调用$digest方法,因为在多数情况下Angular知晓当前scope的变量可能发生变化,如上面小节提到的场景。然后,依然有些情况下Angular对scope对象的改变懵然不知。例如,在新一轮代码中执行某些函数而没有通过Angular提供的接口,setTimeout或者Ajax的回调函数:

setTimeout(function(){
	scope.name = 'Circle';		
}, 50);

此时,Angular对name的改变不知情,因为setTimeout的参数函数延迟到另一轮执行,过后没有调用$digest方法,直到其他事件发生时触发Digest,Angular才能知晓。对于这种情况我们需要手动触发$digest。通常,我们不直接调用$digest方法,而是使用$apply方法:

setTimeout(function(){
	$scope.$apply(function(){
		scope.name = 'Circle';	
	});	
}, 50);


$apply方法的实现很简单,首先调用指定的函数,然后调用$digest方法:

scope.prototype.$apply = function(fn){
	try{
		beginPhase("$apply");
		return fn();
	} finally{
		clearPhase();
		this.$digest();//always execute.
	}
};


值得注意的是,这里的逻辑放到了try/finally代码块中,除了异常处理外,还保证$digest方法总能被执行,即使在执行fn时发生了异常。(Angular实现中,通过scope.$eval方法执行fn函数,因为传入的fn也有可能是个表达式,详细请参考AngularJS 源代码)。对于上面代码的场景,也可以使用Angular提供的$timeout service,因为该service在实现时内部调用了$apply方法。使用$apply方法时还要注意一点,Angular一次只允许一个digest过程执行,因此它在调用$digest方法时会判断当前的阶段,如果正在digest,则会抛出异常。这段逻辑由前面提到的beginPhase/clearPhase实现,$apply方法也有自己的阶段,称为‘$appy’。综上,在调用$apply时,一定要理清当前上下文是否可能正在digest。例如,在一个watcher的listener函数中就不能再调用$apply方法。

$apply方法的特点是,传入的函数会立即执行。scope中还实现了几个与digest相关的方法,如$evalAsync,$applyAsync等。这些方法适用于不同的场景,下面一一阐述。

$evalAsync : 该方法适用于这样的场景,scope中的改动不被Angular知晓(如在某个回调函数中),但希望该改动被Angular消化掉,且不能调用$apply方法(因为有可能已经有一个digest cycle正在进行)。该方法将需要执行的函数延迟到当前digest过程的下一次循环迭代再执行。看代码可能会好理解一些:

scope.prototype.$evalAsync = function(fn){
	//store the scope is used for scope inheritance.
	this.$$asyncQueue.push({scope: this, fn: fn});
};


这里有一个$$asyncQueue队列用于维护所有延迟的函数,每次调用$evalAsync时,函数会立即放入队列,方法返回。在当前digest cycle执行下一次dirty-checking时,从队列中取出每个函数并执行,如:

do {
	while(this.$$asyncQueue.length){
		try{
			var asyncTask = this.$$asyncQueue.shift();
			asyncTask.scope.$eval(asyncTask.fn);
		}catch(e){
			console.error(e);
		}
	}
	//wait for all watchers stable, no value changes.
	dirty = this.$$digestOnce();//...
}while(dirty || this.$$asyncQueue.length);

当没有脏数据并且没有延迟的函数时,才退出循环,可以对照前面$digest方法实现。
如果在调用$evalAsync时没有正在进行的digest,则$evalAsync会调用$digest启动这样一个过程:

scope.prototype.$evalAsync = function(expr){
	//store the scope is used for scope inheritance.
	var self  = this;
	if(!self.$$phase && !self.$$asyncQueue.length){
		setTimeout(function(){
			self.$digest();
		}, 0);
	}
	this.$$asyncQueue.push({scope: this, fn: fn});
};

可以看出,在将fn放入队列前,该方法首先检查当前的阶段是否有值(如‘$digest’,‘$apply’等),如果没有就调度一个$digest方法执行。

$applyAsync:该方法适用于在Angular知晓的范围外,频繁地执行某些回调函数(如某个service),并且需要Angular消化掉这些函数对scope的改动。例如,使用第三方的http服务,在响应时回调函数根据获取的数据改变了scope的某个变量,这个改动Angular不知晓。如果频繁调用$apply会有性能问题,这时就会用到$applyAsync方法。与$evalAsync方法不同的是,$applyAsync方法会将digest过程也延迟,即使当前有正在进行的digest过程,指定的函数也会在下一个digest cycle执行。例如:

scope.prototype.$applyAsync = function(fn){
	var self = this;
	self.$$applyAsyncQueue.push(function(){
		self.$eval(fn);
	});
	setTimeout(function(){
		while(this.$$applyAsyncQueue.length){
  			try{
   				this.$$applyAsyncQueue.shift()();
  			}catch(e){
  				 console.error(e);
  			}
 	}}, 0);//execute in the next round.
	
};


首先将fn放入内部维护的队列$$applyAsyncQueue中。然后在下一轮代码执行时(setTimeout超时),调用$apply方法执行队列中的每个fn。这里有个问题,每个fn的执行都会重新启动一个$digest过程,如果fn很多,可能有性能问题。Angular的优化方法是,通过setTimeout放回的id来判断是否已经调度过执行$$applyAsyncQueue中所有方法,如果是就不再调用setTimeout,这样相当于多个fn共用一个$apply方法。这种优化在前面提到的http响应处理的情景下,就显得尤为重要了。如果在这种情况下使用$evalAsync,就会导致每个fn启动一个新的digest cycle,因为fn在回调函数中,每个fn无法在同一个digest中消化掉。


本文的参考资料包括:

1. AngularJS 源代码

2. AngularJS 官方文档

3. 专著 Build Your Own Angular

  • 2
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值