调用栈:为什么JavaScript代码会出现栈溢出|浏览器篇
# 调用栈:为什么JavaScript代码会出现栈溢出
30 秒速记
- 执行全局代码时,引擎会编译代码并建立全局执行上下文;在单个页面生命周期内,原文认为它只有一份。
- 每次调用函数,函数体都会对应一次编译与函数执行上下文创建;通常函数结束后,该上下文会被销毁。
- 调用
eval时,其中的代码也会经过编译并形成执行上下文。 - 调用栈是管理函数之间调用关系的数据结构,函数嵌套调用会让多个执行上下文形成待处理的调用链。
- 栈溢出与调用关系持续累积有关;原文此处只引出问题,尚未给出具体上限、错误类型或完整触发过程。
栈溢出本质上是未完成的同步函数调用持续累积,超过了当前引擎允许的调用栈深度。 执行全局代码、调用函数或运行 eval 时,引擎会建立相应的执行上下文;函数调用对应的栈帧按后进先出管理,返回后才会弹出。没有终止条件的递归会让每一层都等待下一层返回,最终通常抛出 RangeError。最大深度没有可靠的固定值,排查时应从错误堆栈寻找重复帧并检查终止条件;规模不受控时,可改用循环或显式数组模拟栈。
在上篇文章中,我们讲到了,当一段代码被执行时,JavaScript引擎先会对其进行编译,并创建执行上下文。但是并没有明确说明到底什么样的代码才算符合规范
那么接下来我们就来明确下,哪些情况下代码才算是“一段”代码,才会在执行之前就进行编译并创建执行上下文。一般说来,有这么三种情况
- 当JavaScript执行全局代码的时候,会编译全局代码并创建全局执行上下文,而且在整个页面的生存周期内,全局执行上下文只有一份。
- 当调用一个函数的时候,函数体内的代码会被编译,并创建函数执行上下文,一般情况下,函数执行结束之后,创建的函数执行上下文会被销毁。
- 当使用eval函数的时候,eval的代码也会被编译,并创建执行上下文。
好了,又进一步理解了执行上下文,那本节我们就在这基础之上继续深入,一起聊聊调用栈。学习调用栈至少有以下三点好处:
- 可以帮助你了解JavaScript引擎背后的工作原理;
- 让你有调试JavaScript代码的能力;
- 帮助你搞定面试,因为面试过程中,调用栈也是出境率非常高的题目
比如你在写JavaScript代码的时候,有时候可能会遇到栈溢出的错误,如下图所示:

那为什么会出现这种错误呢?这就涉及到了调用栈的内容。你应该知道JavaScript中有很多函数,经常会出现在一个函数中调用另外一个函数的情况,调用栈就是用来管理函数调用关系的一种数据结构。因此要讲清楚调用栈,你还要先弄明白函数调用和栈结构
原理拆解: 执行经典脚本时,引擎先建立全局执行上下文;每次普通函数调用又会创建对应的函数执行上下文,并把代表该次调用的栈帧压入调用栈。函数返回后,栈帧按后进先出的顺序弹出,调用者才继续执行。eval 执行的字符串也要被解析、编译并在相应执行环境中运行。调用栈管理的是尚未完成的同步调用关系,不是所有已经调用过的函数历史。
最小验证: 输入以下代码,要验证正常嵌套调用的入栈与出栈顺序,以及无终止条件的递归会持续累积栈帧。
function third() {
console.trace("stack in third");
return 3;
}
function second() {
return third() + 2;
}
function first() {
return second() + 1;
}
console.log(first());
function recurse(depth) {
return recurse(depth + 1);
}
try {
recurse(0);
} catch (error) {
console.log(error.name);
console.log(error instanceof RangeError);
}
console.trace 展示的调用路径应包含 third、second、first,随后 console.log 输出 6,说明返回值沿相反方向逐层传回。recurse 没有基线条件,每次调用都必须等待下一层返回,栈帧无法弹出;达到当前引擎允许的深度后,通常会捕获到 RangeError,第二项输出 true。
边界与排查: 最大栈深度属于引擎和运行环境的实现限制,不能依赖某个固定数字;不同浏览器版本、启动参数和函数体复杂度都可能改变触发位置。异步回调通常会在后续任务中重新进入调用栈,不会像同步递归一样保留整条调用链。工程排查可结合错误堆栈寻找重复帧,并检查递归终止条件是否可达;数据规模不受控时,可改用显式数组模拟栈或用循环完成遍历。
面试官追问
追问 1监控记录了数万次历史函数调用,同事据此认定调用栈会永久保存所有调用过的函数,所以迟早溢出,你如何反驳?
调用栈管理的是尚未完成的同步调用关系,并不保存全部调用历史。函数被调用时相应栈帧入栈,返回后按后进先出顺序弹出;只有调用链持续增长且旧栈帧无法退出时,才会逼近环境允许的栈深度。
追问 2你在调试支付页的 first → second → third 同步调用链,需要确认入栈路径和返回顺序,会怎样做最小验证?
可在最深层 third 使用 console.trace,确认堆栈路径包含 third、second、first。再让各层返回并观察最终计算结果,验证返回值沿相反方向逐层传回;开发者工具展示形式可能不同,但后进先出的关系不变。
追问 3树形数据规模来自用户输入,团队在递归遍历和显式数组加循环之间争论,你会基于什么风险做选择?
当树深不可控时,应优先考虑显式数组模拟栈或循环,避免同步递归持续积累调用帧并触发栈溢出。最大栈深度由引擎、环境和函数体等因素共同决定,不能用某个固定层数作为安全承诺;迭代方案的代价是状态管理代码更显式。
追问 4线上捕获到 RangeError,错误堆栈里同一函数名反复出现,但本地偶尔无法复现,你会怎样排查?
应先检查递归基线条件是否存在、是否可达,以及参数是否确实朝终止方向变化,再寻找两个函数互相调用形成的环。触发深度会随引擎、版本、启动条件和函数体复杂度变化,因此本地未复现不能排除风险;也不能仅凭 RangeError 名称断定唯一根因。
追问 5有人建议把无限同步递归改成异步回调,理由是异步代码绝不会发生调用栈问题,你会接受吗?
异步回调通常在后续任务中重新进入调用栈,不会像同步递归那样保留整条未完成调用链,因此能改变栈帧累积方式。它并不自动修复缺失的终止条件,还可能造成任务持续排队等新风险;应先保证流程可终止,再决定采用异步分段还是显式迭代。
追问 6性能评审中有人说全局代码、函数体和 eval 共用一个贯穿页面生命周期的执行上下文,你如何纠正?
浏览器经典脚本会创建一份贯穿页面生命周期的全局执行上下文,但每次函数调用还会创建对应的函数执行上下文,并在一般情况下随函数结束退出。eval 中的字符串也需要解析、编译并在相应执行环境中运行;不能把这些上下文混成一个永不销毁的对象。
# 什么是函数调用
30 秒速记
- 函数调用是通过函数名加括号触发函数体执行,例如
add()。 - 调用发生前,全局代码已经拥有对应的全局执行上下文,其中记录全局变量和函数声明。
- 引擎执行到调用表达式后,会找到目标函数代码,为本次调用创建独立的函数执行上下文并运行函数体。
- 函数调用会让全局执行上下文与函数执行上下文同时存在;出现嵌套调用时,还会继续产生新的执行上下文。
- 多个执行上下文需要按调用与返回次序统一管理,这正是调用栈承担的职责。
函数调用就是通过函数名和括号触发函数执行,例如 add()。 引擎执行到调用表达式时,会找到对应的函数代码,为这次调用创建独立的函数执行上下文,然后运行函数体。此时全局执行上下文并不会消失,函数里如果继续调用其他函数,还会产生更多执行上下文。因为这些上下文必须按照调用和返回的顺序管理,所以引擎需要使用调用栈。
函数调用就是运行一个函数,具体使用方式是使用函数名称跟着一对小括号。下面我们看个简单的示例代码
var a = 2
function add(){
var b = 10
return a+b
}
add()
这段代码很简单,先是创建了一个add函数,接着在代码的最下面又调用了该函数。
那么下面我们就利用这段简单的代码来解释下函数调用的过程。
在执行到函数add()之前,JavaScript引擎会为上面这段代码创建全局执行上下文,包含了声明的函数和变量,你可以参考下图:

