深入理解 JavaScript 作用域链与闭包:原理、绑定与实战解析
JavaScript 中的作用域链与闭包是理解异步编程和内存管理的关键。本文将基于 ECMAScript 规范,从[[Environment]]内部属性出发,拆解词法环境、变量环境的构建过程,并深入探讨闭包的本质、变量绑定机制以及 var 与 let 在循环闭包中的不同表现。
深入作用域链与闭包的核心关联
作用域链与闭包紧密相连,理解前者是掌握后者的基础。在 JavaScript 中,如果内部函数使用了外部变量,这些变量是通过词法环境(由各类环境记录组成)串联起来的,最终在浏览器引擎中体现为作用域链 [[Scope]]。
闭包的触发需要一个独立的空间来管理从外部获取的变量。而这个变量的获取与绑定过程,完全依赖于作用域链。因此,只有理清作用域链的形成原理,才能深入理解闭包的工作机制。
完善的环境记录结构
仅仅将 VariableEnvironment.[[outerEnv]] 指向外部是不完备的。考虑到 JavaScript 中万物皆对象,若每次执行函数都动态解析全局词法来获取外部环境,将严重消耗性能。为此,ECMAScript 规范引入了更高效的机制。
[[Environment]] 内部属性
当函数被声明时,它就被附加了一个内部属性 [[Environment]]。根据 ECMAScript Function Objects 的内部槽定义,该属性是一个环境记录(Environment Record),其 [[outerEnv]] 直接指向声明该函数的词法环境。
| 内部槽 (Internal Slot) | 类型 (Type) | 描述 (Description) |
|---|---|---|
[[Environment]] | an Environment Record | 函数封闭过的环境记录。在评估函数代码时,用作外层环境。 |
执行上下文中的完整结构
在函数执行时创建的词法环境和变量环境,均存储在该函数的 [[Environment]] 中。以以下代码为例:
function foo() {
var a = 1;
let b = 2;
}
foo();
在函数执行前创建上下文时,较为完备的结构解释如下:
ExecutionContext: {
[[Environment]](0x00): {
LexicalEnvironment(0x01): {
b -> nothing
[[outerEnv]]: 0x02
}
VariableEnvironment(0x02): {
a -> undefined
[[outerEnv]]: 0x00
}
...
[[outerEnv]]: global
}
}
闭包的本质与实现
函数实例化的概念
为了更好地解释闭包,首先需要明确函数实例化的概念。虽然函数声明和表达式在语法上有所不同,但它们在运行时都会创建函数对象的实例:
- 函数声明:可以视为创建了原型
Function的一个实例。 - 函数表达式:则是创建函数的实例。
以下代码展示了同一函数代码块如何生成多个独立的函数实例:
function foo() {
return function myFun() {}
}
const fun1 = foo()
const fun2 = foo()
console.log(fun1 === fun2) // false
这里 myFun 是函数表达式,而 fun1 和 fun2 是两个不同的函数实例。
什么是闭包
基于函数实例的概念,闭包可以理解为与函数实例的作用域链绑定的一个映像。每生成一个函数实例,其内部都会有一条由环境记录串成的作用域链。
在具体实践(如 V8 引擎)中,函数在预编译阶段会解析内部词法。无论嵌套深度如何,也无论子函数是否被调用,只要内部引用了外部变量,这些变量就会被存到同一个闭包上。由于这些变量是通过作用域链获取且绑定的,可以说闭包在一定程度上是作用域链的副本或映射。
这个闭包可视为父函数的一个属性,且同一个实例中的所有子函数共用同一个闭包。
变量绑定的关键性
为什么要强调“绑定”?
- 当外部变量发生变化时,闭包中对应的变量也会随之变化。
- 在闭包中修改外部变量,其绑定的环境记录中的变量也会同步更新。
验证代码如下:
let a = 1;
let b = 2;
function foo() {
return function () {
a += 1;
b += 10;
console.log(a, b);
};
}
const bar = foo();
bar(); // 输出: 2 12
bar(); // 输出: 3 22
a += 10;
bar(); // 输出: 14 32
a = 0;
bar(); // 输出: 1 42
console.log(a) // 输出: 1
这种绑定机制解释了经典的面试题:为什么 var 声明的循环中 setTimeout 输出全是 6?
// var 版本
function foo() {
for (var i = 0; i < 6; i++) {
setTimeout(() => {
console.log(i);
}, i * 100);
}
}
foo(); // 输出: 6 6 6 6 6
因为 i 使用 var 声明,它会“逸出”保存到 foo 的变量环境中。所有 setTimeout 匿名函数闭包中的 i 都与 foo 的环境记录绑定。循环结束后 i++ 使 i 变为 6,此时执行回调,所有闭包读取的是同一个 i,故输出均为 6。
对比 let 的表现:
// let 版本
function foo() {
for (let i = 0; i < 6; i++) {
setTimeout(() => {
console.log(i);
}, i * 100);
}
}
foo(); // 输出: 0 1 2 3 4 5
使用 let 声明时,i 保存在最近的词法环境(即块级作用域)中。每次循环迭代都会形成一个新的块级作用域,因此每个 setTimeout 闭包绑定了不同的环境记录,从而能独立维护各自的值。
闭包的独立性与验证
在同一函数实例中,所有子函数共用一个闭包空间;但不同实例间的闭包空间是独立的。
同实例共用闭包
以下代码演示了同一实例中,即使某些子函数未被调用,其引用的变量依然存在於闭包中:
function foo() {
let a = 1;
const b = 2;
let c = 3;
let d = 4;
function bar() { // 验证未调用的情况
console.log(a);
function barSon() {
console.log(b);
}
}
return function () {
console.log(d);
return {
addNum() {
d = "new" + d;
},
};
};
}
const fun1 = foo();
fun1().addNum(); // 验证不同实例的闭包空间独立
fun1();
const fun2 = foo();
fun2();
上图显示,包含变量 a, b 的子函数并未被调用,但它们依然存在于闭包中,证明了闭包捕获的是引用而非当前值。
返回的匿名函数和 bar 使用的变量均在同一个闭包 foo.Closure 中。当我们通过 fun1.addNum() 修改 d 的值后,查看 fun2 的闭包,发现其中的 d 仍然是初始值 4。这证实了两个独立实例的闭包空间是完全隔离的。
总结
与静态的函数实例不同,闭包是一个运行期概念。它在函数执行过程中处于激活状态,并在函数实例调用结束后保持数据的最终状态,直到闭包被销毁。例如,反复调用同一函数实例生成的闭包,会看到数据状态的累积变化,这些值来源于上次调用的最终结果。
实际上,当函数执行并引用外部变量时,会新建一个环境记录作为闭包,存储在 [[Environment]].[[outerEnv]] 的可变绑定中。在浏览器环境中,只要函数内部引用外部变量,其内部属性 [[Scope]] 中就会产生名为 Closure 的对象。
最后,通过下图的形象说明闭包在作用域链中的位置。假设每个空间都有堆内存支持:
function foo() {
let a = 1;
const b = 2;
let c = 3;
let d = 4;
function bar() {
console.log(a);
function barSon() {
console.log(b);
}
}
return function myFun() {
console.log(d);
};
}
const fun1 = foo();
// 词法编译时结构示意
ExecutionContext(foo): {
[[outerEnv]]: global
...
[[Environment]](0x00): {
LexicalEnvironment(0x01): {
[[outerEnv]]: 0x00
...
a → nothing, b -> nothing, c -> nothing, d -> nothing
bar: {
LexicalEnvironment(0x02): {...}
[[Environment]] : {
[[outerEnv]] : 0x10 // 指向闭包
...
}
barSon: {
[[Environment]] : {
// 指向上一层函数的词法环境/闭包
[[outerEnv]] : 0x02
...
}
}
}
myFun: {
[[Environment]] : {
[[outerEnv]] : 0x10 // 指向闭包
...
}
}
}
// 即所谓的闭包,来源於外部的环境记录映射
EnvironmentRecord(0x10): {
a -> nothing, b -> nothing, d -> nothing
[[outerEnv]]: 0x01 // 指向外部词法环境
}
}
...
}
以上就是 JavaScript 深入理解作用域链与闭包详情的详细内容,更多关于 JS 作用域链与闭包的文章资料请关注本站其它相关文章!
本站声明
本文内容仅供参考,不代表本站观点。转载或引用请注明出处。如发现内容问题,请通过联系我们页面反馈。













