前端进阶之旅前端进阶之旅
基础篇
进阶篇
高频篇
精选篇
手写篇
面经篇
AI 篇
原理篇
每日一题
小程序题库
知识卡片NEW
  • 历年面经按年份追踪真实考点
  • 算法题库NEW在线编码即时判题
  • 专项自测100 题快速查漏
  • 业务场景题真实业务问题与追问
  • 查漏补缺常见问题解析
  • AI 模拟面试NEW模拟真实面试 + 报告
  • 前端基础
    • HTTP从报文一路讲到 HTTPS
    • 浏览器渲染、事件循环、进程
    • 计算机基础Linux、网络、操作系统
  • 进阶专项
    • 设计模式23 种模式怎么用
    • 前端系统进阶学习大型项目工程化
    • 前端综合文章长期沉淀的实践文
  • 工程与工具
    • Node学习指南从环境搭建到服务端
    • NPM工作流script、依赖与发布
    • Docker容器化部署上手
    • Canvas图形与动画实战
  • 路线与导图
    • 思维导图知识点全景图
    • 学习路线按图索骥不跑偏
    • AI 定制路线NEW按你的简历现排
    • AI 知识地图NEW串起全站知识点
  • 动态
    • AI 热点NEWAI 每日动态
    • 公众号动态公众号历史文章
    • 博客动态站长的技术博客
    • 开发者导航常用工具与文档站
AI 助手NEW
旧版
基础篇
进阶篇
高频篇
精选篇
手写篇
面经篇
AI 篇
原理篇
每日一题
小程序题库
知识卡片NEW
  • 历年面经按年份追踪真实考点
  • 算法题库NEW在线编码即时判题
  • 专项自测100 题快速查漏
  • 业务场景题真实业务问题与追问
  • 查漏补缺常见问题解析
  • AI 模拟面试NEW模拟真实面试 + 报告
  • 前端基础
    • HTTP从报文一路讲到 HTTPS
    • 浏览器渲染、事件循环、进程
    • 计算机基础Linux、网络、操作系统
  • 进阶专项
    • 设计模式23 种模式怎么用
    • 前端系统进阶学习大型项目工程化
    • 前端综合文章长期沉淀的实践文
  • 工程与工具
    • Node学习指南从环境搭建到服务端
    • NPM工作流script、依赖与发布
    • Docker容器化部署上手
    • Canvas图形与动画实战
  • 路线与导图
    • 思维导图知识点全景图
    • 学习路线按图索骥不跑偏
    • AI 定制路线NEW按你的简历现排
    • AI 知识地图NEW串起全站知识点
  • 动态
    • AI 热点NEWAI 每日动态
    • 公众号动态公众号历史文章
    • 博客动态站长的技术博客
    • 开发者导航常用工具与文档站
AI 助手NEW
旧版
  • HTTP

    • 基础篇

      • HTTP的前世今生
      • HTTP是什么
      • HTTP世界全览
      • HTTP分层
      • 键入网址到回车发生什么
      • HTTP报文是什么样子的
      • 理解请求方法
      • URI
      • 响应状态码
      • HTTP有哪些特点
      • HTTP优缺点
      • HTTP的实体数据
      • HTTP传输大文件
      • HTTP的连接管理
      • HTTP的重定向
      • HTTP的Cookie机制
      • HTTP的缓存控制
      • HTTP的代理服务
      • HTTP的缓存代理
    • 高级篇

    • 扩展篇

  • 浏览器相关

  • 计算机基础

完整面试题地址:
作者:程序员poetry
扫码关注作者公众号:「前端进阶之旅」 每天分享技术干货
前端进阶之旅公众号二维码

调用栈:为什么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路由初始化阶段会嵌套触发多层工具函数,架构评审把执行上下文放进普通无序集合管理,你会接受这个选型吗?
参考回答

不应接受,因为多个尚未结束的函数调用存在明确的嵌套执行顺序,不能只记录“有哪些上下文”。调用者需要等待被调用者返回,后创建的上下文应先结束,因此引擎使用调用栈管理它们;无序集合无法可靠表达当前函数和返回路径。

# 什么是栈

← 变量提升:JavaScript代码是按顺序执行的吗块级作用域:var缺陷以及为什么要引入let和const →

fe
基础篇
进阶篇
高频篇
精选篇
手写篇
面经篇
AI 篇
原理篇
每日一题
小程序题库
知识卡片NEW
  • 历年面经按年份追踪真实考点
  • 算法题库NEW在线编码即时判题
  • 专项自测100 题快速查漏
  • 业务场景题真实业务问题与追问
  • 查漏补缺常见问题解析
  • AI 模拟面试NEW模拟真实面试 + 报告
  • 前端基础
    • HTTP从报文一路讲到 HTTPS
    • 浏览器渲染、事件循环、进程
    • 计算机基础Linux、网络、操作系统
  • 进阶专项
    • 设计模式23 种模式怎么用
    • 前端系统进阶学习大型项目工程化
    • 前端综合文章长期沉淀的实践文
  • 工程与工具
    • Node学习指南从环境搭建到服务端
    • NPM工作流script、依赖与发布
    • Docker容器化部署上手
    • Canvas图形与动画实战
  • 路线与导图
    • 思维导图知识点全景图
    • 学习路线按图索骥不跑偏
    • AI 定制路线NEW按你的简历现排
    • AI 知识地图NEW串起全站知识点
  • 动态
    • AI 热点NEWAI 每日动态
    • 公众号动态公众号历史文章
    • 博客动态站长的技术博客
    • 开发者导航常用工具与文档站
AI 助手NEW
旧版
  • HTTP

    • 基础篇

      • HTTP的前世今生
      • HTTP是什么
      • HTTP世界全览
      • HTTP分层
      • 键入网址到回车发生什么
      • HTTP报文是什么样子的
      • 理解请求方法
      • URI
      • 响应状态码
      • HTTP有哪些特点
      • HTTP优缺点
      • HTTP的实体数据
      • HTTP传输大文件
      • HTTP的连接管理
      • HTTP的重定向
      • HTTP的Cookie机制
      • HTTP的缓存控制
      • HTTP的代理服务
      • HTTP的缓存代理
    • 高级篇

    • 扩展篇

  • 浏览器相关

  • 计算机基础