JavaScript-ES5 循环中的闭包 “共享变量” 问题
前置知识
ES5中只有function是作用域
ES5之前因为if和for都没有块级作用域的概念,所以在很多时候,我们都必须借助于function的作用域来解决应用外面变量的问题
ES6中加入了let,let它是有if和for的块级作用域
ES5中的var是没有块级作用域(if/for)
ES6中的let是有块级作用域的(if/for)
一.for循环
1.示例一
function fn() {for (var i = 0; i < 3; i++) { // 终止条件:i < 3console.log(i); }
}
fn();
⑴.步骤拆解:
-
初始化:
-
var i = 0
,此时i
的值为0
。
-
-
第一次循环迭代:
-
检查条件:
i < 3
→0 < 3
→true
。 -
执行循环体:
console.log(i)
→ 输出0
。 -
执行增量:
i++
→i
变为1
。
-
-
第二次循环迭代:
-
检查条件:
i < 3
→1 < 3
→true
。 -
执行循环体:
console.log(i)
→ 输出1
。 -
执行增量:
i++
→i
变为2
。
-
-
第三次循环迭代:
-
检查条件:
i < 3
→2 < 3
→true
。 -
执行循环体:
console.log(i)
→ 输出2
。 -
执行增量:
i++
→i
变为3
。
-
-
第四次条件检查:
-
检查条件:
i < 3
→3 < 3
→false
。 -
循环终止,不再执行循环体。
-
⑵.关键点
-
循环终止条件:
i < 3
,当i
增长到3
时条件不满足,循环结束。 -
循环体的执行时机:
console.log(i)
在每次循环迭代的 条件检查之后、增量操作之前 执行。 -
i 的实际最大值:
-
循环结束后
i
的值为3
(可以通过在循环外打印i
验证)。 -
但循环体最后一次执行时,
i
的值为2
(增量操作i++
在循环体执行后才生效)。
-
2.验证 i 的最终值
你可以在循环外打印 i
,观察它的最终值:
function fn() {for (var i = 0; i < 3; i++) {console.log("循环内:", i); // 输出 0、1、2}console.log("循环外:", i); // 输出 3
}
fn();
输出结果:
二、循环中的闭包“共享变量”问题
在 ES5 环境中,循环中的闭包“共享变量”问题是一个常见的陷阱。以下是详细解释和解决方案:
1.问题现象
在 for
循环中使用 var
声明变量时,闭包(如回调函数)会捕获循环变量的引用,而非其当前值。当循环结束后,所有闭包共享同一个变量,导致最终输出的值是循环结束后的最终值(而非预期的迭代值)。
示例代码(ES5):
for (var i = 0; i < 5; i++) {setTimeout(function() {console.log(i); // 所有输出都是 5}, 100);
}
输出结果:
5
5
5
5
5
解释:
关键原因总结
- 变量作用域问题:
var 声明的变量具有函数作用域,而非块级作用域(let/const 是 ES6 的块级作用域)。因此,所有 setTimeout 的回调函数共享同一个 i 变量。
- 异步执行的延迟:
setTimeout 是异步操作,其回调函数的执行被推迟到事件循环的下一轮。此时,循环已经完成,i 的最终值是 5,所有回调函数访问的都是这个最终值。
详细过程分析
步骤 1:循环的同步执行
- 初始状态:
i
的初始值是0
,作用域是整个函数(或全局作用域)。 - 循环执行:
- 第一次循环(
i = 0
):- 设置
setTimeout
,但回调函数不会立即执行,而是被推入浏览器的任务队列中。
- 设置
- 第二次循环(
i = 1
):- 再次设置
setTimeout
,回调函数被推入任务队列。
- 再次设置
- 以此类推,直到
i = 4
时,循环结束。 - 循环结束后,
i
的值变为5
(因为循环条件i < 5
不成立时才退出循环)。
- 第一次循环(
步骤 2:异步回调的执行
- 同步代码执行完毕:循环结束后,所有同步代码(如
for
循环本身)已经执行完成。 - 事件循环触发:浏览器的事件循环开始处理任务队列中的
setTimeout
回调函数。 - 回调函数执行时:每个回调函数访问的
i
是同一个变量,此时i
的值已经是5
(循环结束后的最终值)。
关键点详解
1. 变量作用域与闭包
var
的函数作用域:
var i
声明的变量在整个函数或全局作用域中唯一,所有setTimeout
的回调函数共享这个变量。- 闭包捕获变量引用:
每个setTimeout
的回调函数形成了一个闭包,它捕获的是外部变量i
的引用,而非i
的当前值。因此,当回调函数执行时,它们访问的始终是同一个i
的最新值(即5
)。
2. 异步与事件循环
- 异步任务的延迟:
setTimeout
的回调函数会被放入浏览器的宏任务队列中,等待当前同步代码执行完毕后才会执行。 - 事件循环的执行顺序:
浏览器的事件循环机制会先执行所有同步代码(如for
循环),然后处理任务队列中的异步任务。此时,i
的值已经是5
,所有回调函数输出的都是5
。
可视化时间线
时间阶段 | 代码执行内容 |
---|---|
同步阶段 | 循环快速执行完毕,i 的值变为 5 。所有 setTimeout 回调被推入任务队列。 |
事件循环触发 | 任务队列中的回调开始执行,此时 i = 5 ,所有回调输出 5 。 |
2.解决方案
如果希望每个 setTimeout
输出对应的 i
值(如 0,1,2,3,4
),可以通过以下方法隔离变量:
方法 1:使用 let
(ES6)
for (let i = 0; i < 5; i++) {setTimeout(function() {console.log(i); // 输出 0,1,2,3,4}, 100);
}
解释
关键问题:为什么输出是 0,1,2,3,4 而不是 5,5,5,5,5?
核心原因
- let 的块级作用域:
let 声明的变量在每次循环迭代中都会创建一个新的块级作用域,因此每次迭代的 i 是独立的变量。
- 闭包捕获独立变量:
每个 setTimeout 的回调函数捕获的是当前迭代的 i 变量,而非所有迭代共享的同一个变量。
详细过程分析
步骤 1:循环的同步执行
-
初始状态:
for
循环开始执行,let i = 0
在第一次迭代时创建了一个块级作用域,其中i
的初始值为0
。 -
循环迭代:
每次循环迭代时,let
会为i
创建一个新的块级作用域。例如:- 第一次迭代:
- 块级作用域
Scope1
中i = 0
。 - 设置
setTimeout
,回调函数被推入任务队列,但不会立即执行。
- 块级作用域
- 第二次迭代:
- 块级作用域
Scope2
中i = 1
。 - 再次设置
setTimeout
,回调函数被推入任务队列。
- 块级作用域
- 以此类推,直到
i = 4
时,循环结束。
- 第一次迭代:
-
循环结束后:
i
的最终值变为5
(因为循环条件i < 5
不成立时退出循环),但此时所有迭代的i
变量(Scope1
到Scope5
)仍然独立存在。
步骤 2:异步回调的执行
-
同步代码执行完毕:
循环结束后,所有同步代码(如for
循环本身)已经执行完成,浏览器开始处理任务队列。 -
事件循环触发:
浏览器的事件循环开始处理宏任务队列中的setTimeout
回调函数。此时,每个回调函数访问的i
是各自迭代时的块级作用域中的变量,而非最终的i = 5
。 -
回调函数执行时的
i
值:
每个回调函数捕获的是其所在迭代的i
变量的引用:- 第一个回调函数捕获的是
Scope1
中的i = 0
。 - 第二个回调函数捕获的是
Scope2
中的i = 1
。 - 以此类推,最终输出
0,1,2,3,4
。
- 第一个回调函数捕获的是
关键机制详解
1. let
的块级作用域
-
块级作用域的创建:
for
循环的每次迭代都会生成一个新的块级作用域。例如:
for (let i = 0; i < 5; i++) {// 每次迭代的代码块是一个独立的块级作用域
}
-
- 每次迭代的代码块(
{}
)内部,let i
声明的变量i
是独立的,与其他迭代的i
无关。
- 每次迭代的代码块(
-
变量隔离:
每次迭代的i
存在于各自的块级作用域中,因此不会相互干扰。
2. 闭包与词法作用域
-
闭包的形成:
每个setTimeout
的回调函数形成一个闭包,捕获其所在迭代的块级作用域中的i
变量。例如:
setTimeout(function() { console.log(i); }, 100);
- 回调函数的词法作用域链指向其定义时的块级作用域(如
Scope1
、Scope2
等)。
-
变量引用的独立性:
每个闭包访问的是独立的i
变量,而非循环结束后共享的变量。
3. 异步任务的延迟执行
-
任务队列的处理顺序:
setTimeout
的回调函数属于宏任务,会被推入宏任务队列。事件循环会在同步代码执行完毕后,依次执行队列中的任务。 -
闭包执行时的变量值:
当回调函数执行时,它访问的是其闭包中捕获的i
的当前值(即定义时的值),而非循环结束后的最终值。
对比 var
的情况
如果代码使用 var
替代 let
,则会出现问题:
for (var i = 0; i < 5; i++) {setTimeout(function() {console.log(i); // 输出 5,5,5,5,5}, 100);
}
var
的函数作用域:
var
声明的i
具有函数作用域(或全局作用域),所有迭代共享同一个i
变量。- 闭包捕获共享变量:
所有回调函数捕获的是同一个i
的引用,最终值为5
,因此输出全为5
。
总结
代码 | 作用域 | 闭包捕获的变量 | 输出结果 |
---|---|---|---|
for (let i = 0; ... | 块级作用域(每次迭代) | 当前迭代的独立 i 变量 | 0,1,2,3,4 |
for (var i = 0; ... | 函数作用域 | 共享的最终 i = 5 | 5,5,5,5,5 |
方法 2:立即执行函数(IIFE)(ES5 兼容)
for (var i = 0; i < 5; i++) {(function(j) { // 通过参数 j 传递当前的 i 值setTimeout(function() {console.log(j); // 每次迭代的 j 是独立的}, 100);})(i); // 立即执行函数并传入当前 i
}
- 原理:通过
IIFE
为每次迭代创建独立的作用域,j
是当前i
的值,闭包捕获的是j
的独立副本。
解释
逐行解释
1. 外层循环(for 循环)
for (var i = 0; i < 5; i++) {// ...
}
- 这是一个普通的
for
循环,使用var
声明变量i
。 - 问题:
var
的作用域是函数级的,所有循环迭代共享同一个i
变量。
2. 立即执行函数(IIFE)
(function(j) { // 定义一个函数,参数是 j// 函数体
})(i); // 立即执行这个函数,并传入当前的 i 值
- 这是一个立即执行函数表达式(IIFE),每次循环迭代时都会创建一个独立的作用域。
- 关键点:通过参数
j
将当前i
的值传递给函数内部,这样每个 IIFE 的作用域中都有自己的j
变量。
3. setTimeout 回调函数
setTimeout(function() {console.log(j);
}, 100);
- 这是延迟执行的代码块(异步操作)。
- 闭包的作用:这个回调函数会捕获其所在 IIFE 的
j
变量,而不是外层循环的i
变量。
执行过程(分步详解)
步骤 1:循环开始
- 初始状态:
i = 0
(外层循环的var i
)。 - 第一次迭代:
- 执行
i = 0
。 - 调用 IIFE:
(function(j) { ... })(0)
。- 参数
j
被赋值为当前i
的值(即0
)。 - 这个 IIFE 的作用域中,
j = 0
。
- 参数
- 设置
setTimeout
,回调函数被推入任务队列,但不会立即执行。
- 执行
步骤 2:后续迭代
- 第二次迭代:
i
增加到1
。- 调用 IIFE:
(function(j) { ... })(1)
。- 参数
j
被赋值为1
。 - 这个 IIFE 的作用域中,
j = 1
。
- 参数
- 再次设置
setTimeout
,回调函数被推入任务队列。
- 以此类推,直到
i = 4
时,循环结束。
步骤 3:循环结束后
- 外层循环的
i
最终变为5
(因为循环条件i < 5
不成立时退出)。 - 所有
setTimeout
的回调函数仍在任务队列中等待执行。
步骤 4:异步回调执行
- 事件循环触发:当
100ms
到达后,浏览器开始执行这些回调函数。 - 每个回调函数的执行环境:
- 第一个回调函数捕获的是第一次迭代的
j = 0
。 - 第二个回调函数捕获的是第二次迭代的
j = 1
。 - 以此类推,最终输出
0, 1, 2, 3, 4
。
- 第一个回调函数捕获的是第一次迭代的
为什么这样能解决问题?
核心原因:作用域隔离
- IIFE 创建独立作用域:
每次循环迭代时,通过IIFE
创建了一个新的作用域,每个作用域都有自己的j
变量。 - 闭包捕获独立变量:
每个setTimeout
的回调函数捕获的是当前 IIFE 的j
,而非外层共享的i
。 - 避免“最终值”问题:
即使外层的i
最终变为5
,每个回调函数的j
仍保留了当时传递的值(如0
、1
等)。