The time is out of joint. O cursèd spite, that ever I was born to set it right! — Hemlet Act1, Scene 5, 186-190


从一段代码讲起

😉一段十分普通的JS代码

  • 先来看一段十分普通的JavaScript代码,我们试图在控制台用循环语句输出几个数字。

    1. let i = 0
    2. for(i = 0; i<6; i++){
    3. console.log(i)
    4. }
  • 我们在Chrome和Firefox中分别运行了一下,运行结果如下:

Chrome运行结果
屏幕快照 2020-03-06 上午10.41.33.png

Firefox运行结果
屏幕快照 2020-03-06 上午10.37.36.png

  • 用脚趾想都知道,结果是会输出从0到5的6个数。

😏给代码加点料

  • 下面我们给代码加点料,我们仍然使用同样的循环语句输出数字,只不过将console.log语句放在了setTimeout函数中且设置延时为0,看看控制台会输出什么结果?

    1. let i = 0
    2. for(i = 0; i<6; i++){
    3. setTimeout(()=>{
    4. // 延时函数
    5. console.log(i)
    6. },0)
    7. }
  • 运行结果是如下:

Chrome运行结果
Chrome运行结果.png

Firefox运行结果
Firefox运行结果.png

  • 可以看到,两个浏览器的运行结果是相同的,都在控制台打印出了6个6

🤯WTF?

什么鬼?怎么会这样呢!

先来谈谈setTimeout函数

😳setTimeout() 函数是干嘛的?

  • 查询mdn可以看到,文档中给出的描述是:

    • setTimeout() 方法设置一个定时器,该定时器在定时器到期后执行一个函数或指定的一段代码。
  • 再去W3School看看,他们给出的描述是:

    setTimeout() 方法用于在指定的毫秒数后调用函数或计算表达式

  • 听起来似乎更容易理解了一些,看看代码示例也许更容易理解

  1. /* 浏览器3秒后向你打个招呼! */
  2. setTimeout(
  3. function(){
  4. alert("Hello");
  5. }, 3000);
  • 对于setTimeout()函数,更加白话的理解是:

凡是放在这个函数中东西,都过一会再做,至于过多久,可以通过设定毫秒数来调节。

😅那么如果将延时设置为0呢?

  • 用逻辑来理解的话,延时为0s === 不再过很久而是“立即马上”就做,可是“立即”究竟有多“立即”呢?“马上”究竟有多“马上”呢?
  • 要尝试理解“立即马上”,需要引入Event Loop的一些概念。

🤔当我们使用setTimeout() 时,到底发生了什么?

下面一部分的资料基本来自于以下这个视频,建议你看看这个视频,讲得一级棒!( Ichiban! ) B站地址:什么是事件循环? - Philip Roberts(视频审核中)

  • 接下来我们要看看当使用setTimeout() 时,到底发生了什么。

setTimeout()究竟做了什么?

我们使用一张图来演示在调用setTimeout()时发生了什么。不用看图,先往下看文字。

main01.png

😬一些术语的简单解释

你可以先跳过这部分,当然,看看也无妨。

  1. 调用栈(Call Stack)
  • MDN对于调用栈的解释大概是这样的:

    1. 每当我们调用一个函数的时候,这个函数就会被添加进调用栈并开始执行
    2. 正在调用栈中执行的函数如果调用了其他函数,那么那个函数也会被放入调用栈
    3. 调用栈中的函数执行完了之后,会被清出调用栈
  • 说白了就是:要执行的函数push到调用栈顶部,执行完从调用栈顶部pop出来,后进先出。

    关于调用栈的更多信息,可以参考你不知道的JS错误和调用栈常识

  1. 定时器(Timer)
  • 定时器可以理解为:定时执行某段代码,这里的setTimeout()函数就是JS为我们提供的一个定时器。

    关于定时器的更多信息,可以参考JavaScript标准参考教程 - 定时器

  1. Web APIs
  • Web APIs 是浏览器创建的一些线程,包含计时器等等。
  1. 回调序列(Callback queue)
  • 一个包含了回调函数的有序序列。


  1. 事件循环(Event Loop)的简单描述
  • 事件循环包含了以下几个步骤:
    • 函数入栈执行,当执行到定时器(这类异步任务)时,把它丢给Web APIs去执行,接着继续执行栈内的剩余任务(同步任务),直到栈空;
    • 在此期间Web APIs会执行定时器,直到计时结束,然后会将回调函数(也就是setTimeout的第一个参数)扔到回调序列中;
    • 当调用栈为空时,事件循环会把Callback中的一个任务放入栈中,开始执行,回到第一步;

