Skip to content

JavaScript 作用域与经典的 setTimeout 循环问题

三种作用域

JavaScript 中有三种作用域:

  • 全局作用域:在函数和代码块之外声明的变量,任何地方都可访问
  • 函数作用域:用 var 声明的变量,在函数内部有效,外部不可访问
  • 块级作用域:用 letconst 声明的变量,只在最近的 {} 块内有效,包括 ifforwhile 等语句块

varlet/const 最核心的区别就在于作用域粒度。var 没有块级作用域,这在使用循环和条件判断时会造成一些预期的偏差。典型的场景是以下这段代码:

javascript
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 是最简洁的修复方式:

javascript
for (let i = 0; i < 5; i++) {
  setTimeout(function() {
    console.log(i);
  }, 100);
}
// 输出:0, 1, 2, 3, 4

letfor 循环头中具有特殊行为:每一次迭代都会创建一个新的词法环境,为 i 建立独立的绑定。每个 setTimeout 回调捕获的都是当次迭代专属的 i,互不干扰。

解法二:IIFE 创建独立作用域

let 出现之前,开发者通过立即执行函数表达式(IIFE)手动为每次迭代创建独立的作用域:

javascript
for (var i = 0; i < 5; i++) {
  (function(j) {
    setTimeout(function() {
      console.log(j);
    }, 100);
  })(i);
}
// 输出:0, 1, 2, 3, 4

IIFE 的形参 j 接收了当前 i 的值,而 j 是函数作用域内的变量,每次调用 IIFE 都创建一个新的作用域和新的 j 绑定。内部的 setTimeout 回调通过闭包持有的是各自 IIFE 中的 j

为什么 let 在 for 循环中表现不同

这是理解 letvar 差异的关键。letfor 循环头中并非简单地把变量提升到块作用域,而是每次迭代都对变量做一次 per-iteration binding(逐迭代绑定)。规范层面,ECMAScript 为 letfor 语句中定义了一种特殊的词法环境创建机制,确保每次循环体执行时,循环变量都绑定到当前迭代的值。

这并非闭包层面的差异——两种写法都形成了闭包。区别在于闭包捕获的变量是共享的(var)还是独立的(let)。

另一个层面:与作用域链的关系

作用域链是当前作用域引用外层作用域形成的链式结构。当一个函数访问变量时,先从自身的词法环境中查找;找不到则沿作用域链向上,直到全局作用域。

var + setTimeout 的例子中,每个回调的作用域链指向同一个全局作用域中的 i,所以输出一致。而在 let 版本中,每个回调的作用域链指向各自独立的块级词法环境,所以输出不同。这个对比清晰地说明了作用域粒度的差异如何影响运行时行为。