一起了解Javascript定时器

介绍

这篇文章会聊聊关于Javascript的定时器以及它的执行机制,首先会先翻译一篇比较好的定时器和事件队列的文章,然后会根据一些经典的例题来更深入了解。

##
在基础层面上去了解 Javascript 定时器的工作原理是蛮重要的。因为单线程的问题很多时候他们的行为都是无意义的。让我们先来看看构建和操作Timers的函数。

  • var id = setTimeout(fn,delay); 初始化一个单次定时器,它会在设置的延迟时间(delay)后执行特定的回调函数。这个函数会返回一个唯一的ID,这个ID可以在之后用来取消这个定时器。
  • var id = setInterval(fn,delay);setTimeout 类似,但是区别在于这个函数会多次执行直到它被停止。
  • clearInterval(id);clearTimeout(id); 传入定时器的ID(ID由上面两个函数返回)然后停止定时器回调。

为了了解定时器内部的运行原理,有一个很重要的概念我们需要探讨: 定时器延迟是不能保证的。因为所有的JS脚本都是在浏览器的单个线程上执行,异步事件在被触发的时候才会执行(例如鼠标事件和定时器),这个图可以很好的说清楚。

image_1

这张图里面有很多知识点需要消化,但是完全理解了它就可以更好的帮助我们了解Javascript的异步事件机制的运作。这个图是一维的:垂直量度标记的是时间,单位是ms,蓝色框里面的是正在执行的Javascript部分。例如第一个Javascript部分执行了大概18ms,鼠标点击事件大概执行了11ms,以此类推。

因为Javascript只能一次执行一段代码(因为它的单线程性质),所有的代码块都会在线程里面堵塞其他的代码块。这意味着当异步事件发生的时候(例如鼠标点击事件,定时器触发或者一个XMLHttpRequest完成),它将会进入事件队列排队等待执行(这种排队实际上发生的情况肯定会因浏览器和浏览器之间的不同而有所不同,所以这里是一个简化的描述)。

首先,在第一个Javascript代码块中,两个定时器被启动:10ms的 setTimeout 和 10ms的 setInterval。定时器的启动时间和位置是在我们完成第一个代码块前就已经触发了。但是请注意,它不会立即执行(由于线程不能执行)。而是进入队列中以便再下一个可用时间执行。【译者补充:就是说会等第一代码块的顺序代码执行完后,回头才会去处理事件队列里面的代码。】

另外,在第一个Javascript代码块中我们还看到一个鼠标点击事件发生。与这个事件关联的异步回调函数也不会马上就执行(因为我们不会知道用户何时执行操作,因此这个时间也被认为是异步的),跟 timeout 事件初始化一样,它也会放入到队列里面稍后执行。

在初始化Javascript代码块执行完后浏览器马上会问一个问题:“还有谁?!谁在等着被执行?!” 在这个图的案例里面,鼠标点击回调事件和定时器的回调事件都在等着。浏览器会选择下一个队列事件(点击回调事件)并立即执行。timeout 的回调时间则会等待下一个时机去执行。

注意,当鼠标点击事件回调在执行的时候,第一个 interval 事件回调到达时间点执行,它会跟 timeout 的回调事件一样进入队列等待稍后执行。但是请注意,当 interval 再次被触发(当 timeout 的回调事件还在执行)的时候,这个 interval 的回调执行会被放弃。假如在执行大块代码块的时候对所有的 interval 回调进行排队,那么这一系列的回调之间将不会有延迟。但相反,浏览器事实上往往只是等待,在更多的其他的事件入队之前不会再有其他的 interval 回调事件(指相同ID interval衍生出来的回调事件) 会被放入队列。【译者补充:就是说,当一个占用时间较长的事件在执行的时候,如果队列中已经有一个相同ID interval产生的且还没执行的回调事件在,即使到达了再下一个得interval触发时间也不会有新的interval回调事件入队。】

事实上我们可以看到案例中第三个 interval 回调事件被触发的时候, 前一个 interval 回调事件正在执行。这告诉了我们一个事实:interval 回调事件不会在意什么当前执行的内容,它们会不加区分地进入队伍,即使回调事件之间的时间会被浪费掉。