看了这么多概念后,也许你已经一头雾水了,不要急,接下来我们用图片演示一遍事件循环的过程,之后你再回来看这些定义估计会豁然开朗了。

🧐图解setTimeout执行过程

  • 依旧是这段代码,我们用图解的形式理解一下。
    1. let i = 0
    2. for(i = 0; i<6; i++){
    3. setTimeout(()=>{
    4. console.log(i)
    5. },0)
    6. }
  1. 首先我们定义了变量i,并为它赋值,主程序开始。

main02.png

  1. 然后进入循环,循环的第一步就是判断i<6是否成立,需要把判断i<6的语句放入调用栈中执行。

main03.png

  1. 此时i的值是0,i<6显然成立,会继续执行循环体内的代码,即setTimeout()。

main04.png

  1. 作为一个定时器,setTimeout()会被扔到Web APIs中执行。

main05.png

  1. 此时,调用栈会继续执行后续代码,因为本次循环已经完成,所以会再次判断i<6,并进入下一次循环。

main06.png

  1. 几乎是在同一时刻(0s),计时器完成了计时,将回调函数扔到回调序列中。

main07.png

  1. 注意,这时候回调序列中的任务并不会马上执行,需要等到栈空才会开始进栈执行,因此会执行继续主程序。也就是循环体中的setTimeout(),因为是定时器,会被扔到Web APIs中执行。

main08.png

  1. 几乎是在同一时刻(0s),计时器完成了计时,将回调函数扔到回调序列中。

main09.png

  1. 如此循环往复,直到i的值变为6时,循环彻底结束,主程序也随之结束。

main10.png

  1. 此时回调序列中的任务还是进栈执行,打印i的值,而此时i的值为6,所以在控制台打印出了一个6。

main11.png

  1. 回调序列中的任务会逐一进栈执行,直至最后一个回调函数console.log() ,连续打印出六个6。

main12.png

  • 接下来用一段完整的动画演示(使用了Loupe工具):

fgsdfgsfg.gif

  • 至此,我们基本解释了为何文章开头处那段代码会输出6个6,而不是0~5了。
  • 你可以回头看看前面介绍的概念,估计会豁然开朗。

我偏要输出“0~5”!

  • 我们已经解释了为何那段代码会输出6个6,但是如果我偏要用for循环中嵌套setTimeout()的形式输出0~5呢?

    以下代码仅供参考,不再解释,因为我也不知道怎么解释!

😄方案一

  • 在for循环体内声明i
  1. for(let i = 0; i<6; i++){
  2. setTimeout(() => {
  3. console.log(i)
  4. }, 0)
  5. }
  • 运行结果

屏幕快照 2020-03-06 下午5.59.18.png

😁方案二

  • 先声明函数,在setTimeout()中调用。
  1. let i = 0
  2. function cb() {
  3. console.log(i)
  4. }
  5. for(i = 0; i<6; i++){
  6. setTimeout(cb(), 0)
  7. }
  • 运行结果

屏幕快照 2020-03-06 下午5.59.44.png

😊方案三

  • 使用立即执行函数?
  1. let i = 0
  2. for(i = 0; i<6; i++){
  3. (function(i){
  4. setTimeout(() => {
  5. console.log(i)
  6. }, 0)
  7. })(i)
  8. }
  • 运行结果

屏幕快照 2020-03-06 下午6.00.10.png

方案四

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

以上几种方案的本质都是将i限制在循环中每一次创建的函数实例中,无论是借助闭包,还是借助let的块作用域,以及 Function.proptotype.bind()。

鉴于本人才疏学浅,如有错误之处,还望批评指正!

参考资料

浏览器事件循环机制 - 追风筝的人er 如何序列化JavaScript中的并发操作:回调,承诺和异步等待 - itclanCoder 关于JS的for循环包裹异步函数的问题 - microkof JavaScript运行机制详解:再谈Event Loop - 阮一峰 【演讲】What the heck is the event loop anyway? - Philip Roberts 【演讲】In the loop - Jake Archibald