调用栈(Call Stack)
JavaScript 引擎用于管理函数调用关系的数据结构
学习重点:
- 调用栈的概念和结构
- 函数调用的执行流程
- 栈溢出的原因和解决方案
- 调试技巧:查看调用栈信息
1. 调用栈概述
1.1 什么是调用栈
定义:调用栈(Call Stack)是一种用来管理函数调用关系的数据结构,遵循后进先出(LIFO, Last In First Out)的原则。
特点:
- 每调用一个函数,JavaScript 引擎会为其创建执行上下文,并压入调用栈
- 函数执行完毕后,执行上下文会从栈顶弹出
- 调用栈用于追踪函数执行状态和调用关系
类比:可以想象成一摞盘子,最后放上去的盘子会最先被取下来。
1.2 什么时候会创建执行上下文
JavaScript 引擎会在以下三种情况下创建执行上下文:
-
执行全局代码时
- 编译全局代码并创建全局执行上下文
- 在整个页面的生存周期内,全局执行上下文只有一份
-
调用一个函数时
- 函数体内的代码会被编译,并创建函数执行上下文
- 一般情况下,函数执行结束之后,创建的函数执行上下文会被销毁
-
使用 eval 函数时
- eval 的代码也会被编译,并创建执行上下文
- 不推荐使用 eval
2. 函数调用的执行流程
2.1 简单的函数调用
代码示例:
var a = 2
function add() {
var b = 10
return a + b
}
add()执行流程:
-
创建全局执行上下文
// 全局执行上下文 GlobalExecutionContext = { VariableEnvironment: { a: undefined, add: <function object> }, LexicalEnvironment: {...}, ThisBinding: window } -
执行全局代码
// 执行 a = 2 GlobalExecutionContext.VariableEnvironment.a = 2 -
调用 add 函数
- 从全局执行上下文中,取出 add 函数代码
- 对 add 函数的代码进行编译,并创建该函数的执行上下文
- 将 add 函数的执行上下文压入调用栈
-
执行 add 函数
// add 函数执行上下文 FunctionExecutionContext(add) = { VariableEnvironment: { b: undefined }, LexicalEnvironment: {...}, ThisBinding: undefined } // 执行 b = 10 FunctionExecutionContext.VariableEnvironment.b = 10 // 查找 a,通过作用域链在全局执行上下文中找到 a = 2 // 返回 a + b = 12 -
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)执行流程:
-
创建全局执行上下文并压入栈底
// 调用栈状态 调用栈: [ GlobalExecutionContext { VariableEnvironment: { a: 2, add: <function>, addAll: <function> } } ] -
调用 addAll 函数
- 创建 addAll 函数的执行上下文
- 将 addAll 函数的执行上下文压入调用栈
// 调用栈状态 调用栈: [ GlobalExecutionContext {...}, FunctionExecutionContext(addAll) { VariableEnvironment: { d: 10, result: undefined } } ] -
执行 addAll 函数中的代码
// d = 10 FunctionExecutionContext(addAll).VariableEnvironment.d = 10 -
调用 add 函数
- 创建 add 函数的执行上下文
- 将 add 函数的执行上下文压入调用栈
// 调用栈状态 调用栈: [ GlobalExecutionContext {...}, FunctionExecutionContext(addAll) {...}, FunctionExecutionContext(add) { VariableEnvironment: {}, // 参数 b = 3, c = 6 (存储在 Arguments 对象中) } ] -
执行 add 函数
- add 函数返回 9
- add 函数的执行上下文从栈顶弹出
// 调用栈状态 调用栈: [ GlobalExecutionContext {...}, FunctionExecutionContext(addAll) { VariableEnvironment: { d: 10, result: 9 // add 函数返回的结果 } } ] -
addAll 函数继续执行
// 返回 a + result + d = 2 + 9 + 10 = 21 // addAll 函数的执行上下文从栈顶弹出 -
最终调用栈状态
// 调用栈状态 调用栈: [ 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:使用断点调试
- 打开 Chrome DevTools
- 点击 “Source” 标签
- 在代码中设置断点
- 刷新页面,执行到断点处暂停
- 在右侧的 “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 核心要点
- 调用栈的作用:管理函数调用关系,追踪函数执行状态
- 调用栈的规则:后进先出(LIFO)
- 调用栈的限制:有大小限制,超过限制会栈溢出
- 调用栈的调试:可以通过 DevTools 和 console.trace() 查看
6.2 学习建议
- 理解调用栈的执行流程:通过简单的例子理解函数调用的执行过程
- 掌握调试技巧:学会使用 DevTools 查看调用栈
- 避免栈溢出:理解栈溢出的原因,学会优化递归代码
7. 参考资源
- 浏览器工作原理与实践 - 调用栈 — 李兵老师的系列文章
- MDN - Stack — 栈的概念
- Chrome DevTools - Call Stack — 调用栈调试