先记住这个答案
因为 var 声明的 i 在整个函数里只有一个,三个回调读的是同一个 i。setTimeout 的回调要等循环全部跑完才执行,这时 i 已经加到 3 了,所以打印三个 3。最简单的修法是把 var 换成 let 就行。let 在每轮循环都会生成一个新的 i,每个回调记住的是自己那一轮的 i。
var只有一个i,所有回调共用- 回调执行时循环已结束,
i已经是最终值 - 闭包记住的是变量本身,不是当时的值
- 修法:
let、IIFE、setTimeout第三个参数
先看代码和输出
这是面试里最常出现的写法,先猜一下输出。
var 和 let 的对比JavaScriptfor (var i = 0; i < 3; i++) {
setTimeout(() => console.log('var', i))
}
for (let j = 0; j < 3; j++) {
setTimeout(() => console.log('let', j))
}查看输出与解释
var 3
var 3
var 3
let 0
let 1
let 2执行顺序是这样的:两个循环先同步跑完,只是把 6 个回调放进了定时器队列;等同步代码结束,回调才依次执行。第一个循环的 3 个回调去读 i,只有一个 i,而且已经是 3。第二个循环每轮都有自己的 j,分别是 0、1、2。
为什么闭包读到的是最终值
很多人以为回调创建时会把 i 的值「拍个照」存下来,其实不是。闭包记住的是变量本身,等回调真正执行时,才去读这个变量现在的值。
所以关键就两点:有几个 i,以及什么时候去读。var 只有一个 i,读的时候循环已经结束了,所以全是 3。如果回调在循环里同步立刻执行,var 也能打印出 0、1、2,因为读的时候 i 还没变。
let 能解决,是因为 for 循环对 let 有特殊处理:每进入新的一轮,就用上一轮的值新建一个 j。三个回调各自指向不同的 j,互不影响。
不用 let 的两种修法
老项目或者面试官要求不用 let 时,思路都是一样的:想办法让每轮循环有一个自己的变量。
i 存成参数JavaScriptfor (var i = 0; i < 3; i++) {
;(function (n) {
setTimeout(() => console.log(n)) // 读的是参数 n,不是外面的 i
})(i)
}
// 0 1 2每轮循环都立刻调用一次函数,把当时的 i 作为参数 n 传进去。函数每调用一次就有一个新的 n,回调读的是 n,自然就是 0、1、2。
- 用
setTimeout的第三个参数 setTimeout从第三个参数开始,会原样传给回调。写成setTimeout((n) => console.log(n), 0, i),调用setTimeout那一刻i的值就被当作参数传进去了,效果和 IIFE 一样。这个写法面试时提一句,能看出你对 API 比较熟。
容易答错的地方
- 以为是
setTimeout的问题 setTimeout只是让回调晚点执行,真正的原因是只有一个i。换成Promise.resolve().then()、按钮点击回调,只要是循环结束后才执行,结果都一样。- 以为
let会把整个循环体都复制一份 let只是让循环变量每轮都是新的。循环外面定义的对象、数组还是只有一份,在回调里修改它们,所有回调看到的都是同一个。
面试官还会怎么问?
如果把 setTimeout 的延迟改成 i * 1000,输出会变吗?
输出还是三个 3,只是每隔一秒打印一个。延迟时间是在调用 setTimeout 时就算好的,分别是 0、1000、2000 毫秒,但回调里读 i 的时机还是在循环结束之后。
forEach 为什么没有这个问题?
forEach 每处理一个元素都会调用一次回调函数,每次调用都有自己的参数,相当于天然用了 IIFE 的写法,不存在共用一个变量的问题。
在 let 循环体里修改 j,会影响下一轮吗?
会。下一轮的 j 是用本轮结束时的值再加 1 生成的,本轮把 j 改成 5,下一轮就从 6 开始。但已经创建的回调记住的还是本轮那个 j。
参考资料
示例用于理解所注明的运行环境与边界;延伸学习可结合原文中的更多案例。