调用栈(Call Stack)

JavaScript 引擎用于管理函数调用关系的数据结构

学习重点

  • 调用栈的概念和结构
  • 函数调用的执行流程
  • 栈溢出的原因和解决方案
  • 调试技巧:查看调用栈信息

1. 调用栈概述

1.1 什么是调用栈

定义:调用栈(Call Stack)是一种用来管理函数调用关系的数据结构,遵循后进先出(LIFO, Last In First Out)的原则。

特点

  • 每调用一个函数,JavaScript 引擎会为其创建执行上下文,并压入调用栈
  • 函数执行完毕后,执行上下文会从栈顶弹出
  • 调用栈用于追踪函数执行状态和调用关系

类比:可以想象成一摞盘子,最后放上去的盘子会最先被取下来。


1.2 什么时候会创建执行上下文

JavaScript 引擎会在以下三种情况下创建执行上下文:

  1. 执行全局代码时

    • 编译全局代码并创建全局执行上下文
    • 在整个页面的生存周期内,全局执行上下文只有一份
  2. 调用一个函数时

    • 函数体内的代码会被编译,并创建函数执行上下文
    • 一般情况下,函数执行结束之后,创建的函数执行上下文会被销毁
  3. 使用 eval 函数时

    • eval 的代码也会被编译,并创建执行上下文
    • 不推荐使用 eval

2. 函数调用的执行流程

2.1 简单的函数调用

代码示例

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

执行流程

  1. 创建全局执行上下文

    // 全局执行上下文
    GlobalExecutionContext = {
      VariableEnvironment: {
        a: undefined,
        add: <function object>
      },
      LexicalEnvironment: {...},
      ThisBinding: window
    }
  2. 执行全局代码

    // 执行 a = 2
    GlobalExecutionContext.VariableEnvironment.a = 2
  3. 调用 add 函数

    • 从全局执行上下文中,取出 add 函数代码
    • 对 add 函数的代码进行编译,并创建该函数的执行上下文
    • 将 add 函数的执行上下文压入调用栈
  4. 执行 add 函数

    // add 函数执行上下文
    FunctionExecutionContext(add) = {
      VariableEnvironment: {
        b: undefined
      },
      LexicalEnvironment: {...},
      ThisBinding: undefined
    }
     
    // 执行 b = 10
    FunctionExecutionContext.VariableEnvironment.b = 10
     
    // 查找 a,通过作用域链在全局执行上下文中找到 a = 2
    // 返回 a + b = 12
  5. add 函数执行完毕

    • add 函数的执行上下文从调用栈弹出
    • 调用栈中只剩下全局执行上下文

调用栈状态变化

[执行前]
调用栈: [全局执行上下文]

[执行 add 函数时]
调用栈: [全局执行上下文, add 函数执行上下文]

[add 函数执行完毕后]
调用栈: [全局执行上下文]

2.2 嵌套函数调用

代码示例

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)

执行流程

  1. 创建全局执行上下文并压入栈底

    // 调用栈状态
    调用栈: [
       GlobalExecutionContext {
         VariableEnvironment: {
           a: 2,
           add: <function>,
           addAll: <function>
         }
       }
    ]
  2. 调用 addAll 函数

    • 创建 addAll 函数的执行上下文
    • 将 addAll 函数的执行上下文压入调用栈
    // 调用栈状态
    调用栈: [
       GlobalExecutionContext {...},
       FunctionExecutionContext(addAll) {
         VariableEnvironment: {
           d: 10,
           result: undefined
         }
       }
    ]
  3. 执行 addAll 函数中的代码

    // d = 10
    FunctionExecutionContext(addAll).VariableEnvironment.d = 10
  4. 调用 add 函数

    • 创建 add 函数的执行上下文
    • 将 add 函数的执行上下文压入调用栈
    // 调用栈状态
    调用栈: [
       GlobalExecutionContext {...},
       FunctionExecutionContext(addAll) {...},
       FunctionExecutionContext(add) {
         VariableEnvironment: {},
         // 参数 b = 3, c = 6 (存储在 Arguments 对象中)
       }
    ]
  5. 执行 add 函数

    • add 函数返回 9
    • add 函数的执行上下文从栈顶弹出
    // 调用栈状态
    调用栈: [
       GlobalExecutionContext {...},
       FunctionExecutionContext(addAll) {
         VariableEnvironment: {
           d: 10,
           result: 9  // add 函数返回的结果
         }
       }
    ]
  6. addAll 函数继续执行

    // 返回 a + result + d = 2 + 9 + 10 = 21
    // addAll 函数的执行上下文从栈顶弹出
  7. 最终调用栈状态

    // 调用栈状态
    调用栈: [
       GlobalExecutionContext {...}
    ]

3. 栈溢出(Stack Overflow)

3.1 什么是栈溢出

