JS函数执行的时机

120 阅读3分钟

JS函数执行的时机

一段代码

在讨论JS函数执行的时机之前,我们先来看一段代码

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

执行上述代码,最终控制台输出结果是什么?

  1. setTimeout的id, 0, 1, 2, 3, 4, 5
  2. setTimeout的id, 6, 6, 6, 6, 6, 6

可能有人会写出上述代码,并期望得到结果1,但实际控制台会输出结果2。

本篇文章会分析js的执行过程,来分析为什么会输出结果2,涉及到了调用栈,作用域与闭包,以及event loop与macro task queue,并在最后提出几种能够得到结果2的方法。

使用let关键字更改输出

这里的关键点,我认为有以下几点

首先是setTimeout内回调函数的执行时间,其次是setTimeout内回调函数使用的i变量的作用域

其实搞清i的作用域我们就知道,整个执行过程中,我们使用了一个i变量,并在最后输出6次i。

那么如果不更改setTimeout内的代码,有没有办法记录下i的值,并在之后输出呢,也就是实现控制台输出0, 1, 2, 3, 4, 5的效果呢

很显然,我们需要6个额外的变量来保存i每次循环时的值

在ES6之前,js并没有块级作用域,但在ES6新出的letconst中,使用这两个关键字会自动将其所在的{}变更为一个新的作用域。

而且为了记录for循环每次的i的值,js还额外做了一些工作,会将for循环的{}变成一个新的作用域。

此时我们改写代码如下

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

为了理解,我会将代码转化如下

{
  let _i = 0
  while(_i < 6) {
    const i = _i
    setTimeout(() => { console.log(i) })
    _i++
  }
}

此时在js执行完毕后,根据作用域链,查找到for循环的{}内,因为每次执行都创建新的作用域,所以每一轮循环都会有一个“新的i”,也就是类似于js保存了i变量此时的快照,所以总共有6个i,并且在for循环外是访问不到的,也就是setTimeout内的console.log(i)携带了一个闭包。

关于闭包的定义及使用,如果有不清楚的小伙伴可以多查找一些资料。

这里也推荐一个系列视频

既然我们通过使用let关键字,来实现效果,并且其核心原理就是增加新的作用域,那么其实我们也可以使用letconst来声明新变量的方式来实现同样的效果

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

这里我使用了const关键字来声明j变量,以便更明确的标明j变量是一个新的变量。

使用立即执行函数来创建作用域,更改输出

那么,如果不适用letconst关键字呢?

我们可以使用立即执行函数来创建作用域

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

如果我们不想修改console.log(i),还可以写为如下

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

此时每执行一次循环,就会将i的值作为参数传递给立即执行函数,console.log(i)中i的值也就在进入macro task queue前就已确定。