K 的一隅

JavaScript JavaScript 核心机制

一段 JavaScript 是怎么跑起来的:执行上下文与调用栈

从后厨接单的顺序出发,弄清函数调用、执行上下文、调用栈、递归与闭包究竟保存了什么。

10 分钟阅读 更新于 2026-07-24
本文目录

有些 JavaScript 问题看起来属于不同领域:为什么函数会按这个顺序输出?为什么报错信息里列着一串函数名?为什么递归太深会崩,而闭包过了很久还能读到外层变量?

它们背后其实是同一个问题:此刻正在执行哪段代码,做完以后又该回到哪里。

后厨一次只能沿着一张单子往下做

想象一家只有一位厨师的小餐馆。前台送来一张“番茄鸡蛋面”的单子,厨师先开始做面;做到一半发现要先炒浇头,于是把“面做到这里”记下,转去炒菜;炒菜又需要先打蛋,就再记住当前位置,去处理鸡蛋。

打完蛋以后,厨师不会重新研究今天该做哪张单,而是回到刚才炒菜停下的位置。浇头完成,再回到煮面的步骤。

这和同步函数调用很像。一个函数调用另一个函数时,调用者暂时停住;被调用者返回后,调用者从调用点后面继续。JavaScript 必须保存这些“正在做什么”和“做完回哪里”的信息。

先别运行,猜猜四行日志的顺序

先凭直觉读下面的代码:

js
function prepareEgg() {
  console.log('3. 打好鸡蛋')
}

function cookTopping() {
  console.log('2. 开始炒浇头')
  prepareEgg()
  console.log('4. 浇头完成')
}

function cookNoodles() {
  console.log('1. 开始煮面')
  cookTopping()
  console.log('5. 面条出锅')
}

cookNoodles()
console.log('6. 前台取餐')

输出是 1、2、3、4、5、6。容易忽略的是,cookTopping() 执行时,cookNoodles() 并没有消失。它只是停在调用处,等子调用返回。prepareEgg() 返回到 cookTopping(),而不是直接跳回全局代码,因为后者才是它的直接调用者。

把这一连串关系弄清,已经抓住了调用栈最有用的部分。

下面用序列图概括「谁调用谁、谁先返回」——和 DevTools Call Stack 从上往下读的习惯一致(最上面是最近进入的函数):

每次函数调用都带来一份执行现场

ECMAScript 规范用“执行上下文”描述代码求值所需的状态。全局代码开始执行时有全局执行上下文;调用普通函数时,会建立该函数的执行上下文。它关联当前运行的代码、词法环境、变量环境、函数的 this 绑定等信息。

可以把它理解成一份工作现场记录,但不要把它误解成一个能在代码中打印出来的普通对象。下面这样的伪结构只是帮助观察:

js
{
  currentFunction: cookTopping,
  lexicalEnvironment: '本次调用能访问的绑定',
  returnTo: 'cookNoodles 中调用语句的下一步'
}

真正的执行上下文是规范用来定义语义的内部概念。不同引擎可以用寄存器、栈帧或优化后的其他结构保存相关信息。我们关心的是可观察结果,而不是假装看见了引擎内部唯一的内存布局。

函数每调用一次,就有一次新的执行现场。即便调用的是同一个函数,两次调用的参数和局部变量也彼此独立:

js
function makeDish(name) {
  const label = `正在制作:${name}`
  console.log(label)
}

makeDish('面')
makeDish('饭')

这里不是反复擦写同一个 name。每次调用都有自己的绑定。

执行上下文还解释了一个看似奇怪的现象:函数声明可以在源码位置之前调用,而 let 变量在声明行之前访问却会报错。

js
serve()

function serve() {
  console.log('上菜')
}

console.log(price) // ReferenceError
let price = 18

开始真正逐句求值前,当前代码的声明会按各自规则建立绑定。函数声明的绑定已经关联函数对象;let 的绑定也已存在,却在执行声明前保持未初始化状态,这段区域就是常说的暂时性死区。它们不是因为引擎把某几行源码粗暴“移动到顶部”。

