关于 setTimeout 的一些小故事
· 5 min read
@lideming曾经向我举过几个setTimeout的有趣例子。我先前不是很了解 JavaScript 的并发模型和事件循环,这些例子对我来说都算得上是巨坑无比了。
setTimeout 和事件循环
函数 setTimeout 接受两个参数:待加入队列的消息和一个时间值(可选,默认为 0)。这个时间值代表了消息被实际加入到队列的最小延迟时间。如果队列中没有其它消息并且栈为空,在这段延迟时间过去之后,消息会被马上处理。但是,如果有其它消息,setTimeout 消息必须等待其它消息处理完。因此第二个参数仅仅表示最少延迟时间,而非确切的等待时间。
下面的例子演示了这个概念(setTimeout 并不会在计时器到期之后直接执行):
- code
- result
const s = new Date().getSeconds();
setTimeout(function() {
// 输出 "2",表示回调函数并没有在 500 毫秒之后立即执行
console.log("Ran after " + (new Date().getSeconds() - s) + " seconds");
}, 500);
while(true) {
if(new Date().getSeconds() - s >= 2) {
console.log("Good, looped for 2 seconds");
break;
}
}
Good, looped for 2 seconds
Ran after 2 seconds
根据刚才的说法,对于刚才这段代码,setTimeout 的回调至少要等到 while loop 执行结束之后才能执行。所以在 setTimeout 中,并不存在真正的“零延迟”。
setTimeout “零延迟”
零延迟并不意味着回调会立即执行。以 0 为第二参数调用 setTimeout 并不表示在 0 毫秒后就立即调用回调函数。其等待的时间取决于队列里待处理的消息数量。在下面的例子中,"这是一条消息" 将会在回调获得处理之前输出到控制台,这是因为延迟参数是运行时处理请求所需的最小等待时间,但并不保证是准确的等待时间。
- code
- result
(function() {
console.log('这是开始');
setTimeout(function cb() {
console.log('这是来自第一个回调的消息');
});
console.log('这是一条消息');
setTimeout(function cb1() {
console.log('这是来自第二个回调的消息');
}, 0);
console.log('这是结束');
})();
这是开始
这是一条消息
这是结束
这是来自第一个回调的消息
这是来自第二个回调的消息
基本上,setTimeout 需要等待当前 队列中所有的消息都处理完毕之后才能执行,即使已经超出了由第二参数所指定的时间。
setTimeout 在循环中的大坑
setTimeout 只要不塞进循环里就还相安无事。下面的例子是大坑中的一个:
- code
- 等效 code(?)
- result
for(var i=0; i<9; i++){
setTimeout(function(){
console.log(i);
}, 0);
}
// for 循环开始
var i = 0;
setTimeout(function(){
console.log(i);
}, 0);
i = 1;
setTimeout(function(){
console.log(i);
}, 0);
i = 2;
setTimeout(function(){
console.log(i);
}, 0);
// ...
i = 8;
setTimeout(function(){
console.log(i);
}, 0);
i = 9;
// for 循环结束
9
9
9
9
9
9
9
9
9
在上方的“零延迟”部分介绍的特性在此处通用,所以所有 setTimeout 的回调会被塞到一起执行。