深入对比:var 和 let 在 for 循环中的差异

33 阅读7分钟

让我们通过这两个 for 循环示例,详细分析 var 和 let 的区别。

1. 原始代码分析

使用 var

for (var i = 0; i < 10; ++i) {
  setTimeout(() => {
    console.log(i);
  }, 1000);
}
// 输出: 10 10 10 10 10 10 10 10 10 10

使用 let

for (let i = 0; i < 10; ++i) {
  setTimeout(() => {
    console.log(i);
  }, 1000);
}
// 输出: 0 1 2 3 4 5 6 7 8 9

2. 执行过程详细分析

var 版本的执行过程

// 代码等价于:
var i;  // 1. 变量提升到全局或函数作用域
for (i = 0; i < 10; ++i) {
  // 每次循环都创建一个新的 setTimeout
  // 但所有回调函数共享同一个 i
  setTimeout(() => {
    console.log(i);  // 这里的 i 是外部作用域的 i
  }, 1000);
}
// 循环结束后,i = 10
// 1秒后,所有回调函数执行,访问的 i 都是 10

可视化时间线

时间 0ms: 循环开始
i=0 → 创建 setTimeout1,回调访问 i
i=1 → 创建 setTimeout2,回调访问 i
...
i=9 → 创建 setTimeout10,回调访问 i
i=10 → 循环结束
时间 1000ms: 所有 setTimeout 回调执行
console.log(i) 中的 i 现在等于 10
输出: 10 10 10 10 10 10 10 10 10 10

let 版本的执行过程

// 代码等价于:
for (let i = 0; i < 10; ++i) {
  // 每次循环创建一个新的块级作用域
  // 每个作用域有自己的 i
  let loop_i = i;  // 每次循环创建新的变量
  setTimeout(() => {
    console.log(loop_i);  // 闭包捕获当前作用域的 loop_i
  }, 1000);
}
// 每个 setTimeout 回调访问自己作用域的 i

可视化时间线

时间 0ms: 第一次循环
创建块级作用域1,i1=0
创建 setTimeout1,回调访问 i1
i自增为1

时间 0ms+:第二次循环
创建块级作用域2,i2=1
创建 setTimeout2,回调访问 i2
i自增为2
...
时间 0ms+:第十次循环
创建块级作用域10,i10=9
创建 setTimeout10,回调访问 i10
i自增为10,循环结束

时间 1000ms: 所有 setTimeout 回调执行
setTimeout1: console.log(i1) → 0
setTimeout2: console.log(i2) → 1
...
setTimeout10: console.log(i10) → 9
输出: 0 1 2 3 4 5 6 7 8 9

3. 根本区别详解

var 的问题:函数作用域

// var 是函数作用域
function testVar() {
  for (var i = 0; i < 3; i++) {
    console.log('循环内:', i);  // 0, 1, 2
  }
  console.log('循环后:', i);  // 3 - var 泄露到外部
}
testVar();
console.log('全局:', typeof i);  // undefined,但在函数内泄露

let 的优势:块级作用域

// let 是块级作用域
function testLet() {
  for (let i = 0; i < 3; i++) {
    console.log('循环内:', i);  // 0, 1, 2
  }
  // console.log('循环后:', i);  // ReferenceError: i is not defined
}
testLet();

4. 闭包和变量捕获的差异

var 导致的问题

// 问题:所有回调共享同一个 i
var callbacks = [];
for (var i = 0; i < 5; i++) {
  callbacks.push(function() {
    console.log(i);
  });
}
callbacks.forEach(fn => fn());  // 5 5 5 5 5

用 IIFE 解决 var 的问题

// 解决方案1:立即执行函数表达式(IIFE)
var callbacks = [];
for (var i = 0; i < 5; i++) {
  (function(index) {
    callbacks.push(function() {
      console.log(index);
    });
  })(i);
}
callbacks.forEach(fn => fn());  // 0 1 2 3 4

let 的自动块级作用域

// 解决方案2:使用 let
var callbacks = [];
for (let i = 0; i < 5; i++) {
  // 每次循环都会创建新的块级作用域
  // 回调函数捕获当前作用域的 i
  callbacks.push(function() {
    console.log(i);
  });
}
callbacks.forEach(fn => fn());  // 0 1 2 3 4

5. 底层原理:let 在 for 循环中的特殊行为

规范说明

JavaScript 规范规定,for (let i = 0; ...)会在每次迭代中创建新的绑定

// 每次迭代都会创建新的词法环境
for (let i = 0; i < 3; i++) {
  // 每个迭代有独立的词法环境
  // 回调函数捕获当前迭代的 i
  setTimeout(() => console.log(i));
}
// 输出: 0 1 2

等价的重写

使用 let 的 for 循环可以看作:

// 模拟 let 的行为
{
  let i = 0;
  if (i < 3) {
    // 第一次迭代
    let iteration_i = i;  // 创建新变量
    setTimeout(() => console.log(iteration_i));
    i++;
    
    if (i < 3) {
      // 第二次迭代
      let iteration_i = i;  // 创建新变量
      setTimeout(() => console.log(iteration_i));
      i++;
      
      if (i < 3) {
        // 第三次迭代
        let iteration_i = i;  // 创建新变量
        setTimeout(() => console.log(iteration_i));
        i++;
      }
    }
  }
}