最后在第二个 interval 回调事件执行完后,我们可以看到在javascipt引擎的队列里面已经没有东西可以执行了。这意味着浏览器现在会等待新的异步事件的发生。在 interval 回调事件再次触发的时候时间线已经到达50ms了,这一次,因为没有其他代码块在执行,所以这个 interval 回调事件会马上执行。

来看一个例子去更好地区分 setTimeoutsetInterval

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16



setTimeout(function(){

/* Some long block of code... */

setTimeout(arguments.callee, 10);

}, 10);

setInterval(function(){

/* Some long block of code... */

}, 10);

乍看之下,这两个代码片段似乎实现的功能是一致的,但仔细看其实不同。值得注意的是, 在前一个回调执行之后,setTimeout 的代码至少会有10ms的延迟才会执行(可能会更多,但不会更少)。 setInterval 则在每10ms的时间里都会尝试去执行回调,而不会管前一个回调执行的完成时间。

今天学习了很多,我们来回顾一下:

  • Javascript 引擎只有一个单线程,强制异步事件排队等待执行。
  • setTimeout 和 setInterval 在它们如何处理异步代码之上有着根本性的不同
  • 如果定时器被阻止立即执行,它将被延迟到下一个可能的执行点(这将会比期望的延迟时间更长)。
  • 如果 Interval 的回调事件需要花费足够长的时间执行,那么它们将可以无延迟的背靠背(连续)执行。

这些都是重要的基础知识。了解Javascript引擎的工作原理,特别是遇到大量异步事件的情况下,可以在构建高级应用程序代码的基础层面上做好准备。

经典例题

1
2
3
for (var i=0; i<5; i++){
console.log(i)
}

第一题就是基础的不能再基础了顺序输出0 1 2 3 4

1
2
3
4
5
for (var i=0; i<5; i++){
setTimeout(function(){
console.log(i)
}, 1000 * i);
}

第二题就有我们上一节提到的知识了,javascript会先把for循环执行完,把setTimeout的回调事件都放到事件队列中,等初始块代码执行完后再去处理事件队列里的回调事件,而这个时候,for局部里面的变量 i 已经一早递加为 5 了(注意,for循环是先执行语句3再去判断能不能执行内部语句的,所以 i 已经是 5 了)。因此结果是:

1
2
3
4
5
6
//延迟
5
5
5
5
5
1
2
3
4
5
6
7
for (var i=0; i<5; i++){
(function(i){
setTimeout(function(){
console.log(i)
}, 1000 * i);
})(i);
}

第三题用了一个匿名函数和马上执行的传参来包住了setTimeout,就是说setTimeout 的 i 这个时候用的是闭包里面的局部变量 i,因为匿名函数的 i 传参不是引用传值而是数值传值,所以匿名函数里的 i 不会根据外面for循环的变量 i 的改变而改变。因此结果是:

1
2
3
4
5
6
//延迟
0
1
2
3
4

1
2
3
4
5
6
7
for (var i=0; i<5; i++){
(function(){
setTimeout(function(){
console.log(i)
}, 1000 * i);
})(i);
}

第四题考察的就是闭包的知识了,因为没有值传入,所以setTimeout读的还是已经跑完for循环的 i。因此结果是:

1
2
3
4
5
6
//延迟
5
5
5
5
5

1
2
3
4
5
for (var i=0; i<5; i++){
setTimeout((function(i){
console.log(i)
})(i), 1000 * i);
}

第五题setTimeout里面的回调函数被立即匿名调用了,所以先会跟正常输出一样,不会延迟执行。同时因为匿名函数没有返回值,所以 setTimeout 的回调函数是 undefined。输出结果是:

1
2
3
4
5
6
//无延迟
0
1
2
3
4

1
2
3
4
5
6
7
for (var i=0; i<5; i++){
setTimeout((function(i){
return function(){
console.log(i);
}
})(i), 1000);
}

根据第五题的变形另外加一题,这题跟第五题的区别就是匿名函数有返回值,就是 setTimeout的回调函数就不再是undefined了,所以输出结果是:

1
2
3
4
5
6
//延迟
0
1
2
3
4

Reference

[0] 例题来源,小芋头君知乎live(如禁止发布请告知删除)