给自己看的JS中for配合setTimeout()的执行机制

232 阅读5分钟

TL;DR

  • setTimeout()的倒计时是在所有可执行代码执行完毕后才开始。
  • 多个setTimeout()各自独立进行倒计时。
  • 实现for-setTimeout情况下依次输出i的主要方法有:for-let-setTimeout、闭包、拆分结构。

为什么是6 6 6 6 6 6,而不是0 1 2 3 4 5?

首先来看一段代码:

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

一段很简单的let-for-setTimeout代码,setTimeout()内执行代码内容为简单的console.log()函数。

但对于新人来说,无法理解的是为什这段代码在控制台的打印结果为6 6 6 6 6 6

关于这点,我们粗略的可以细分为两个小问题:

  1. 为什么是6 6 6 6 6 6,而不是0 1 2 3 4 5
  2. 为什么是6 6 6 6 6 6,而不是5 5 5 5 5 5

setTimeout()的机制

对于这两个问题,我们首先要理解的是setTimeout()的运行机制,它的语法格式如下:

setTimeout(function[,delay])

function是在倒计时结束后所要执行的函数。

delay是延迟,即倒计时的毫秒数,一个可选项。默认 0,意为“马上”执行。

新人所理解的setTimeout(),是在代码执行到setTimeout(()=>{console.log(i)},0)一段后,立即开始计时,在倒计时结束后立即开始执行setTimeout()内的函数。以如下代码为例:

let i = 0
setTimeout(function(){
	console.log(i)
}, 1)
i = 1

在一部分JS新手眼中,这段代码的执行过程是这样的: Pasted image 20220629110522.png

但实际上,setTimeout()的计时是在所有可执行代码执行完毕后才开始,无论给的延迟参数有多小,哪怕是0。 因此,对于刚给出的代码,JS中的执行过程其实是这样的: Pasted image 20220629110928.png

对于这点,用如下这段代码可能更加体现得更加明显:

setTimeout(()=>console.log(1), 5000) // 5s
console.log(2)

控制台会先打印2,并停顿约5s的时间之后,再打印出1

多个setTimeout()的情况

由此,我们延伸出一个问题:在多个setTimeout()的情况下,又会如何呢?我们来执行如下代码:

setTimeout(()=>console.log(1), 5000) // 5s
setTimeout(()=>console.log(2), 5000) // 5s
console.log(3)

通过粗略的秒表计时(你可以用你手机的内置计时功能),我们可以明显感觉到,代码执行后几乎是立即控制台就打印了3,在大约5秒后,依次打印了12

setTimeout(()=>console.log(1), 10000) // 10s
setTimeout(()=>console.log(2), 5000) // 5s
console.log(3)

遵循之前的操作, 我们可以明显感觉到,控制台立即打印了3,大约5秒后打印了2,大约10秒后打印了1

由此我们可以得出结论,多个setTimeout()的情况下,并在可执行代码执行完毕后同时开始倒计时,每个setTimeout()计时结束后立即开始执行。仅有当延迟相同时,才会按照源代码中的先后顺序来依次执行。

为什么会打印6 6 6 6 6 6?

回到本篇文章最开始的代码:

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

为了直观,我们可以把它转化成以下等价的的代码:

let i = 0
setTimeout(()=>{console.log(i)},0)
i++ // for循环:先执行判断、再执行循环体、最后执行i++
setTimeout(()=>{console.log(i)},0)
i++
setTimeout(()=>{console.log(i)},0)
i++
setTimeout(()=>{console.log(i)},0)
i++
setTimeout(()=>{console.log(i)},0)
i++
setTimeout(()=>{console.log(i)},0)
i++

通过之前的分析,我们可以很轻易的分析出,这段代码会先执行6次setTimeout()和i++。待所有可以执行的代码执行完毕后,再开始执行setTimeout()内部的函数,即()=>{console.log(i)}。回到之前提出的两个问题:

  1. 为什么是6 6 6 6 6 6,而不是0 1 2 3 4 5
  2. 为什么是6 6 6 6 6 6,而不是5 5 5 5 5 5

对于问题1,其原因在于setTimeout()的运行机制:它不是执行到setTimeout()后立即开始倒计时的,而是在所有可执行代码执行完毕后才开始倒计时的。或者说我们可以这么理解,setTimeout()的功能在于:给出一个在所有当前可执行代码结束后才开始的倒计时,在倒计时结束后,往代码末增加一段新的代码。 ()=>{console.log(i)}是在执行6次i++后,才开始执行的。

对于问题2,其原因在for循环的执行顺序:先执行判断、再执行循环体、最后执行i++。即在第6次循环体执行完毕后,还会再执行一次i++,使i从5变成6。

如何打印0 1 2 3 4 5?

那么,我们如何才能使用for循环配合setTimeout()来打印出0 1 2 3 4 5的效果呢?

for-let

JS通过为for-let设置了特殊机制,为我们实现了这个需求:

for(let i = 0; i<6; i++){
  setTimeout(()=>{
    console.log(i)
  },0)
}
// 0 1 2 3 4 5

通过for-let-setTimeout语序下,可以实现依次打印i的不同值的功能。为了区别,我们本文开始的代码:

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

我们可以看出,这两段代码最大的区别在于let的位置。我将后者称为let-for-setTimeout语序。

在前者,即for-let-setTimeout语序下,JS会特别地在每次i++的过程中新创建一个i,而每次console.log(i)所访问的,都是不同的i,我们可以这样理解它:

let i
for(i = 0; i<6; i++){
  let j = i
  setTimeout(()=>{
    console.log(j)
  },0)
}
// 0 1 2 3 4 5

闭包

通过闭包,将i的变量驻留在内存中,这种情况下在执行setTimeout()时,就已经确定输出了。

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

拆分结构

将计时器函数定义在循环外部,这样每次循环都会将当前的i作为实参传递给外部的函数fn()。

function fn(i) {
    setTimeout(() => console.log(i), 0);
}
for (var i = 1; i <= 5; i++) {
    fn(i);
}

关于setTimeout()的第三个参数

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

很多网站都将它列为for-setTimeout情况下依次输出i的方法之一,但实际似乎并不是如此。很多代码之所以能依次输出i,大概是因为他们使用了let定义。

通过查阅MDN,我们也可以得到对于setTimeout()第三个参数的描述:

附加参数,一旦定时器到期,它们会作为参数传递给function

显然,哪怕给予其第三个参数,参数也是在定时器到期后才传递给setTimeout()的。