6. 更多对比示例

示例1:嵌套循环

// 使用 var - 有问题
for (var i = 0; i < 3; i++) {
  for (var i = 0; i < 2; i++) {
    console.log(`内层: ${i}`);
  }
  console.log(`外层: ${i}`);  // 只输出一次
}
// 输出: 内层:0 内层:1
//      外层:2

// 使用 let - 正确
for (let i = 0; i < 3; i++) {
  for (let i = 0; i < 2; i++) {
    console.log(`内层: ${i}`);
  }
  console.log(`外层: ${i}`);
}
// 输出: 内层:0 内层:1 外层:0
//      内层:0 内层:1 外层:1
//      内层:0 内层:1 外层:2

示例2:在循环中重新声明

// var 允许重复声明
for (var i = 0; i < 3; i++) {
  var i = 5;  // 重新声明,会出问题
  console.log(i);
}
// 输出: 5

// let 不允许重复声明
for (let i = 0; i < 3; i++) {
  // let i = 5;  // SyntaxError: Identifier 'i' has already been declared
  console.log(i);
}

示例3:循环后的访问

// var 循环后可以访问
for (var i = 0; i < 3; i++) {}
console.log('var 循环后 i =', i);  // 3

// let 循环后不能访问
for (let j = 0; j < 3; j++) {}
// console.log('let 循环后 j =', j);  // ReferenceError

7. 实际应用中的影响

事件处理

// 问题示例
var buttons = document.querySelectorAll('button');
for (var i = 0; i < buttons.length; i++) {
  buttons[i].addEventListener('click', function() {
    console.log('按钮 ' + i + ' 被点击');  // 总是输出最后一个 i
  });
}

// 解决方案1:使用 let
for (let i = 0; i < buttons.length; i++) {
  buttons[i].addEventListener('click', function() {
    console.log('按钮 ' + i + ' 被点击');  // 正确的索引
  });
}

// 解决方案2:使用闭包(var 时)
for (var i = 0; i < buttons.length; i++) {
  (function(index) {
    buttons[index].addEventListener('click', function() {
      console.log('按钮 ' + index + ' 被点击');
    });
  })(i);
}

异步操作

// 常见的错误模式
var requests = [1, 2, 3, 4, 5];
var results = [];

for (var i = 0; i < requests.length; i++) {
  setTimeout(() => {
    results.push(`结果 ${i}`);  // 所有都是 "结果 5"
  }, 100);
}

// 使用 let 修复
for (let i = 0; i < requests.length; i++) {
  setTimeout(() => {
    results.push(`结果 ${i}`);  // 结果 0, 结果 1, 结果 2...
  }, 100);
}

8. 性能考虑

作用域查找性能

// var 在全局作用域
var globalVar = 1;
function varTest() {
  for (var i = 0; i < 1000000; i++) {
    globalVar += i;  // 需要查找全局作用域
  }
}

// let 在块级作用域
function letTest() {
  let localVar = 1;
  for (let i = 0; i < 1000000; i++) {
    localVar += i;  // 在当前作用域
  }
}

9. 最佳实践建议

  1. 总是使用 let 而不是 var

    // ❌ 避免
    for (var i = 0; i < 10; i++) { ... }
    
    // ✅ 推荐
    for (let i = 0; i < 10; i++) { ... }
    
  2. 在循环中使用 const 当不修改迭代变量

    const array = [1, 2, 3, 4, 5];
    
    // ✅ 如果不需要修改 i
    for (const item of array) {
      console.log(item);
    }
    
    // ✅ 如果需要修改 i
    for (let i = 0; i < array.length; i++) {
      // 可以修改 i
    }
    
  3. 处理异步循环时特别注意

    async function processArray(array) {
      // ✅ 正确:使用 for...of
      for (const item of array) {
        await processItem(item);
      }
    
      // ✅ 正确:使用 for 循环 + let
      for (let i = 0; i < array.length; i++) {
        await processItem(array[i]);
      }
    
      // ❌ 避免:var 在异步循环中
      for (var i = 0; i < array.length; i++) {
        setTimeout(() => processItem(array[i]), 0);
      }
    }
    

10. 总结表格

特性varlet
作用域函数作用域块级作用域
变量提升提升并初始化为 undefined提升但不初始化(暂时性死区)
重复声明允许不允许
全局属性会成为全局对象属性不会成为全局对象属性
for循环共享同一个变量每次迭代创建新绑定
闭包捕获捕获共享变量捕获独立变量
推荐使用不推荐,已过时推荐,现代标准

关键结论

  1. var在 for 循环中

    • 只有一个变量 i
    • 所有迭代共享同一个 i
    • 异步回调访问的都是循环结束后的值
  2. let在 for 循环中

    • 每次迭代创建新的变量绑定
    • 每个迭代有自己的 i
    • 异步回调访问各自迭代的 i
  3. 实际开发中

    • 总是使用 letconst
    • 避免使用 var
    • 理解闭包和作用域的概念
    • 注意异步操作中的变量捕获

这就是最初的示例中,第一个循环输出 10 个 10,而第二个循环输出 0-9 的原因。

let的块级作用域特性解决了 var在循环中的闭包问题。