JavaScript 作用域与经典的 setTimeout 循环问题
三种作用域
JavaScript 中有三种作用域:
- 全局作用域:在函数和代码块之外声明的变量,任何地方都可访问
- 函数作用域:用
var声明的变量,在函数内部有效,外部不可访问 - 块级作用域:用
let或const声明的变量,只在最近的{}块内有效,包括if、for、while等语句块
var 与 let/const 最核心的区别就在于作用域粒度。var 没有块级作用域,这在使用循环和条件判断时会造成一些预期的偏差。典型的场景是以下这段代码:
for (var i = 0; i < 5; i++) {
setTimeout(function() {
console.log(i);
}, 100);
}
// 输出:5 个 5为什么输出 5 个 5
这段代码的行为由两个机制共同决定。
第一个是 var 没有块级作用域。循环体内的 i 并不是每次迭代都创建一个新的绑定,而是被提升到了函数作用域(此处退化为全局作用域)。五次迭代操作的是同一个 i 变量。
第二个是 setTimeout 的异步执行。回调函数不会在循环体执行时立即调用,而是被放入宏任务队列,等待同步代码执行完毕后才依次执行。当这五个回调开始执行时,循环早已结束,i 的值已经是 5。
因此,五个回调读取到的都是同一个全局变量 i,而它此时的值是 5。
解法一:改用 let
将 var 替换为 let 是最简洁的修复方式:
for (let i = 0; i < 5; i++) {
setTimeout(function() {
console.log(i);
}, 100);
}
// 输出:0, 1, 2, 3, 4let 在 for 循环头中具有特殊行为:每一次迭代都会创建一个新的词法环境,为 i 建立独立的绑定。每个 setTimeout 回调捕获的都是当次迭代专属的 i,互不干扰。
解法二:IIFE 创建独立作用域
在 let 出现之前,开发者通过立即执行函数表达式(IIFE)手动为每次迭代创建独立的作用域:
for (var i = 0; i < 5; i++) {
(function(j) {
setTimeout(function() {
console.log(j);
}, 100);
})(i);
}
// 输出:0, 1, 2, 3, 4IIFE 的形参 j 接收了当前 i 的值,而 j 是函数作用域内的变量,每次调用 IIFE 都创建一个新的作用域和新的 j 绑定。内部的 setTimeout 回调通过闭包持有的是各自 IIFE 中的 j。
为什么 let 在 for 循环中表现不同
这是理解 let 与 var 差异的关键。let 在 for 循环头中并非简单地把变量提升到块作用域,而是每次迭代都对变量做一次 per-iteration binding(逐迭代绑定)。规范层面,ECMAScript 为 let 在 for 语句中定义了一种特殊的词法环境创建机制,确保每次循环体执行时,循环变量都绑定到当前迭代的值。
这并非闭包层面的差异——两种写法都形成了闭包。区别在于闭包捕获的变量是共享的(var)还是独立的(let)。
另一个层面:与作用域链的关系
作用域链是当前作用域引用外层作用域形成的链式结构。当一个函数访问变量时,先从自身的词法环境中查找;找不到则沿作用域链向上,直到全局作用域。
在 var + setTimeout 的例子中,每个回调的作用域链指向同一个全局作用域中的 i,所以输出一致。而在 let 版本中,每个回调的作用域链指向各自独立的块级词法环境,所以输出不同。这个对比清晰地说明了作用域粒度的差异如何影响运行时行为。