作用域链和闭包

371 阅读5分钟

前言

闭包在JavaScript中无处不在,但要想理解闭包,作用域链是绕不开的,甚至作用域和作用域链是所有编程语言的基础。

今天我们来聊聊什么是作用域链,在通过作用域链来聊聊什么是闭包

作用域链

理解作用域链需要我们先理解下执行上下文,在这篇文章【底层原理】聊聊执行上下文与执行栈中有关于执行上下文的详细介绍。

在每个执行上下文的变量环境中都有一个外部引用outer,用来指向外部的执行上下文。当我们使用了一个变量,我们会现在内部执行上下文中查找该变量,如果在当前的环境中没有找到,js引擎会在outer所指向的执行上下文中查找。这就是作用域链的实现

下面我们通过作用域链来解释这段代码,明明bar是在foo里面执行的,为什么打印的是外面而不是里面呢?

function bar() {
   console.log(myName)
}
function foo() {
   var myName = "里面"
   bar()
}
var myName = "外面"
foo()

作用域链outer.png

为了方便理解,我画了一张图,我们可以看到bar函数和foo函数的outer都指向全局执行上下文,这就意味着,当bar和foo使用了外部变量,那JavaScript引擎会去全局执行上下文中查找变量,这根查找的链条就是作用域链

我们可以看到作用域链是通过外部变量的引用outer实现的!

那为什么outer指向全局执行上下文呢?这是因为在 JavaScript 执行过程中,其作用域链是由词法作用域决定的。下面我们来讲讲词法作用域

词法作用域

执行上下文是在代码执行前的那一小段时间编译好的,但是作用域只和代码位置有关,是由代码中函数声明位置确定的,所以词法作用域是静态作用域,通过它能预测代码在执行过程中如何查找变量。

注意这里是和“声明”而不是“调用”的位置有关,所以才是静态的

我们通过下面这张图来理解

词法作用域.webp

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

闭包

接下来我们来讲讲js的一座大山——闭包

我们先上代码

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这行代码时调用栈的情况,你可以参考下图:

闭包1.png

从上面的代码可以看出,innerBar 是一个对象,包含了 getName 和 setName 的两个方法(通常我们把对象内部的函数称为方法)。你可以看到,这两个方法都是在 foo 函数内部定义的,并且这两个方法内部都使用了 myName 和 test1 两个变量。

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

闭包.png

从上图可以看出,foo 函数执行完成之后,其执行上下文从栈顶弹出了,但是由于返回的 setName 和 getName 方法中使用了 foo 函数内部的变量 myName 和 test1,所以这两个变量依然保存在内存中。这像极了 setName 和 getName 方法背的一个专属背包,无论在哪里调用了 setName 和 getName 方法,它们都会背着这个 foo 函数的专属背包

之所以是专属背包,是因为除了 setName 和 getName 函数之外,其他任何地方都是无法访问该背包的,我们就可以把这个背包称为 foo 函数的闭包。

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

闭包的回收

通常,如果引用闭包的函数是一个全局变量,那么闭包会一直存在直到页面关闭;但如果这个闭包以后不再使用的话,就会造成内存泄漏。

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

所以在使用闭包的时候,你要尽量注意一个原则:如果该闭包会一直使用,那么它可以作为全局变量而存在;但如果使用频率不高,而且占用内存又比较大的话,那就尽量让它成为一个局部变量。

总结

  1. 通过作用域链查找变量的链条叫做作用域链,它是通过词法作用域和执行上下文中保存外部变量的引用outer实现的

  2. 闭包的本质是一种只让内部函数访问的变量集合