var 又有不同表现:绑定在进入相应上下文时初始化为 undefined,赋值仍发生在原来的语句位置。用“提升”记现象很方便,但追到执行上下文和环境记录,才不会误以为源码真的被重排。

参数也属于本次调用的环境。普通函数的 this 由调用方式等规则决定,不是简单地从定义位置捕获;箭头函数没有自己的 this 绑定,会从外层词法环境取得。这些规则细节不同,共同点是:函数体求值不能脱离本次调用的执行现场。

调用栈记录的是“做完以后回到哪里”

为了描述当前活跃的执行上下文,我们常用“调用栈”这张图。开始执行全局代码时,全局上下文在底部;调用 cookNoodles,把它放到上面;再调用 cookTopping,继续压入;函数返回时,从顶部移除。

prepareEgg() 正在执行的那个瞬间,可以画成:

text
prepareEgg
cookTopping
cookNoodles
global

它是后进先出:最后进入的函数最先完成。这个顺序并不是 JavaScript 特有的口诀,而是嵌套函数调用自然需要的返回关系。若 prepareEgg 没结束,cookTopping 就还拿不到调用结果;后者没结束,cookNoodles 也不能继续。

调试器里的“Call Stack”面板很实用,因为它把这条路径直接展示出来。遇到一个值突然变错时,不只看当前函数,还可以沿着调用者往下找:谁传进了这个参数,谁又触发了调用者。

返回值也沿这条路径交还。执行 return total 时,当前函数结束求值,调用表达式的结果成为返回值,然后调用者继续:

js
function subtotal(price, count) {
  return price * count
}

function checkout() {
  const amount = subtotal(12, 2)
  return `应付 ${amount} 元`
}

subtotal 返回的不是一条“去 checkout”的命令。是 checkout 的执行现场早已记录了等待中的调用表达式,返回结果填回那里。若函数运行到末尾没有显式 return,调用结果是 undefined,返回关系本身并没有消失。

遇到 throw 时,正常返回路径被中断。运行时沿着活跃调用寻找能够处理该异常的 try...catch;找不到就继续向外传播,最后由宿主报告未捕获异常。

异常堆栈为什么能指出调用路径

假设打蛋步骤发现库存为零:

js
function prepareEgg(stock) {
  if (stock === 0) {
    throw new Error('鸡蛋用完了')
  }
}

function cookTopping() {
  prepareEgg(0)
}

function cookNoodles() {
  cookTopping()
}

cookNoodles()

错误的 stack 通常会列出 prepareEggcookToppingcookNoodles 以及更外层位置。格式、函数名推断和显示多少帧由运行环境决定,但核心价值相同:异常发生时,当前活跃调用形成了一条路径。

若中途有函数捕获错误,栈会沿调用方向展开到那个处理位置。被移除的调用不会在之后自动恢复;catch 接手的是错误,不是把失败函数从原地重开。

递归不是无限抽屉

递归函数会调用自己,每次调用仍需要一份独立现场:

js
function countdown(n) {
  if (n === 0) return
  console.log(n)
  countdown(n - 1)
}

countdown(3)

只要存在终止条件,这段代码会依次建立 countdown(3)countdown(2)countdown(1)countdown(0) 的调用,再逐层返回。若忘了减一或终止条件永远不成立,活跃调用会不断增加,最终常见结果是 RangeError: Maximum call stack size exceeded

不要把某个浏览器测出的“最多一万多层”写进业务逻辑。最大深度受引擎、版本、函数内容和优化方式影响,没有跨环境固定数字。深层树遍历若可能超过安全范围,可以改为自己维护数组或队列,用循环逐步处理。

闭包留下的不是整座调用栈

下面的 takeNumbercreateNumberMachine() 返回后仍能修改 current

js
function createNumberMachine() {
  let current = 0

  return function takeNumber() {
    current += 1
    return current
  }
}

const takeNumber = createNumberMachine()

