深入作用域链与闭包
为什么要把作用域链和闭包放在一起讲呢,它们有什么关联吗?
试想,我们如果在一个内部的函数使用了外部的变量,是通过[[outerEnv]]
串起来的词法环境(各类环境记录),即最终在浏览器上的实现,作用域链[[Scope]]
。
而闭包的触发,是需要在一个独立的空间中管理从外部获得的变量。而这个外部变量的获取与绑定,则是需要通过作用域链。
所以理解了作用域链的形成原理,才能更好的深入理解闭包。
作用域链
上节的例子中对于函数中变量记录的阐释并不完备,只是简单的将VariableEnvironemnt.[[outerEnv]]
指向了外部。仔细思考的同学可能会发现,JavaScript里面万物皆对象,函数这个对象满天飞,如果每次都要解析全局词法来获取某个函数的外部环境,是不是很浪费性能呢?
[[Environment]]
所以其实在函数被声明的时候,就被加上了一个内部属性[[Environment]]
,根据规范定义,它也是一个环境记录,其[[outerEnv]]
指向声明函数的词法环境。
10.2 ECMAScript Function Objects) Internal Slots of ECMAScript Function Objects
Internal Slot | Type | Description |
---|---|---|
[[Environment]] | an Environment Record | The Environment Record that the function was closed over. Used as the outer environment when evaluating the code of the function. |
完善环境记录
同时,在函数执行的时候,创建的词法环境和变量环境都是存储在 [[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
}
}
复制代码
闭包
函数实例
为了更好的解释闭包。先了解一下函数实例化的概念:声明函数的时候可以使用new Function
,实例出来一个函数对象
- 函数声明:可以叫做函数实例化,创建了原型
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
cnsole.log(a) // 1 (在闭包中+1,全局环境中的 a 也对应+1)
复制代码
这个绑定也可以解释一个经典的面试题(相关前置知识可以参考上一节《环境变量》)
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++
,即 foo
的 i++
,闭包中的 i
随之变化。因为所有闭包绑定了同一个环境记录,所以是显示同一个值,退出循环后仍然执行了一次 i++
,因此输出为 6 而不是 5。
对应的。我们来看看用 let
声明的 i
的表现。
function foo() {
for (let i = 0; i < 6; i++) {
setTimeout(() => {
console.log(i);
}, i * 100);
}
}
foo(); // 1 2 3 4 5
复制代码
这里的 i
是由 let
声明,所以它会被保存到最近的词法环境中,即块的词法环境。每次循环都会形成一个新的块级作用域,因此 i
保存的环境都不一样,即每个setTimeout
匿名函数闭包中的 i
绑定了不同的环境记录。因此可以单独管理。
同一个闭包
上文提到,在同一个函数实例中,所有子函数公用一个闭包。我们用具体代码来验证一下
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();
复制代码
这里我们新建了两个实例,按照上文的理论,二者的闭包应该是独立的,且所有子函数无论深度以及子函数是否被调用都会共用一个闭包。
![](https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/627463f8f65c45ce9c1d30a822198d37~tplv-k3u1fbpfcp-zoom-in-crop-mark:4536:0:0:0.image?closure_1.png)
上图的包含变量 a,b
的子函数并没有调用,但是在闭包中仍然存在。
返回的匿名函数和 bar
有使用到的变量在同一个闭包 foo.Closure
中
![](https://p6-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/4d85ce87013f479daf370b15a749f226~tplv-k3u1fbpfcp-zoom-in-crop-mark:4536:0:0:0.image?closure_2.png)
![](https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/b169fef811784cf38a408bc8e0e7efe4~tplv-k3u1fbpfcp-zoom-in-crop-mark:4536:0:0:0.image?closure_3.png)
这里我们调用fun1.addNum
修改了 d
的值,但是实例 fun2
的闭包中的 d
仍然是 4 。可以看出两个闭包是独立的。
总结
与静态的函数实例相对应,闭包是一个运行期的概念,其在函数执行的过程中处于激活的、可访问的状态。并在函数实例调用结束后保持数据信息的最终状态,直到闭包被销毁。(具体体现形式为上个例子中不断调用同一个函数会输出不同而值,而这些值是来自于上次调用的最终数据状态)
实际上,函数执行时,如果当前函数有使用到外部变量,会新建一个环境记录作为闭包(规范定义),它存在与 [[Environment]].[[outerEnv]]
的可变绑定。(在浏览器中,只要函数内部对外部变量进行引用,函数的内部属性[[Scope]]
中会有名为 Closure
的闭包)
下面是上一个例子执行完毕之后两个函数实例的[[Scope]]
在最后举个例子形象说明一下闭包在作用域链中处于什么位置
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): {
LexicalEnviroemnt(0x01): {
[[outerEnv]]: 0x00
...
a —> nothing, b -> nothing, c -> nothing, d -> nothing
bar: {
LexicalEnviroemnt(0x02): {...}
[[Environment]] : {
[[outerEnv]] : 0x10 // 指向闭包
...
}
barSon: {
[[Environment]] : {
// 指向上一层函数的词法环境,如果有闭包则会指向上一层函数的闭包
[[outerEnv]] : 0x02
...
}
}
}
myFun: {
[[Environment]] : {
[[outerEnv]] : 0x10 // 指向闭包
...
}
}
}
// 即所谓的闭包,这里面变量的来源于外部的环境记录(某种映射)
EnvironmentRecord(0x10): {
a -> nothing, b -> nothing, d -> nothing
[[outerEnv]]: 0x01 // 指向外部词法环境
}
}
...
}
复制代码
参考资料
ECMAScript® 2022 Language Specification
《JavaScript忍者秘籍》(第二版)
《JavaScript语言精髓与编程实践》(第三版)
【JS】详解ES6执行时词法环境/作用域/执行上下文/执行栈和闭包
解读闭包,这次从ECMAScript词法环境,执行上下文说起
最后
作者水平有限,如果有错误或者不严谨的地方,请务必指出,十分感谢!!!