前言
这是我《js核心基础》系列的一篇文章。
目前已经完成:
从promise到await - 掘金 (juejin.cn)
概论
对于闭包,网上的很多文章讲得很浅,只是说:“闭包就是访问了自由变量的函数”。而说到为什么能访问自由变量,往往给的解释是:“因为保留着子函数作用域的引用没有被释放掉”。
其实闭包的存在有着更深层的原理。和执行上下文、作用域链的知识息息相关。本文将从内存的角度揭开闭包原理。
这个部分的知识在我之前文章里js从编译到执行过程 - 掘金 (juejin.cn)详细阐述了,这里我只是把关于闭包的部分结论放出来。想要具体了解的朋友可以看看。
一,前置知识
1.1,执行上下文
之前的文章讲过,执行上下文是JavaScript 代码执行环境,在js代码执行前,就会创建并且压入执行栈(调用栈)。
一个执行上下文通常包括:变量环境(保存var变量和function函数声明)、词法环境(保存let和const变量)、this的绑定。
其中词法环境中还维护着一个词法环境栈,用来存储块级作用域的层级结构。
let a=0
function fn(){
let a=1
{
let b=2
console.log(a,b)//1 2
}
console.log(b)// b is not defined
}
fn()
对应的执行栈:
1.2,执行上下文和作用域链的关系
在上下文内部,变量访问总是从词法环境的栈顶开始,从栈顶到栈底,然后到变量环境。
function fun()
{
var c=4
let a = 1
{
let a = 2
let b = 3
console.log(a)
console.log(b)
}
console.log(a)
console.log(b)
}
fun()
如下图所示:
而在执行上下文之间呢,是如何进行变量的访问的?
有人会说是按照调用栈的层级去查找的。其实不是。
实际上,每个执行上下文之间还存在一个名为outer的变量,它指向下一个作用域(注意不是下一层执行上下文),直到全局上下文结束。
如下代码:
function bar(){
console.log(myName)//我名字
}
function foo(){
var myName='啦啦啦'
bar()
}
var myName='我名字'
foo()
对应的outer指向:
可以看到,bar()中打印的是全局中的“我名字”,而不是下一层执行上下文的“啦啦啦”。
也就是说,真正的作用域链,在当前执行文的变量查找顺序是:从词法环境的栈顶开始,从栈顶到栈底,然后到变量环境。
而跨上下文之间,则是通过outer变量的指向来决定下一级执行上下文。
1.3,[[scopes]]的作用域存储
上文说到:作用链其实就是通过每个执行上下文的outer链接起来形成的。
试想一下,如果每次访问变量都要顺着作用域链挨次查找,是不是很费时费力占用资源?而作用域链在代码执行前的预编译阶段就已经确定的,那为啥不把对应执行上下文的作用域预先【缓存】起来呢?
实际上,每个执行上下文在创建的时候,还会生成一个名为[[scopes]]的属性。它是个数组,作用就是存储除了当前执行上下文之外的作用域链内容。
这样一来,js访问变量就只需要查找当前执行上下文和这个数组就行。
如上1.2中的代码:
function bar(){
console.dir(bar)
console.log(myName)//我名字
}
function foo(){
var myName='啦啦啦'
bar()
}
var myName='我名字'
foo()
在执行到bar函数中的 console.dir(bar)的时候,对应的作用域链是bar->全局。查看打印结果:
可以看到,这个[[scopes]]数组中就存储了全局作用域。
二,闭包的生成
有了第一节中对于执行上下文和作用域链的认知,就可以开始重新认识闭包。
1.1,闭包的产生条件
函数嵌套
内部函数引用了外部函数的数据(变量/函数)
外部函数执行
如下代码:
function foo(){
var myName = "苏轼"
const test1 = 2
function bar(){
console.log(myName,test1)
}
bar()
}
foo()
就生成了一个闭包对象:
{
myName:"苏轼",
test1:2
}
1.2,闭包的作用域链情况
js的内存分为三个空间。
代码空间:存储代码
栈空间:存储执行栈
堆空间:存储引用类型的数据
如下代码:
function foo(){
var myName = "苏轼"
const test1 = 2
const test2=3
function getValue(){
var test3=4
console.dir(getValue)
console.log(myName,test1)
}
function setName(name){
myName=name
}
return {
getValue,
setName
}
}
var result=foo()
result.getValue()
result.setName('李白')
result.getValue()
在浏览器可以查看运行情况:
这时候打印出来的[[scopes]]就是作用域链减去当前执行上下文的内容,于是就会是:
[
0: Closure (foo) {myName: '李白', test1: 2, getValue: ƒ}
1: Global {window: Window, self: Window, document: document, name: '', location: Location, …}
]
可以看到,[[scopes]]数组中多了个Closure (foo) ,这就是闭包。
1.3,闭包Closure的具体特性
结合上文的知识,[[scopes]]数组是outer指向连接起来的,那这时候数组中存在Closure (foo),是不是意味着getValue函数的执行上下文中的outer指向的正是这个闭包?
答案是正确的。
闭包存在时,outer变量会指向堆空间中闭包对象。
如上2.2节中的代码,对应的内存outer指向情况:
可以清晰地看到,生成的闭包对象存储在堆空间中,并且当前执行上下文的outer指向堆空间的闭包,然后闭包对象中又有个outer指向全局执行上下文。
于是这时候的作用域链是getValue->闭包对象->全局执行上下文。
也就是说,在预编译阶段,每个函数执行前都会且仅会创建一个闭包对象,里面包含着该函数所有的闭包参数,没有被使用的自由变量不会加入闭包对象中。
var a=1
function foo(){
var b=3
var d=4
function test1(){
var c=5
console.log(a,b,c)
}
console.dir(test1)
test1()
}
foo()
如上代码。生成的闭包对象仅仅是{b=3},而不会有d=4的存在。
这才是闭包能访问自由变量的真正原因。
三,代码实际理解
如下代码:
function foo(){
var myName = "苏轼"
const test1 = 2
const test2=3
function getValue(){
console.dir(getValue)
console.log(myName,test1)
}
return getValue
}
var getValue1=foo()
getValue1()
在foo已经执行完毕后,就是因为生成的闭包对象{myName:"苏轼",test1:2}还在内存中,当执行getValue1的时候,getValue1执行上下文的outer变量就是指向内存中的闭包对象。
于是[[scopes]]数组的第一项就是这个闭包对象:
[
0: Closure (foo) {myName: '苏轼', test1: 2, getValue: ƒ}
1: Global {window: Window, self: Window, document: document, name: '', location: Location, …}
]
于是作用域链上就有了foo函数中的自由变量,这才是闭包的真正原理。