调用栈

之前我们讲到,当一段代码被执行时,JavaScript 引擎先会对其进行编译,并创建执行上下文。但是当时并没有明确说明到底什么样的代码才算符合规范。

接下来我们就来明确下,哪些情况下代码才算是 “一段” 代码,才会在执行之前就进行编译并创建上下文。一般来说,有这么三种情况:

  • 当 JavaScript 执行全局代码的时候,会编译全局代码并创建全局执行上下文,而且在整个页面的生存周期内,全局执行上下文只有一份。
  • 当调用一个函数的时候,函数体内部的代码会被编译,并创建函数执行上下文,一般情况下,函数执行结束之后,创建的函数执行上下文会被销毁。
  • 当使用 eval 函数的时候,eval 的代码也会被编译,并创建执行上下文。

本篇文章,我们就在此基础之上继续深入,一起聊聊调用栈。学习调用栈至少有以下三点好处:

  • 可以帮助你了解 JavaScript 引擎背后的工作原理;
  • 让你具备调试 JavaScript 代码的能力;
  • 帮助你搞定面试,面试中,调用栈也是出镜率非常高的题目。

比如在编写 JavaScript 代码的时候,有时候我们会遇到栈溢出的错误。这就涉及调用栈的内容。在 JavaScript 中有很多函数,经常会出现在一个函数中调用另外一个函数的情况,调用栈就是用来管理函数调用关系的一种数据结构。因此要搞清楚调用栈,还需要先弄明白 函数调用栈结构

什么是函数调用

函数调用就是运行一个函数。

var a = 2
function add() {
  var b = 10
  return a + b
}
add()
js

这段代码很简单,显示创建一个 add 函数,接着又调用该函数。

下面我们就利用这段简单代码来解释下函数调用的过程。

在执行到函数 add 之前,JavaScript 引擎会为这段代码创建全局执行上下文,包括声明的函数和变量。

global_context.png

从图中可以看出,代码中全局变量和函数都保存在全局上下文的变量环境中。

执行上下文准备好之后,便开始执行全局代码,当执行到 add 函数,JavaScript 判断这是一个函数调用,将执行以下操作:

  • 首先,从全局执行上下文中,取出 add 函数;
  • 其次,对 add 函数的这段代码进行编译,并创建该函数的执行上下文和可执行代码。
  • 最后,执行代码,输出结果。

function_context.png

当执行到 add 函数的时候,我们就有了两个执行上下文,全局执行上下文和 add 函数的执行上下文。

也就是说在执行 JavaScript 时,可能会存在多个执行上下文。JavaScript 引擎是通过一种栈数据结构来管理这些执行上下文。

什么是 JavaScript 的调用栈

JavaScript 引擎利用栈来管理执行上下文。在执行上下文创建好后,JavaScript 引擎会将执行上下文压入栈中,通常把这种用来管理执行上下文的栈成为执行上下文站,又称调用栈。

为了便于你更好地理解调用栈,我们来看一段稍微复杂的示例代码。

var a = 2
function add(b, c){
  return b + c
}
function addAll(b, c){
  var d = 10
  result = add(b, c)
  return  a + result + d
}
addAll(3, 6)
js

在上面这段代码中,你可以看到它是在 addAll 函数中调用了 add 函数,那在整个代码的执行过程中,调用栈是如何变化的呢?

下面我们就一步步地分析在代码的执行过程中,调用栈的状态变化情况。

call_stack01.png

从图中可以看出,变量 a、函数 add 和 addAll 都保存在全局上下文的变量环境对象中。

全局执行上下文压入到调用栈后,JavaScript 引擎便开始执行全局代码了。首先会执行 a = 2 的赋值操作,执行该数据会将全局上下文变量环境中 a 的值设置为 2。设置后的全局上下文状态如下图所示:

call_stack02.png

接下来,第二步是调用 addAll 函数。当调用该函数时,JavaScript 引擎编译该函数,并为其创建一个执行上下文,最后还将该函数的执行上下文压入栈中。

call_stack03.png

