Javascript执行机制(三)作用域链和闭包

上一篇关于作用域的文章中我们通过调用栈讲什么是作用域。

每当我们调用一个函数时就会在调用栈中为该函数创建执行上下文,到目前为止我们知道了该执行上下文中有变量环境,用于存储通过var声明的变量,也有一个词法环境,词法环境又是一个栈,依次放置了函数中通过let和const声明的在不同层级的块级作用域中的变量

基于上面的变量环境和词法环境,我们可以明白如何在一个函数的执行上下文中找到正确的变量。

那如果我们所需要的变量其实不在当前执行上下文,而是在上一级的执行上下文中呢?我们怎么确定上一级的执行上下文是哪个?

这个问题就涉及了我们今天的主题:作用域链。

理解作用域链是理解闭包的基础,而闭包在 JavaScript 中几乎无处不在,同时作用域和作用域链还是所有编程语言的基础。所以,如果你想学透一门语言,作用域和作用域链一定是绕不开的。

作用域链

首先我们来看下面这段代码:

1
2
3
4
5
6
7
8
9
function bar() {
console.log(myName)
}
function foo() {
var myName = "大树"
bar()
}
var myName = "bigTree"
foo()

你觉得这段代码中的 bar 函数和 foo 函数打印出来的内容是什么?

那么当这段代码执行到 bar 函数内部时,其调用栈的状态图如下所示:

函数调用栈

从图中可以看出,全局执行上下文和 foo 函数的执行上下文中都包含变量 myName,那 bar 函数里面 myName 的值到底该选择哪个呢?

也许你的第一反应是按照调用栈的顺序来查找变量,查找方式如下:

  1. 先查找栈顶是否存在 myName 变量,但是这里没有,所以接着往下查找 foo 函数中的变量。
  2. 在 foo 函数中查找到了 myName 变量,这时候就使用 foo 函数中的 myName。

如果按照这种方式来查找变量,那么最终执行 bar 函数打印出来的结果就应该是“大树”。但实际情况并非如此,如果你试着执行上述代码,你会发现打印出来的结果是“bigTree”。为什么会是这种情况呢?要解释清楚这个问题,那么你就需要先搞清楚作用域链了。

Outer指针

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

当一段代码使用了一个变量时,JavaScript 引擎首先会在“当前的执行上下文”中查找该变量,

比如上面那段代码在查找 myName 变量时,如果在当前的变量环境中没有查找到,那么 JavaScript 引擎会继续在 outer 所指向的执行上下文中查找。为了直观理解,你可以看下面这张图:

函数调用栈及outer指针

从图中可以看出,bar 函数和 foo 函数的 outer 都是指向全局上下文的,这也就意味着如果在 bar 函数或者 foo 函数中使用了外部变量,那么 JavaScript 引擎会去全局执行上下文中查找。

我们把这个查找的链条就称为作用域链。现在你知道变量是通过作用域链来查找的了,不过还有一个疑问没有解开,foo 函数调用的 bar 函数,那为什么 bar 函数的外部引用是全局执行上下文,而不是 foo 函数的执行上下文?

要回答这个问题,你还需要知道什么是词法作用域。这是因为在 JavaScript 执行过程中,其作用域链是由词法作用域决定的。

词法作用域就是指作用域是由代码中函数声明的位置来决定的,所以词法作用域是静态的作用域,通过它就能够预测代码在执行过程中如何查找标识符。

也就是说,词法作用域是代码编译阶段就决定好的,和函数是怎么调用的没有关系。

块级作用域中的变量查找

前面我们通过全局作用域和函数级作用域来分析了作用域链,那接下来我们再来看看块级作用域中变量是如何查找的?在编写代码的时候,如果你使用了一个在当前作用域中不存在的变量,这时 JavaScript 引擎就需要按照作用域链在其他作用域中查找该变量,如果你不了解该过程,那就会有很大概率写出不稳定的代码。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21

function bar() {
var myName = "bigTree2"
let test1 = 100
if (1) {
let myName = "Chrome浏览器"
console.log(test)
}
}
function foo() {
var myName = "大树"
let test = 2
{
let test = 3
bar()
}
}
var myName = "bigTree"
let myAge = 10
let test = 1
foo()

你可以自己先分析下这段代码的执行流程,看看能否分析出来执行结果。要想得出其执行结果,那接下来我们就得站在作用域链和词法环境的角度来分析下其执行过程。

ES6 是支持块级作用域的,当执行到代码块时,如果代码块中有 let 或者 const 声明的变量,那么变量就会存放到该函数的词法环境中。对于上面这段代码,当执行到 bar 函数内部的 if 语句块时,其调用栈的情况如下图所示:

函数作用域链查找顺序

闭包

首先说一下闭包是什么。

第一种说法,也是最本质的说法,闭包就是带有自己运行环境的一个函数,也就是说,这个函数有自己的执行上下文。

另外一种从使用上来说,一种通俗的,比较基于表象的说法来解释:首先它是闭合的,也就是说外界无法直接访问其中的变量,第二,它又是个包,所以它暴露出来某些方法可以让外界来操作它的内部数据。

这里你可以结合下面这段代码来理解什么是闭包:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20

function foo() {
var myName = "bigTree"
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.getName()
console.log(bar.getName())

首先我们看看当执行到 foo 函数内部的return innerBar这行代码时调用栈的情况,你可以参考下图:

https://res.cloudinary.com/dvtfhjxi4/image/upload/v1615207902/origin-of-ray/微信截图_20210308205133_amqnac.png

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

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

闭包原理

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

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

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

闭包的回收

理解什么是闭包之后,接下来我们再来简单聊聊闭包是什么时候销毁的。因为如果闭包使用不正确,会很容易造成内存泄漏的,关注闭包是如何回收的能让你正确地使用闭包。

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

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

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