从图中可以看出,代码中全局变量和函数都保存在全局上下文的变量环境中。
执行上下文准备好之后,便开始执行全局代码,当执行到add这儿时,JavaScript判断这是一个函数调用,那么将执行以下操作:
- 首先,从全局执行上下文中,取出add函数代码。
- 其次,对add函数的这段代码进行编译,并创建该函数的执行上下文和可执行代码。
- 最后,执行代码,输出结果。
完整流程你可以参考下图:

就这样,当执行到add函数的时候,我们就有了两个执行上下文了——全局执行上下文和add函数的执行上下文。
也就是说在执行JavaScript时,可能会存在多个执行上下文,那么JavaScript引擎是如何管理这些执行上下文的呢?
答案是通过一种叫栈的数据结构来管理的。那什么是栈呢?它又是如何管理这些执行上下文呢?
面试官追问
追问 1结算页执行 add() 时读取了全局变量 a,同事认为函数体里没有声明 a,所以调用期间只存在全局执行上下文,这个判断哪里不对?
调用 add() 后,全局执行上下文和本次函数执行上下文会同时存在,并非只剩全局环境。a 和 add 保存在全局上下文中,而函数本次执行所需的状态属于新建的函数上下文;把两者混为一谈,会误判变量来源和故障所在层级。
追问 2订单页先声明 add,但用户始终没有触发 add(),监控却把“函数已创建”记成“函数已执行”,你会如何纠正这套埋点语义?
声明函数只是在全局执行上下文中保存函数,不能代表函数体已经运行。埋点应放在实际调用入口或函数体执行路径上;只有执行到 add(),引擎才会取出函数代码、准备其执行上下文并执行,未触发的页面不能计为调用。
追问 3批量计价代码在 addAll() 中连续调用两次 add(),评审者想让两次调用共用一个函数执行上下文以减少开销,这种模型成立吗?
这种模型不成立,每次实际调用都对应一次独立的函数执行过程和执行上下文。即使函数代码相同,两次调用的参数和执行状态也可能不同;强行按共享上下文理解,会掩盖调用之间的隔离关系,源码也没有支持复用同一上下文的结论。
追问 4线上只在 add() 内出现错误,但全局变量 a 已确认赋值为 2,排查时为什么仍要分别检查全局上下文和函数上下文?
应分别确认全局环境中的 a、add 是否符合预期,以及本次 add() 调用的局部状态和执行路径是否正确。函数调用会新增执行上下文,而不会替代全局上下文;只验证 a=2 不能证明函数本次调用的全部状态都正常。
追问 5路由初始化阶段会嵌套触发多层工具函数,架构评审把执行上下文放进普通无序集合管理,你会接受这个选型吗?
不应接受,因为多个尚未结束的函数调用存在明确的嵌套执行顺序,不能只记录“有哪些上下文”。调用者需要等待被调用者返回,后创建的上下文应先结束,因此引擎使用调用栈管理它们;无序集合无法可靠表达当前函数和返回路径。