console.log(takeNumber()) // 1
console.log(takeNumber()) // 2

这不代表 createNumberMachine 的执行上下文永远压在调用栈里。它早已返回,当前调用也已经结束。留下来的是内部函数与其创建时词法环境之间的可达关系;只要 takeNumber 仍可访问,current 这个绑定就不能被回收。

闭包保存“能到达哪些绑定”,调用栈记录“当前有哪些调用尚未完成”。一个面向词法作用域和生命周期,一个面向此刻的控制流。把两者混成“闭包把栈保存了”会导致很多错误推断,例如误以为外层函数仍在继续运行。

闭包也不是把变量当时的值拍成照片。内部函数访问的是绑定,所以后来修改同一绑定,闭包读到的是新值:

js
function createSign() {
  let text = '营业中'

  return {
    read: () => text,
    close: () => {
      text = '已打烊'
    },
  }
}

const sign = createSign()
console.log(sign.read()) // 营业中
sign.close()
console.log(sign.read()) // 已打烊

这里两个方法共享同一个 text 绑定。若再次调用工厂函数,则会创建另一套独立环境。这比“函数记住了一个值”更准确,也能解释为什么多个闭包有时共享状态、有时互不影响。

容易踩的坑:规范没有规定一摞物理栈帧

“JavaScript 使用调用栈”是一种非常有用的运行模型,但更准确的说法是:规范维护一个执行上下文栈来定义当前运行上下文;具体引擎怎样实现,是实现细节。

这一区分平时似乎不重要,碰到优化、异步堆栈或闭包时却很关键。开发者工具显示的异步调用链甚至可能包含运行环境额外保存并拼接的因果信息,它不等同于那个瞬间仍然活跃的同步调用。

例如定时器回调运行时,登记定时器的函数通常早已返回。调试器可能为了方便显示“这个定时器从哪里安排”,但那是异步因果链,不代表原函数一直压在同步调用栈底部。真正执行回调时,会形成一次新的调用和新的活跃执行上下文。

另一个常见误解是“单线程等于任何时候只能做一件事”。对当前页面里的 JavaScript 执行来说,同一代理上的代码不会同时随意穿插;但浏览器还管理网络、计时、渲染、工作线程等系统。它们如何把结果交还给 JavaScript,会在第四章事件循环里展开;第二章、第三章则先把“名字从哪来、this 绑给谁”这两块地基铺好。

判断一段代码是不是“还在当前调用里”,有个实用办法:问它能否用普通 return 把结果交回原调用表达式。同步子函数可以;定时器和点击回调不行,因为登记它们的调用早已结束。它们只能在新的调用中处理结果,或借助 Promise、事件和共享状态把结果传给后续代码。

这个问题也能帮助阅读第三方库的堆栈:先找仍未完成的同步调用,再把事件、Promise 等异步边界单独标出来,不要把两段不同时间的调用误画成一条从未返回的深栈。

试一试:在返回前后各加一行日志

给下面三个位置分别加日志,再先写出预测:

js
function pay() {
  return '已付款'
}

function checkout() {
  const result = pay()
  return result
}

console.log(checkout())

建议加在 pay 返回前、checkout 调用 pay 后、全局调用 checkout 后。然后在浏览器调试器里给 pay 加断点,观察每一步的 Call Stack。比起背“后进先出”,亲眼看一次调用者如何暂停会更牢。

再做一个对照:把 pay() 放进 setTimeout,看看断点停住时 checkout 是否仍在活跃调用栈中。这个变化会把本章的同步返回关系和第四章的异步重新调用清楚地分开。

下一步:外层返回后,为什么还能读到变量

第一章区分了调用栈与词法环境,但只点到为止。循环里的监听器、模块里的私有状态、异步回调里读到的外层变量,其实都在问同一件事:函数返回之后,哪些绑定仍然可达?

下一章从值班员留下的备忘签说起,专门拆开闭包、词法环境与生命周期。先把“名字从哪来”弄清,再进入 this 与事件循环,后面读异步代码会轻松很多。