定义:当调用栈中的执行上下文数量超过栈的最大容量时,JavaScript 引擎会抛出栈溢出错误。

错误信息

RangeError: Maximum call stack size exceeded

原因

  • 调用栈是有大小的,当入栈的执行上下文超过一定数目,JavaScript 引擎就会报错
  • 特别是在写递归代码的时候,很容易出现栈溢出的情况

3.2 栈溢出的示例

示例 1:无限递归

function division(a, b) {
  return division(a, b)
}
console.log(division(1, 2))
// RangeError: Maximum call stack size exceeded

原因分析

  • division 函数是递归的,并且没有任何终止条件
  • 它会一直创建新的函数执行上下文,并反复将其压入栈中
  • 栈是有容量限制的,超过最大数量后就会出现栈溢出的错误

示例 2:递归调用次数过多

function runStack(n) {
  if (n === 0) return 100
  return runStack(n - 2)
}
runStack(50000)
// RangeError: Maximum call stack size exceeded

原因分析

  • 虽然递归有终止条件,但调用栈的深度能达到 n(50000)
  • 当输入一个较大的数时,就会出现栈溢出的问题

3.3 如何避免栈溢出

方法 1:将递归改为循环

// 优化前:递归
function runStack(n) {
  if (n === 0) return 100
  return runStack(n - 2)
}
 
// 优化后:循环
function runStack(n) {
  while (true) {
    if (n === 0) return 100
    // 防止陷入死循环
    if (n === 1) return 200
    n = n - 2
  }
}
console.log(runStack(50000)) // 100

方法 2:使用尾递归优化

// 尾递归优化(需要引擎支持)
function runStack(n, acc = 100) {
  if (n === 0) return acc
  return runStack(n - 2, acc)
}

方法 3:使用定时器拆分任务

function runStack(n) {
  if (n === 0) return 100
  setTimeout(() => {
    return runStack(n - 2)
  }, 0)
}

4. 在开发中如何利用调用栈

4.1 使用浏览器查看调用栈信息

方法 1:使用断点调试

  1. 打开 Chrome DevTools
  2. 点击 “Source” 标签
  3. 在代码中设置断点
  4. 刷新页面,执行到断点处暂停
  5. 在右侧的 “Call Stack” 面板中查看调用栈

示例

function add(b, c) {
  debugger // 设置断点
  return b + c
}
function addAll(b, c) {
  var d = 10
  result = add(b, c)
  return a + result + d
}
addAll(3, 6)

Call Stack 显示

add (当前执行位置)
addAll
(anonymous) // 全局代码入口

4.2 使用 console.trace() 输出调用栈

代码示例

function add(b, c) {
  console.trace() // 输出调用栈
  return b + c
}
function addAll(b, c) {
  var d = 10
  result = add(b, c)
  return a + result + d
}
addAll(3, 6)

控制台输出

console.trace
    at add (<anonymous>:2:11)
    at addAll (<anonymous>:6:11)
    at <anonymous>:9:1

4.3 调用栈的调试技巧

技巧 1:定位错误位置

  • 当代码抛出错误时,查看调用栈可以快速定位错误发生的位置
  • 调用栈会显示从全局代码到错误发生位置的完整调用路径

技巧 2:理解代码执行流程

  • 通过调用栈可以清晰地看到函数的调用关系
  • 有助于理解复杂代码的执行流程

技巧 3:性能分析

  • 调用栈信息可以帮助分析性能瓶颈
  • 在 Performance 面板中可以看到调用栈信息

5. 调用栈与其他概念的关系

5.1 调用栈与执行上下文

  • 调用栈是执行上下文的容器:调用栈用来管理执行上下文
  • 执行上下文是调用栈的元素:每个执行上下文都存储在调用栈中

5.2 调用栈与作用域链

  • 作用域链在执行上下文中:每个执行上下文都有自己的作用域链
  • 调用栈不影响作用域链:作用域链由词法作用域决定,而不是调用栈

5.3 调用栈与闭包

  • 闭包与调用栈无关:闭包是词法作用域的结果,不是调用栈的结果
  • 调用栈销毁不影响闭包:函数执行完毕后,执行上下文从调用栈弹出,但闭包仍然可以访问外部变量

6. 总结

6.1 核心要点

  1. 调用栈的作用:管理函数调用关系,追踪函数执行状态
  2. 调用栈的规则:后进先出(LIFO)
  3. 调用栈的限制:有大小限制,超过限制会栈溢出
  4. 调用栈的调试:可以通过 DevTools 和 console.trace() 查看

6.2 学习建议

  1. 理解调用栈的执行流程:通过简单的例子理解函数调用的执行过程
  2. 掌握调试技巧:学会使用 DevTools 查看调用栈
  3. 避免栈溢出:理解栈溢出的原因,学会优化递归代码

7. 参考资源


javascript 调用栈 执行上下文 栈溢出 调试技巧