让我们通过这两个 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. 最佳实践建议
-
总是使用 let 而不是 var
// ❌ 避免 for (var i = 0; i < 10; i++) { ... } // ✅ 推荐 for (let i = 0; i < 10; i++) { ... } -
在循环中使用 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 } -
处理异步循环时特别注意
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. 总结表格
| 特性 | var | let |
|---|---|---|
| 作用域 | 函数作用域 | 块级作用域 |
| 变量提升 | 提升并初始化为 undefined | 提升但不初始化(暂时性死区) |
| 重复声明 | 允许 | 不允许 |
| 全局属性 | 会成为全局对象属性 | 不会成为全局对象属性 |
| for循环 | 共享同一个变量 | 每次迭代创建新绑定 |
| 闭包捕获 | 捕获共享变量 | 捕获独立变量 |
| 推荐使用 | 不推荐,已过时 | 推荐,现代标准 |
关键结论
-
var在 for 循环中:- 只有一个变量
i - 所有迭代共享同一个
i - 异步回调访问的都是循环结束后的值
- 只有一个变量
-
let在 for 循环中:- 每次迭代创建新的变量绑定
- 每个迭代有自己的
i - 异步回调访问各自迭代的
i
-
实际开发中:
- 总是使用
let和const - 避免使用
var - 理解闭包和作用域的概念
- 注意异步操作中的变量捕获
- 总是使用
这就是最初的示例中,第一个循环输出 10 个 10,而第二个循环输出 0-9 的原因。
let的块级作用域特性解决了 var在循环中的闭包问题。