深入理解JavaScript中的执行堆栈

2,145 阅读11分钟

1. 前言

最近读了一些资料,对 JS 中的执行堆栈有了新的体会,有的是来自一些书籍(红宝书、《你不知道的JS》),有的是一些优秀的博客和教程,脑子里装了很多东西,但是平常开发无法直观地去应用这些知识,所以想了想还是写一篇博客来记录一下吧,下面一起来探索其中的奥妙吧~

2. JS中的代码执行流程

我先提出一个问题:JS 代码到底是如何执行的呢?

我们都知道 JS 是单线程的,但严格来说负责执行 JS 代码的程序是单线程的,这个程序就是 JS 引擎,比如我们熟知的 Google V8 引擎。 JS 代码的执行需要环境提供内存空间、依赖的全局变量、Event Loop系统,而这个是由 JS 引擎所在的宿主环境提供的,这个环境可以是浏览器或者 Node.js下面的讨论基于浏览器中的V8环境)。

当我们写完代码后,代码的表现形式还是字符串(相对于代码本身来说),接下来会大体经过下面阶段:

  • 词法分析:将代码字符串分析成词法单元(token),你可以理解为英文单词中的词性(名词、动词、介词...)
  • 语法分析:将词法单元流解析成 AST (抽象语法树),这个过程包括词法作用域生成、变量提升等阶段。
  • 代码生成:根据上面生成的 AST 转换成字节码,这部分由 V8 中的 Ignition 解释器来生成的。
  • 执行代码:逐条解释执行字节码,当 V8 发现有大量重复字节码时(热点代码 HotSpot ),会将其编译成机器码(由引擎中 TurboFan 编译器进行编译),下次再碰到类似字节码不需要解释,直接执行,这种与解释器配合的过程也称为 JIT (即时编译)。

而从底层去追溯,那就是 CPU 如何执行计算机指令的问题了,这里不在本文的范畴了。上面过程用一张图表示:

image.png

有了上面的知识的铺垫后,我们可以知道 JS 的执行大体上分为两个阶段:

  • 编译阶段:变量提升、确定词法作用域。
  • 运行阶段:创建执行上下文、作用域链等。

图中右半部分不用看清楚,只需要了解长什么样子就可以了。

下面将从编译阶段与运行阶段来对本文主题进行展开说明。

2.1 可执行环境

引擎是怎么知道要执行代码的呢?这里就要提到 JS 中可执行的环境了,主要是三类:

  • 全局执行环境
  • 函数执行环境
  • eval执行环境

本文暂不考虑 eval 所在的执行环境。

我们先来说说全局执行环境,当你打开一个 tab 页面后,浏览器就会单独分配一个渲染进程(暂时不考虑 iframe ,以及同域 tab),它提供了 JS 引擎需要的一切(内存,全局变量),其中就包括了全局执行环境,也就是说当你打开 tab 页面,JS 引擎就立马开始工作了,它会执行 script 标签里面的代码。

当遇到函数调用时,会创建一个新的执行环境,这个环境就是函数执行环境,也可以叫执行上下文。而 JS 为了管理函数的调用关系,申请了一个固定内存(不同浏览器大小有差异),这个内存区域就是执行环境栈,它来管理当前渲染进程中所有的函数调用关系。当 tab 页面打开时,就已经创建了一个全局执行上下文,并且放到了栈的底部。

讲到这里我们先捋一捋,上面引入了一些概念:

  • 执行环境(Execution Environment):JS 代码可执行的一个环境。
  • 执行环境栈 (Execution Stack Context):一块内存区域,用于管理函数的调用关系。
  • 执行上下文(Execution Context):当前代码所在区块的上下文,保存了当前代码需要的数据(比如this、变量)。

我们看一个简单的例子:

var a = 1
function bar() {
    console.log(a)
}
function foo() {
    bar()
}
foo()

上面这段代码展现的形式如下:

image.png

当然浏览器也是可以清楚看到调用关系的:

image.png

2.2 变量对象

现在有一个新的疑问:bar 函数的 a 变量是如何找到它的值?log 我们没有定义呀,它又在哪儿?

这里我引用红宝书中的一段话:

变量或函数的上下文决定了它们可以访问哪些数据,以及它们的行为。每个上下文都有一个关联的变量对象(variable object),而这个上下文中定义的所有变量和函数都存在于这个对象上。

这个变量对象就存在于执行上下文中,而全局执行上下文比较特殊,它还有一个全局对象(global object),它里面包括了能提供给 JS 引擎的各种 API:DOM、BOM、定时器...但是我们是不能直接访问的,必须通过一个引用关系来访问,这个就是我们熟悉的 window对象 ,而为了方便我们使用,我们可以在使用这些 API 的时候省略 window这个关键字。

说完了全局执行上下文中的变量对象后,我们再来聊聊函数执行上下文。它内部也有一个变量对象,但它的名字比较特殊,函数中的变量对象被称为活动对象(activation object)。我就纳闷了,两个对象作用都是一样的,凭啥还要搞“特殊化”?后来我仔细想了想,函数只有调用的时候,函数执行上下文才会被激活,被激活这个过程里面的各种变量就“活跃”了,如果函数不掉用里面的代码对于解释器来说就是普通的字符串,所以叫活动对象也挺好。

讲到这里,我们再总结一下:

执行上下文中会存在一个叫变量对象的家伙,在全局执行上下文中叫 variable object,而且还多了一个叫 global object,而在函数执行上下文中被叫做 activation object,它们里面都存放了代码执行需要用到的各种数据。

到此为止,我们接触了很多名词,为了后面作图方便,这里对相关术语进行缩写:

  • AO:activation object。
  • GO:global object。
  • VO:variable object。
  • ESC:执行环境栈。
  • EC:执行环境。