addAll 函数的执行上下文创建好之后,便进入函数代码的执行阶段,这里先执行的是 d = 10 的赋值操作,执行语句会将 addAll 函数执行上下文中的由 undefined 变成 10。然后接着往下执行。

第三步,当执行到 add 函数调用语句时,同样会为其创建执行上下文,并将其压入调用栈。

call_stack04.png

当 add 函数返回时,该函数的执行上下文就会从栈顶弹出,并将 result 的值设置为 add 函数的返回值,也就是 9 。

call_stack05.png

紧接着 addAll 执行最后一个相加操作后并返回,addAll 的执行上下文也会从栈顶弹出,此时调用栈中就只剩下全局上下文。

call_stack02.png

至此,整个 JavaScript 流程执行就结束了。

现在你应该知道调用栈是 JavaScript 引擎追踪函数执行的一个机制,当一次有多个函数被调用时,通过调用栈能够追踪到哪个函数正在被执行以及各函数之间的调用关系。

如何利用好调用栈

例如用浏览器查看栈信息

当执行一段复杂的代码时,你可能很难从代码文件中分析其调用关系,这时候你可以在你想要查看的函数中加入断点,当执行到该函数时,就可以查看该函数的调用栈了。

我们拿上面那段代码做演示,你可以打开 “开发者工具”,点击 “source” 标签,选择 JavaScript 代码的页面,然后在 add 函数中加上断点,并刷新页面。你可以看到在执行到 add 函数时,程序就暂停了,这时你可以通过右边 “call stack” 来查看当前的调用栈的情况。

call_stack06.png

从图中可以看出,右边的 “call stack” 下面显示出了函数的调用关系:栈的最底部是 anonymous,也就是全局的函数入口;中间是 addAll 函数;顶部是 add 函数。这就清晰地反映了函数的调用关系,所以在分析复杂结构代码,或者检查 Bug 时,调用栈都是非常有用的。

除了通过断点来查询调用栈,你还可以使用 console.trace() 来输出当前的函数调用关系,比如在示例代码中的 add 函数里添加 console.trace() ,你就可以看到控制台输出的结果。

call_stack07.png

call_stack08.png

栈溢出(Stack Overflow)

现在你已经知道调用栈是一种用来管理执行上下文的数据结构,符合先进后出的规则。不过还有一点需要注意,调用栈是有大小的,当入栈的执行上下文超过一定数目,JavaScript 引擎就会报错,我们把这种错误叫做栈溢出。

在写递归代码的时候,就很容易出现栈溢出的情况。

function division(a, b) {
  return division(a, b)
}
division(1, 2)
js

这段代码执行时,就会抛出栈溢出错误。

call_stack09.png

从上图你可以看到,抛出的错误信息为:超出最大栈调用大小(Maximun call stack size excedded)。

JavaScript 引擎开始执行这段代码时,它首先调用函数 division,并创建执行上下文,压入栈中。但是,这个函数是递归的,并且没有任何终止条件,所以它会一直创建新的函数执行上下文,并反复将其压入栈中,但栈是有容量限制的,超过最大数量后就会出现栈溢出的错误。

理解栈溢出原因后,就可以使用一些方法来避免或者解决栈溢出的问题,比如把递归调用的形式改造成其他形式,或者使用加入定时器的方法把当前任务拆分为其他很多小任务。

总结

  • 每调用一个函数,JavaScript 引擎都会为其创建执行上下文,并把该执行上下文压入调用栈,然后 JavaScript 引擎开始执行函数代码;
  • 如果在一个函数 A 中调用了另一个函数 B,那么 JavaScript 引擎会为 B 函数创建执行上下文,并将 B 函数的执行上下文压入栈顶;
  • 当前函数执行完毕后,JavaScript 引擎会将该函数的执行上下文弹出栈;
  • 当分配的调用栈空间被占满时,会引发 “堆栈溢出” 问题。

栈是一种非常重要的数据结构,不仅应用在 JavaScript 中。在其他的编程语言,例如 C/C++、Java、Python 等,在其执行过程中也都使用了栈来管理函数之间的调用关系。所以栈是非常基础且重要的知识点,我们必须要掌握。