AngularJS巩固实践-40-$timeout使用技巧

在前端开发中,经常处理一些延时任务,比如,为了防止界面停止响应,将一些费时任务延后(js单线程执行,所以需要合理安排任务执行顺序),或是要等一些DOM元素出现后才能继续,这时,通常使用window.setTimeout来专门处理这类延时任务。

在ng应用中也可以使用setTimeout,但由于ng的脏检查机制,在延时任务中修改被绑定到界面中的变量时,window.setTimeout是不会触发脏检查来更新UI界面的,所以此时就需要使用$scope.$apply来手动触发脏检查。

但有时会遇到ng报错:Error: $digest already in progress,即ng内部已经正进行脏检查了,此时可以先检查ng内部是不是正在做脏检查,使用如下代码解决:

1
2
3
function safeApply(scope, fn){
(scope.$$phase || scope.$root.$$phase) ? fn() : scope.$apply(fn);
}

上述代码似乎已经完美解决问题了,但其实可以直接使用ng提供的$timeout,自带$apply效果。

$timeout源码分析

ng内置$timeout服务是ng包装原生的window.setTimeout而实现的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
function $TimeoutProvider(){
this.$get = ['$rootScope', '$browser', '$q', '$excpetionHandler',
function($rootScope, $browser, $q, $excpetionHandler){
var deferreds = {};

function timeout(fn, delay, invokeApply){
var deferred = $q.defer(),
promise = deferred.promise,
skipApply = (isDefined(invokeApply) && !invokeApply),
timeoutId;

timeoutId = $browser.defer(function(){
try {
deferred.resolve(fn());
}catch(e){
deferred.reject(e);
$exceptionHandler(e);
}finally{
delete deferreds[promise.$$timeoutId];
}

if(!skipApply)
$rootScope.$apply();
}, delay);

promise.$$timeoutId = timeoutId;
deferreds[timeoutId] = deferred;

return promise;
}

timeout.cancel = function(promise){
if(promise && promise.$$timeoutId in deferreds){
deferreds[promise.$$timeoutId].reject('canceled');
delete deferreds[promise.$$timeoutId];
return $browser.defer.cancel(promise.$$timeoutId);
}
return false;
}

return timeout;
}];
}

function Browser(window, document, $log, $sniffer){
var self = this;
// ...
self.defer = function(fn, delay){
var timeoutId;
outstandingRequestCount++;
timeoutId = setTimeout(function(){
delete pendingDeferIds[timeoutId];
completeOutstandingRequest(fn);
}, delay || 0);
pendingDeferIds[timeoutId] = true;
return timeoutId;
};
slef.defer.cancel = function(deferId){
if(pendingDeferIds[deferId]){
delete pendingDeferIds[deferId];
clearTimeout(deferId);
completeOutstandingRequest(noop);
return true;
}
return false;
};
}

ng在$browser中封装了defer和defer.cancel方法,他们分别封装了window.setTimeout和取消window.setTimeout的任务,之所以封装是为了针对不同浏览器的粘合。

$timeout服务利用$browser中分组了defer和defer.cancel,再次将window.setTimeout封装为Promise的方法,而且可以使用.then方法注册接受延时回调的返回值,并且可以用$timeout.cancel(promise)取消这次延时任务。

在$timeout中,接受延时任务的回调函数、延时间隔时间(毫秒)、以及是否需要调用$apply的标记参数。对于延时间隔为0,表示在当前任务完成,线程空闲后立即执行。apply的标记参数默认为true,需要调用$apply机制,此处启动脏检查会在当前任务完成后,线程空闲才执行,所以不会出现前面的Error: $digest already in progress的问题

$timeout是一个便于进行单元测试的服务组件,在ngMock中会为$timeout添加一个flush方法:将放在队列中的延时任务全部立即执行以便,这样就将异步延时任务变为同步,以便在ng的单元测试中更好的测试应用的业务组件。

angular-mock中angualr.mock.$Browser方法中关于defer的定义:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
angular.mock.$Browser = function(){
var self = this;
// ...
this.isMock = true;

slef.defer = function(fn, delay){
delay = delay || 0;
self.deferredFns.push({
time: (self.defer.now + delay),
fn: fn,
id: self.deferredNextId
});
self.deferredFns.sort(function(a, b){
return a.time - b.time;
});
return self.deferredNextId++;
};

self.defer.now = 0;
self.defer.cancel = function(deferId){
var fnIndex;
angular.forEach(self.deferredFns, function(fn, index){
if(fn.id === deferId) fnIndex = index;
});

if(fnIndex |== undefined){
self.deferredFns.splice(fnIndex, 1);
return true;
}
return false;
};

self.defer.flush = function(delay){
if(angular.isDefined(delay)){
slef.defer.now += delay;
}else{
if(self.deferredFns.length){
self.defer.now = self.deferredFns(self.deferredFns.length - 1).time;
}else{
throw new Error('No deferred tasks to be flushed');
}
}

while(self.deferredFns.length && self.deferredFns[0].time <= self.defer.now){
self.deferredFns.shift().fn();
}
};
}

$timeout不仅可用于延时任务,而且对第三方的js组件(比如jquery)封装很有用。

在需要手动scope.$apply的情况下,都可以利用$timeout或者$scope.$evalAsync的延时和默认$apply机制巧妙解决问题。

对于定时器window.setInterval,ng内置了$interval服务。在使用$interval服务之前,确定是否可以用HTML5 WebSocket代替。