那什么时候变量会存到变量对象里头呢?这里就要说到变量提升了。

2.3 变量提升

我们再来看一个例子:

console.log(a)

function a() {
    console.log('a')
}
var a = 1
function a() {
    console.log('b')
}

上面代码会打印最后一个函数 a 的字符串形式,这是因为代码在 AST 生成阶段会进行变量提升,并且把这些变量存放在 VO/AO 中,初始值为 undefined。如果 VO/AO 中已经存在该变量声明,则跳过;如果是function 声明的同名变量,则覆盖。

能提升的变量有下面几种方式:

  • function 声明的变量。
  • var 声明的变量。
  • import 声明的变量(暂时不考虑)。

上面整段代码执行之前会先扫描上面提到的关键字(这里是 functionvar ),如果有会提升到 VO 中,下面是解析整体流程:

  • 遇到函数声明 function a ,放入 VO ,并且让其初始值为 undefined
  • 遇到 var a ,准备放入 VO ,结果发现 VO 已经存在同名变量,跳过。
  • 再次遇到 function a,准备放入 VO ,结果发现 VO 已经存在同名变量,覆盖。

从这里可以看出,function 声明的变量很有“分量”,同一个变量使用 function 声明可以覆盖已经存在的同名变量,说明 function 声明的优先级比 var 声明的要高。

还有一点要注意的是,变量的提升只会提升到当前词法作用域的顶端。咦,啥是词法作用域 ?

2.4 词法作用域

我们开头有说过,代码在生成 AST 的时候就会确定词法作用域,也就说我们代码还没有执行它的词法作用域就已经确定好了,通俗来说词法作用域就是变量能被访问的一块区域,它决定着变量的生命周期。我们再来看一段代码:

function baz() {
    console.log(a)
}

function foo() {
    var a = 2
    function bar() {
        var a = 3
        console.log(a)
        baz()
    }
    bar()
}

var a = 1

foo()

大家记住一句话就好:函数的词法作用域跟它的定义的位置有关系。上面代码的执行栈为:

  • foo函数被调用,创建一个新的执行上下文,入栈。
  • bar函数被调用,创建一个新的执行上下文,入栈。
  • baz函数被调用,创建一个新的执行上下文,入栈。

baz 里面访问 a 变量时它是如何知道去哪个词法作用域去查找 ?

这里需要介绍一个特殊的机制:函数在创建 AO 的时候会存放它上一级词法作用域的引用,当 baz 执行上下文中找不到 a 时,会通过这个引用往上一层的词法作用域查找,而它上一层的词法作用域是全局作用域,所以它最终打印的是 1,这个过程也是作用域链的机制,而查找 a 的值的过程也是 RHS 查询规则。

作用域是一套规则,用于确定在何处以及如何查找变量(标识符)。如果查找的目的是对变量进行赋值,那么就会使用 LHS 查询;如果目的是获取变量的值,就会使用 RHS 查询。 --- 《你不知道的JavaScript》(上)。

3. 案例分析

我们上小节从代码的执行阶段开始,往编译阶段进行追溯, 了解了 JS 在不同阶段会有不同的处理。接下来我们会结合一些案例来对本文所有涉及到的知识点进行串讲。

3.1 案例一

var a = {
    n: 1,
}

var b = a

a.x = a = {
    n: 2,
}

console.log(a.x)
console.log(b)

这是一道很经典的题,我们先分析一下。首先在编译期间:

  • 遇到 var 进行变量提升。
  • 确定全局作用域。
  • 将外部(上下文)引用保存在 outer (这里就是全局)。

执行代码的时候:

  • 动态分配堆区内存
  • 确定执行上下文

用图来展示过程就是:

image.png

3.2 案例二

var num = 100
function fn() {
    var num = 200
    return function (a) {
        console.log(a + num++)
    }
}

var foo = fn()
foo(10)
foo(20)

还是像上题分析:

  • 变量提升:num、fn、foo 提升到 VO,初始值 undefined
  • 函数也是一个特殊的对象,所以也会在堆内存申请一块空间。
  • 函数调用时确认函数上下文(thisAO...),作用域链、形参赋值、初始化 arguments

调用 fn 函数时,创建一个函数执行上下文,入栈,然后将返回的函数引用值存储在 foo 变量中,但是当 fn 调用出栈时,发现内部函数引用了 AO 中的 num 变量,导致这块地址无法被回收,于是就形成了闭包。

image.png

然后再调用 foo 函数,创建一个新的上下文,设置实参为 10 ,然后利用 RHS 查询找到闭包里的 num ,于是最后打印的是 210,最后让闭包里面的 num 自增为 201(注意自增的方向,放前面先自增后运算,反之后自增)。后面再次调用 foo 时,处理过程是同样的,这里就不分析了。

那闭包如何销毁?

这个就要分析闭包所在的内存空间被引用的位置了,这里是存在了 foo 变量中,而这个变量在全局上下文,所以只能等到页面关闭的时候闭包才可以释放(或者通过设置 foo=null 进行手动释放),而如果 foo 是局部变量时,那函数销毁时就会等待垃圾回收器回收。

上面提到的手动释放是立马释放内存,而是让它变成无引用状态,等垃圾回收器回收,这点跟 C++/C 语言中的释放内存不是一个概念。

4. 总结

本文主要对 JS 执行过程中涉及到一些如执行上下文、变量对象、变量提升等特性进行了讲解,最后用了两个案例来对上面过程进行一个概述。虽然都是一些概念性知识,但是理解了这些概念对我们理解 JS 语言有很大的帮助。最后,本文难免有一些概念没有阐述有误情况,请诸位进行批评指正,您的建议也是对我的肯定,多谢观看~

5. 参考