十分钟系列:作用域链与闭包

145 阅读6分钟

五分钟learn变量提升
五分钟learn调用栈
五分钟系列:块级作用域

作用域链

简单例子

function bar() {
    console.log(myName)
}
function foo() {
    var myName = "inner"
    bar()
}
var myName = "outer"
foo()

执行foo,最终需要打印“myName”。按照调用栈内容,从上往下依次是bar执行上下文、foo执行上下文、全局执行上下文。

  1. 先查找栈顶是否存在myName变量,这里没有,接着往下查找foo函数执行上下文。
  2. 在foo中查找到了myName变量,这时候就使用foo函数中的myName,也就是“inner”

但实际上,打印的却是“outer”,这是为什么呢

定义

其实在每个执行上下文的变量环境中,都包含了一个外部引用,用来指向外部的执行上下文。

当一段代码使用了一个变量时,JavaScript引擎首先会在“当前的执行上下文”中查找该变量,如果在当前的变量环境中没有查找到,那么JavaScript引擎会继续在所指向的外部执行上下文中查找,而不是根据调用栈往下查找。我们把这个查找的链条就称为作用域链

在上面示例中,bar函数和foo函数的外部执行上下文都是指向全局执行上下文。

词法作用域

词法作用域就是指作用域是由代码中函数声明的位置来决定的,它是静态的作用域,代码编译阶段就决定好的,和函数是怎么调用的没有关系

image.png 从图中可以看出,词法作用域就是根据代码的位置来决定的,其中main函数包含了bar函数,bar函数中包含了foo函数,因为JavaScript作用域链是由词法作用域决定的,所以整个词法作用域链的顺序是:foo函数作用域—>bar函数作用域—>main函数作用域—>全局作用域。

再看之前的用例,foobar都是在外层定义的,所以它们的上级作用域都是全局作用域,所以如果foo或者bar函数使用了一个它们没有定义的变量,那么它们会到全局作用域去查找。

块级作用域中的变量查找

function bar() {
    var myName = "bar"
    let test1 = 100
    if (1) {
        let myName = "bxy"
        console.log(test)
    }
}
function foo() {
    var myName = "foo"
    let test = 2
    {
        let test = 3
        bar()
    }
}
var myName = "window"
let myAge = 10
let test = 1
foo()

当执行到打印test的情况时,其调用栈及查找路线如下图所示:

image.png

闭包

定义

在JavaScript中,根据词法作用域的规则,内部函数总是可以访问其外部函数中声明的变量,当通过调用一个外部函数返回一个内部函数后,即使该外部函数已经执行结束了,但是内部函数引用外部函数的变量依然保存在内存中,我们就把这些变量的集合称为闭包。

用例

function foo() {
    var myName = "foo"
    let test1 = 1
    const test2 = 2
    var innerBar = {
        getName:function(){
            console.log(test1)
            return myName
        },
        setName:function(newName){
            myName = newName
        }
    }
    return innerBar
}
var bar = foo()
bar.setName("bar")
bar.getName()
console.log(bar.getName())

当执行到foo函数内部的return innerBar这行代码时调用栈的情况: image.png

从上面的代码可以看出,innerBar是一个函数,包含了getName和setName的两个方法。这两个方法都是在foo函数内部定义且内部都使用了myName和test1。

根据词法作用域的规则,内部函数getName和setName总是可以访问它们的外部函数foo中的变量,所以当innerBar对象返回给全局变量bar时,虽然foo函数已经执行结束,但是getName和setName函数依然可以使用foo函数中的变量myName和test1。所以当foo函数执行完成之后,其整个调用栈的状态如下图所示:

image.png

可以看出,foo函数执行完成之后,其执行上下文从栈顶弹出了,但是由于返回的setName和getName方法中使用了foo函数内部的myName和test1,所以这两个变量依然保存在内存中。而且这两个变量只能被setName和getName访问到,其他任何地方都无法访问到,这就是所谓的闭包

堆栈

首先我们需要了解堆栈概念。前面我们一直在说“调用栈”,这其实就是栈空间,用来存储执行上下文的。

JS数据分为原始数据和引用数据,原始类型的数据值都是直接保存在“栈”中的,引用类型的值是存放在“堆”中的。然后,存放在堆中的位置,会被放到执行上下文中:

function foo(){
    var a = 1;
    var b = {name:"foo"};
    var c = b
}
foo()

image.png

所有数据直接存放在“栈”中不可以吗?

不可以。因为JavaScript引擎需要用栈来维护程序执行期间上下文的状态,如果栈空间大了话,所有的数据都存放在栈空间里面,那么会影响到上下文切换的效率,进而又影响到整个程序的执行效率。

通常情况下,栈空间都不会设置太大,主要用来存放一些原始类型的小数据。而引用类型的数据占用的空间都比较大,所以这一类数据会被存放到堆中,堆空间很大,能存放很多大的数据,不过缺点是分配内存和回收内存都会占用一定的时间。

闭包数据放在哪儿

  1. 当JavaScript引擎执行到foo函数时,首先会编译,并创建一个空执行上下文。
  2. 在编译过程中,遇到内部函数setName,JavaScript引擎还要对内部函数做一次快速的词法扫描,发现该内部函数引用了foo函数中的myName变量,由于是内部函数引用了外部函数的变量,所以JavaScript引擎判断这是一个闭包,于是在堆空间创建换一个“closure(foo)”的对象(这是一个内部对象,JavaScript是无法访问的),用来保存myName变量。
  3. 接着继续扫描到getName方法时,发现该函数内部还引用变量test1,于是JavaScript引擎又将test1添加到“closure(foo)”对象中。这时候堆中的“closure(foo)”对象中就包含了myName和test1两个变量了。
  4. 由于test2并没有被内部函数引用,所以test2依然保存在调用栈中。

image.png

当执行到foo函数时,闭包就产生了;当foo函数执行结束之后,返回的getName和setName方法都引用“closure(foo)”对象,所以即使foo函数退出了,“ closure(foo)”依然被其内部的getName和setName方法引用。

产生闭包的核心有两步:

  1. 需要预扫描内部函数
  2. 把内部函数引用的外部变量保存到堆中

回收

如果引用闭包的函数是一个全局变量,那么闭包会一直存在直到页面关闭。 如果引用闭包的函数是个局部变量,等函数销毁后,在下次JavaScript引擎执行垃圾回收时,判断闭包这块内容如果已经不再被使用了,那么JavaScript引擎的垃圾回收器就会回收这块内存。