从作用域链和内存角度重新理解闭包

467 阅读6分钟

前言

这是我《js核心基础》系列的一篇文章。

目前已经完成:

js从编译到执行过程 - 掘金 (juejin.cn)

从异步到promise - 掘金 (juejin.cn)

从promise到await - 掘金 (juejin.cn)

浅谈异步编程中错误的捕获 - 掘金 (juejin.cn)

作用域和作用域链 - 掘金 (juejin.cn)

原型链和原型对象 - 掘金 (juejin.cn)

this的指向原理浅谈 - 掘金 (juejin.cn)

js的函数传参之值传递 - 掘金 (juejin.cn)

js的事件循环机制 - 掘金 (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()

对应的执行栈:

4.PNG

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()

如下图所示:

2.png

而在执行上下文之间呢,是如何进行变量的访问的?

有人会说是按照调用栈的层级去查找的。其实不是。

实际上,每个执行上下文之间还存在一个名为outer的变量,它指向下一个作用域(注意不是下一层执行上下文),直到全局上下文结束。

如下代码:

function bar(){
    console.log(myName)//我名字
}
function foo(){
    var myName='啦啦啦'
    bar()
}
var myName='我名字'
foo()

对应的outer指向:

7.PNG

可以看到,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->全局。查看打印结果:

1.PNG

可以看到,这个[[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()

在浏览器可以查看运行情况:

9.PNG

这时候打印出来的[[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指向情况:

11.png

可以清晰地看到,生成的闭包对象存储在堆空间中,并且当前执行上下文的outer指向堆空间的闭包,然后闭包对象中又有个outer指向全局执行上下文。

于是这时候的作用域链是getValue->闭包对象->全局执行上下文

12.PNG

也就是说,在预编译阶段,每个函数执行前都会且仅会创建一个闭包对象,里面包含着该函数所有的闭包参数,没有被使用的自由变量不会加入闭包对象中。

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函数中的自由变量,这才是闭包的真正原理。