JavaScript setTimeout在闭包中捕获变量值与引用的混淆
你可能遇到过这样的怪事:在循环里用 setTimeout 设定延迟打印,结果所有输出都打印了循环的最后一项。这通常是由于 setTimeout 在闭包中错误地捕获了变量引用,而非其当时值导致的。本文将手把手教你理解、复现并彻底解决这个问题。
1. 问题复现:一个典型的“全部输出最后一项”的错误
创建一个使用 var 声明的循环。
for (var i = 0; i < 5; i++) {
setTimeout(function() {
console.log(i);
}, 1000);
}
运行这段代码。一秒钟后,你不会看到 0, 1, 2, 3, 4,而是看到连续打印了五个 5。
// 控制台输出:
// 5
// 5
// 5
// 5
// 5
理解为什么会这样:setTimeout 的回调函数是异步执行的。循环瞬间执行完毕,此时 i 的值已经变成了 5。随后,所有排队等待的回调函数依次执行,它们访问的是同一个变量 i(因为 var 声明的变量是函数作用域的),所以打印的都是最终值 5。这是一个典型的闭包捕获变量引用的问题。
2. 根本原理:值捕获 vs. 引用捕获
区分两种捕获方式是解决问题的关键。
- 值捕获:函数在创建时,复制一份当时变量的值存起来。之后外部变量变化不影响已存的副本。
- 引用捕获:函数在创建时,只记住变量在哪里(即它的引用地址)。每次执行时,都去那个地址查看最新的值。
在 JavaScript 中,闭包(这里指 setTimeout 的回调函数)默认捕获的是变量的引用,而不是它的值。在第一个例子中,所有回调函数捕获的都是同一个变量 i 的引用,因此在循环结束后执行时,读到的都是最终值 5。
3. 解决方案:如何正确捕获变量的值
以下是几种有效的方法,每种方法的原理都是确保回调函数在创建时,能够绑定(或捕获)一个当时不变的值。
方法一:使用 let 代替 var(ES6推荐)
使用 let 关键字声明循环变量。
for (let i = 0; i < 5; i++) {
setTimeout(function() {
console.log(i);
}, 1000);
}
输出结果符合预期:0, 1, 2, 3, 4。
原理:let 会为循环的每一次迭代创建一个新的块级作用域,并初始化一个独立的 i 变量。每次迭代中 setTimeout 的回调函数捕获的是当次迭代中独立的 i,因此能输出正确的值。这是最简洁、最符合现代 JavaScript 习惯的方案。
方法二:使用立即执行函数 (IIFE) 创建独立作用域
将循环体包裹在一个立即执行函数中。
for (var i = 0; i < 5; i++) {
(function(j) {
setTimeout(function() {
console.log(j);
}, 1000);
})(i);
}
输出结果:0, 1, 2, 3, 4。
原理:我们通过 IIFE (function(j){ ... })(i) 为每次循环创建了一个独立的函数作用域。将当前循环变量 i 的值作为参数 j 传入。在这个新的作用域里,变量 j 被成功赋值为当次循环的 i 值。回调函数捕获的是这个新变量 j 的引用,由于 j 在其作用域内不再改变,所以输出正确。
方法三:利用 setTimeout 的第三个参数
利用 setTimeout 本身可以传递额外参数的特性。
for (var i = 0; i < 5; i++) {
setTimeout(function(j) {
console.log(j);
}, 1000, i); // 第三个参数 `i` 会作为第一个参数 `j` 传入回调函数
}
输出结果:0, 1, 2, 3, 4。
原理:setTimeout(callback, delay, param1, param2, ...) 中,从第三个参数开始的所有参数,都会作为额外参数传递给 callback 函数。这里,我们将当前的 i 值作为参数传入。回调函数定义了参数 j 来接收它,从而捕获了循环时 i 的值。这个方法避免了创建新的作用域,代码更紧凑。
方法四:使用 Function.prototype.bind
使用 bind 方法预先绑定参数。
for (var i = 0; i < 5; i++) {
setTimeout(function(j) {
console.log(j);
}.bind(null, i), 1000);
}
输出结果:0, 1, 2, 3, 4。
原理:bind(null, i) 会创建一个新函数,这个新函数在执行时,其第一个参数(对应原函数的第一个参数 j)已被固定为当前的 i 值。这个新函数被传递给 setTimeout,自然也能正确输出。
4. 方案对比与选择
| 方案 | 核心机制 | 优点 | 缺点/注意 |
|---|---|---|---|
let 声明 |
利用块级作用域创建独立变量 | 语法最简洁,意图最清晰,是现代JS的标准做法。 | 需要ES6环境。 |
| 立即执行函数 (IIFE) | 手动创建函数作用域,通过参数传值 | 兼容旧环境,原理清晰,是理解闭包的经典案例。 | 语法稍显冗余。 |
setTimeout 第三个参数 |
利用API本身传递参数的特性 | 代码紧凑,无需额外包裹或声明。 | 回调函数需多声明一个形参,可能降低可读性。 |
bind 方法 |
通过绑定创建预置参数的新函数 | 功能强大,适用于多种需要绑定this或参数的场景。 |
对于简单场景稍显“杀鸡用牛刀”。 |
根据你的项目环境和团队习惯选择:
- 首选
let。它最直接地解决了变量作用域的问题,代码最干净。 - 如果需要兼容非常古老的环境,或想深入理解闭包,可以练习 IIFE 写法。
- 当需要传递多个额外参数,或想避免额外包裹时,可以考虑
setTimeout第三个参数或bind。

暂无评论,快来抢沙发吧!