最近,有一道JavaScript面试题挺流行的,很多朋友去面试的时候都遇到了。这道题目大致是这个样子的:
以下这段代码执行后,结果为什么不是依次输出0到9?如果要让它实现这样的输出,你会怎么来修改这段代码?
for (var i = 0 ; i < 10; i++) {
setTimeout(function () {
console.log(i)
})
}
那让我们先来看一看,在这段代码中打印变量i
的最终输出结果到底会是什么呢?待一阵十指乱动,风一般的敲出执行代码的命令,只见屏幕一闪,亮出十行大字:
10
10
10
10
10
10
10
10
10
10
What?! 它输出的居然是10个10
而不是更贴近我们第一感觉的0到9
,这是怎么回事儿?又是一个什么坑......还能不能好好的写JavaScript了......
原因分析
其实,这个锅也不能全由JavaScript来背,有可能是你没有完全理解JavaScript导致的。产生这个运行结果的关键点就在于for
语句中的var i = 0;
这句变量声明代码。
我们都知道,var
是用来声明变量的,并且我们通常也知道,一个语句从哪里开始声明就会在哪里开始被处理。但是var
是JavaScript语法中的一个例外!我们来看一下Mozilla官方文档中对var
的定义:
var变量声明,无论发生在何处,都在执行任何代码之前进行处理。
用var声明的变量的作用域是它当前的执行上下文,它可以是嵌套的函数,也可以是声明在任何函数外的变量。如果你重新声明一个 JavaScript 变量,它将不会丢失其值。
由于上述定义的原因,var
变量声明(以及其他声明,比如函数声明)总是在任意代码执行之前处理的,所以在代码中的任意位置声明变量总是等效于在代码开头声明。这意味着变量可以在声明之前使用,这个行为叫做变量提升(Hoisting)
。变量提升就像是把所有的变量声明移动到函数或者全局代码的开头位置:
bla = 2
var bla
// 可以理解为:
var bla
bla = 2
因此对于我们这道题,变量i
的声明就相当于提前到了for
语句的外面,相应的,变量i
的作用域范围也同时扩大到了for
语句的外面,与以下的写法相互等效:
var i = 0;
for (; i < 10; i++) {
setTimeout(function () {
console.log(i)
})
}
另外一点,我们得明白setTimeout()
的运行时机:它总是在当前的同步代码执行完成后开始运行。可以在前面的代码中加入一些log进行跟踪并验证这一点:
var i = 0;
for (; i < 10; i++) {
console.log('+++++', i)
setTimeout(function () {
console.log(i)
})
}
执行这段代码后的结果:
+++++ 0
+++++ 1
+++++ 2
+++++ 3
+++++ 4
+++++ 5
+++++ 6
+++++ 7
+++++ 8
+++++ 9
10
10
10
10
10
10
10
10
10
10
由此可见,当开始执行setTimeout()
中的代码时for
循环外面的变量i
就已经变成了10
,使用console.log(i)
从作用域查找到的i
值就是10
,因此十次setTimeout()
中的代码就都打印出了10
。
解决方式
原因找到了,罪魁祸首说到底就是由于var
变量的作用域特性以及作用域范围导致的。那解决这个问题的关键点还是在怎么控制变量的作用域。
方法一
要控制变量的作用域,最常见的手段,就是使用函数闭包
将变量值封闭在指定的作用域内。
我们可以在setTimeout()
的外面进行一层简单的包装来形成闭包,达到将每次循环时的i
值封闭在闭包内部:
for (var i = 0 ; i < 10; i++) {
(function (i) {
setTimeout(function () {
console.log(i)
})
})(i)
}
这样的话,在setTimeout()
中查找变量i
的时候,就会获取到封入闭包并以参数形式传入的参数i
了。
方法二
除了函数闭包,我们还可以使用的解决方案,就是ES6中新引入的let
变量声明。与var
不同的是,由let
声明的变量的作用域是只在其声明的块或子块中可用,所以它被称为块级作用域变量。
我们这道题的代码只要做很小的修改,只需要将var
替换成let
,就能如我们期望的那样工作了:
for (let i = 0 ; i < 10; i++) {
setTimeout(function () {
console.log(i)
})
}
使用了let
后,变量i
的作用域被限定在for
语句块以及子块setTimeout()
中,并且:
子块中的变量值是该子块产生时的那个值
是不是觉得let
变量的作用域关系比较清晰?在现在的实际开发中,我们也更推荐使用let
来替代var
进行变量声明,它会使你的代码更清晰更简化,不容易出bug。