【面试】setTimeout新解

331 阅读2分钟

前言

本文于2021.11.12全面更新。

一段有点难理解的代码

let i = 0
for(i = 0; i < 6; i++){
  setTimeout(()=>{
    console.log(i)
  },0)
}

实际面试过程中更常看到的版本是完全不考虑ES6语法的:

for(var i = 0; i < 6; ++i) {
    setTimeout(function() {
        console.log(i);
    }, 0);
}

第一眼看到代码时,我们会想当然地觉得会输出0-5(0到5)。但最终的输出结果却是6个6。如下图:

六个六

完全不考虑ES6的情况下会输出如下图:

六个六v2

原因如下:

  1. 假设在不懂宏任务和微任务概念的情况下,setTimeout(cb, t)应被理解为在t时间过后尽快执行cb函数。换句话,t时间过后才有cb函数执行的可能性。
  2. 引入宏任务和微任务概念之后,可以见下一个小节。

用事件循环机制解释为什么会输出6个6

事件循环机制的顺序是:调用栈 -> 微任务 -> 宏任务

  • 普通函数、setTimeout()函数本身、Promise构造函数本身会进入调用栈
  • Promise后的thencatchfinally回调函数会进入微任务队列
  • setTimeout中的回调函数会进行宏任务队列

var版本执行方式为例,说明执行过程。在上一小节代码中,在for循环开始后,执行步骤如下:

  1. 执行循环5次,每次都往调用栈中压入setTimeout()函数本身。在i === 6时跳出循环;
  2. 每次setTimeout()函数进调用栈的过程,必然伴随着function () { console.log(i) }进入宏任务队列;
  3. 然后按照调用栈 -> 微任务 -> 宏任务执行一栈两队列中的函数;
  4. 调用栈中的setTimeout()函数被执行完毕,必须要等到0 * 5ms之后执行后续的步骤;
  5. 由于此题中没有Promise(),所以跳过微任务队列;
  6. 宏任务队列中有6个console.log(i)。由于此题中i是全局变量,值为6,所以自然会输出6个6。

如何在setTimeout之后还能输出0-5

很简单,直接使用letfor循环中即可。修改代码如下:

for(let i = 0; i < 6; i++){
    setTimeout(() => { console.log(i); }, 0);
}

这样相当于每次进去的都是新变量。但上述写法是个语法糖(这在别的语言中非常正常的写法变成了语法糖...无语,谢天谢地ES6让JavaScript看起来正常了许多)。如果不考虑语法糖,可以这样写:

let i = 0;
for(i = 0; i < 6; i++){
    let _ = i;
    setTimeout(() => {
        console.log(_);
    }, 0);
}

输出如下:

image.png

某亦庄大厂的考法是这样的:

如果限定条件为禁止使用let,乃至禁用ES6,则可以这样改用类lambda表达式(即立即执行函数):

for(var i = 0; i < 6; ++i){
    (function(i) {
        setTimeout(function() {
            console.log(i);
        }, 0);
    })(i);
}

输出如下:

image.png