一个常见的问题
for(var i = 0;i < 5;i++)
{
setTimeout(() => console.log(i),1000);
}
对于这个问题,想必大家都有所耳闻,最终输出是
5 5 5 5 5
毫无疑问,这是和我们的预期输出不符的(预期输出是0 1 2 3 4)。造成这个问题的原因,就是var关键字声明的标识符的作用域范围是函数作用域。
因此在上面的js代码中,i标识符是存放在全局作用域中的。因此,当setTimeout的回调函数执行的时候,i标识符存放的值已经是5了(5是循环结束的终点),因此将输出5个5。
但这里还有一个疑点,为什么setTimeout执行的时候i已经是5了,是因为等待了1秒钟嘛?
setTimeout的执行时机
现在我们知道了第一个问题的答案和造成错误的原因,那么下面这个问题呢
for(var i = 0;i < 5;i++)
{
setTimeout(() => console.log(i),0);
}
实际上这个问题的输出也是
5 5 5 5 5
这个输出实际上告诉了我们刚刚疑点的答案,并不是因为等待了1秒钟导致setTimeout执行的时候i中存放的值变成了5。
那么是什么原因呢?
我们可以仔细阅读一下这个代码,我们发现for循环是同步代码,而setTimeout是异步代码。
原来如此,我们立刻联想到事件循环,事件循环可以帮助我们区分同步代码和异步代码的执行时机。事件循环简单来讲是先执行宏任务,再清空微任务队列的一个循环。
其中常见的宏任务有
- <script>标签下所有同步代码
- setTimeout、setInterval
- I/O
- UI交互
常见的微任务有
- Promise相关方法
因此js引擎在执行这个for循环的时候,遇到了setTimeout,将会将setTimeout放入宏任务队列,之后接着执行同步代码,当同步代码执行完毕后去检查微任务队列,在下一轮事件循环开始的时候才会将setTimeout从宏任务队列中取出。
因此,setTimeout的执行必然在所有同步代码,也就是整个for循环执行完毕之后,因此在setTimeout执行的时候,i已经是5了。
解决方法
现在我们明确了造成代码输出和预期不符的原因,也就是作用域。
首先很容易想到,如果把标识符i限制在块作用域内,就可以解决问题。
使用let
for(let i = 0;i < 5;i++)
{
setTimeout(() => console.log(i),1000);
}
setTimeout的回调函数() => console.log(i)的词法作用域是for循环内部的块作用域。而这个函数被指定为回调函数,毫无疑问,将会在其词法作用域以外被调用,并且当它被调用的时候js引擎会对i进行RHS引用,因此即使for循环已经执行完毕,其每一层循环的作用域依旧被() => console.log(i)维持,因此setTimeout在执行的时候可以访问到每一层循环的i。这实际上形成了我们常说的闭包(一个函数在其被定义的词法作用域以外被调用,并且维持着对其被定义的词法作用域中变量的引用)。
使用IIFE
IIFE是Immediately Invoked Function Expression,立即调用函数表达式。
for(var i = 0;i < 5;i++)
{
setTimeout((function(i){
return () => console.log(i);
})(i),1000)
}
这里使用IIFE的目的是使用闭包。每一层循环执行IIFE相当于为每一层循环都创建了一个函数作用域,并把每一层循环的i的值作为参数传入,存放在IIFE的函数作用域的同名标识符中。并且这个IIFE将() => console.log(i)作为值返回给了setTimeout。
同样的,() => console.log(i)被定义在IIFE的函数作用域,它将在它所在的词法作用域以外被调用,并且在() => console.log(i)中维持了对它所在词法作用域中的变量的引用(在这里,实际上词法作用域的作用域链查找中的‘同名遮蔽‘也起了作用)。() => console.log(i)函数在调用时,js引擎会对i进行RHS查找,js引擎首先询问了() => console.log(i)的自身函数作用域,发现并没有这个标识符,于是根据词法作用域查找规则,去() => console.log(i)的上一级词法作用域,也就是IIFE的函数作用域查找标识符i,在这里js引擎发现了标识符i,因此有这个闭包存在(对IIFE作用域的引用),setTimeout在执行的时候可以访问到每一层循环的i。