返回首页

JS 执行上下文与调用栈:代码到底是怎么"跑起来"的

原型链讲"对象怎么找属性",执行上下文 + 调用栈讲的是"代码怎么跑起来"。这一篇把全局/函数上下文、创建阶段和执行阶段的两步流程、变量提升的本质、栈溢出和 let/const 的暂时性死区一次性讲透。

上一篇 JS 原型与原型链 讲了"对象怎么找属性"。这一篇换一个视角,讲"代码怎么跑起来"。

JS 工程师面试 / 进阶绕不过的几道送命题:

  • 变量提升到底"提"了什么?
  • var / let / const 在执行时究竟有什么差别?
  • 为什么递归会"栈溢出"?
  • console.log 这一行真正执行之前,引擎做了哪些准备工作?

这些问题的答案,全在"执行上下文 + 调用栈"这一对兄弟概念里。这一篇把它俩讲透。

一句话定义

执行上下文(Execution Context) = JS 引擎在执行一段代码之前,把当前环境的"作用域、变量、this 指向"等信息打包成的一个抽象对象

调用栈(Call Stack) = 这些执行上下文按 LIFO(后进先出) 顺序管理的栈结构。

代码在哪个上下文里跑、上下文又怎么进出栈,这两件事决定了 JS 的几乎所有运行时行为。

执行环境有哪几种

JS 把代码分成三种执行环境,每种对应一个执行上下文:

环境 对应上下文 数量 状态
全局环境 全局执行上下文 一个程序只有 1 个 进入 JS 就创建,关页面才销毁
函数环境 函数执行上下文 每次调用都新建 函数执行完出栈销毁(除非有闭包)
eval 环境 eval 执行上下文 调用一次新建一次 已不推荐使用,本文不展开

JS 引擎执行流程大致是:

  1. 进入 JS 文件 → 创建全局执行上下文 → 入栈
  2. 遇到函数调用 → 创建该函数的执行上下文 → 入栈
  3. 函数执行完 → 该上下文出栈销毁
  4. 全局代码跑完 → 全局上下文一直在栈底,直到页面关闭

调用栈:一个 LIFO 数据结构

栈这种数据结构很简单,只有一个出入口,在顶部。后进的先出,先进的后出 —— LIFO(Last In First Out)。

   入栈 →   ┌─────────┐   ← 出栈
            │   top   │
            │         │
            │ bar ctx │   ← 当前正在执行的函数
            │ foo ctx │
            │ global  │   ← 永远在栈底
            └─────────┘

重点:

  • 栈底永远是全局执行上下文(从 JS 启动到页面关闭,它都在)
  • 栈顶总是当前正在执行的那段代码的上下文
  • 调用一个函数 → 该函数的上下文压入栈顶
  • 函数 return / 执行到末尾 → 上下文从栈顶弹出
  • 控制权回到栈顶新的那个上下文(就是调用者)

跟着例子看入栈出栈

经典的嵌套调用:

function foo() {
  function bar() {
    return 'I am bar'
  }
  return bar()
}
foo()

整个调用过程,栈的变化:

时刻 1                时刻 2                时刻 3
代码刚加载            进入 foo()            foo 里又调了 bar()
┌─────────┐          ┌─────────┐          ┌─────────┐
│         │          │         │          │ bar ctx │  ← 栈顶
│         │          │ foo ctx │          │ foo ctx │
│ global  │          │ global  │          │ global  │
└─────────┘          └─────────┘          └─────────┘

时刻 4                时刻 5                时刻 6
bar 返回出栈          foo 返回出栈          全局执行完
┌─────────┐          ┌─────────┐          ┌─────────┐
│         │          │         │          │         │
│ foo ctx │          │         │          │         │
│ global  │          │ global  │          │ global  │
└─────────┘          └─────────┘          └─────────┘

浏览器开发者工具的 Sources → Call Stack 面板,在断点处看到的"foo → bar"那一列,就是这个栈的可视化。在代码里写 console.trace() 也能打印当前调用栈。

栈溢出:Maximum call stack size exceeded

栈的容量虽然没有规范死硬上限,但实际有限。一旦超过引擎给栈分配的空间,就会:

function foo() {
  foo()
}
foo()
// Uncaught RangeError: Maximum call stack size exceeded

各引擎栈深度参考:

引擎 默认栈深度(约) 是否可调
Chrome V8(浏览器) ~10,500 帧 不能用户调
Node.js ~10,500 帧 启动加 --stack-size=N 可调(单位 KB)
Firefox SpiderMonkey ~25,000 帧 不能用户调
Safari JavaScriptCore 浏览器版本差异大 不能用户调

帧数取决于函数体大小(局部变量越多,单帧越大,能放的帧就越少),所以同一个 foo 在不同函数里实测会有几千的差距。

栈溢出的常见原因:

  1. 递归忘了终止条件(99% 的栈溢出都是这种)
  2. A 调 B,B 又调 A 的互相递归
  3. getter / setter 里访问了自己(get x() { return this.x } 这种)
  4. 模板引擎 / JSON 转换时遇到循环引用

对策:

  • 递归改循环
  • 大深度递归用 trampoline(把递归改成"返回下一次要调用的函数",外层 while 循环里跑)
  • 拆分成异步任务(queueMicrotask / setTimeout)分批处理,每次都清空栈
  • ES6 规范的尾调用优化(TCO) 理论上能解 —— 但 V8 至今没开(Safari 是唯一打开 TCO 的浏览器)

执行上下文的生命周期

每个执行上下文都有两个阶段,顺序固定:

┌──────────────────┐         ┌──────────────────┐
│   创建阶段        │ ────►  │   执行阶段        │
│  (Creation)      │         │  (Execution)     │
└──────────────────┘         └──────────────────┘

很多 JS 行为(变量提升、TDZ、this 指向等)都是这两阶段的"副作用"。

创建阶段干了什么

引擎进入一段代码时,还没真的执行任何一行,但已经把环境准备好了:

  1. 创建变量环境(老规范叫"变量对象 VO",ES6 之后叫"词法环境 + 变量环境")
    • 初始化 arguments 对象(函数环境才有)
    • 处理形参,把传入的实参绑进去
    • 扫描所有函数声明 —— 名字 + 函数体一起挂上(所以函数能整体提升)
    • 扫描所有 var 变量声明 —— 只挂名字,值是 undefined
    • 扫描所有 let / const / class 声明 —— 挂名字,但标记为"未初始化"(进入 TDZ,访问就报错)
  2. 确定 this 指向(由调用方式决定)
  3. 建立作用域链(由代码写在哪里决定,词法作用域)

把整个上下文抽象成对象,大致是这样的:

executionContext = {
  variableEnvironment: {
    arguments: { ... },
    foo: <pointer to function foo>,
    a: undefined,
    b: undefined,
  },
  lexicalEnvironment: {
    c: <uninitialized>,
    d: <uninitialized>,
  },
  scopeChain: [...],
  this: <某个对象>,
}

执行阶段干了什么

创建阶段铺好底子,逐行执行:

  1. 给变量赋具体值(var a = 'Hello' 这一步,a 才真的变成 'Hello')
  2. 函数表达式赋值(var b = function() {...} 这一步,b 才指向函数)
  3. 调用其他函数(产生新的执行上下文,入栈)
  4. 顺序执行其他逻辑

跟着例子看两阶段

例子 1:简单的 var + 函数声明

const foo = function (i) {
  var a = 'Hello'
  var b = function privateB() {}
  function c() {}
}
foo(10)

foo(10) 调用瞬间,引擎为 foo 新建上下文,创建阶段结束时它长这样:

fooContext = {
  variableEnvironment: {
    arguments: { 0: 10, length: 1 },
    i: 10,                         
    c: <pointer to function c>,    
    a: undefined,                  
    b: undefined,                  
  },
  scopeChain: [...],
  this: <调用方决定>,
}

关键观察:

  • arguments 对象、形参 i、函数声明 c —— 这三类已经有值
  • var avar b —— 名字挂上去,值是 undefined
  • 函数 c 的"提升"是连函数体一起提,所以这一刻就能调用 c()

执行阶段结束:

fooContext = {
  variableEnvironment: {
    arguments: { 0: 10, length: 1 },
    i: 10,
    c: <pointer to function c>,
    a: 'Hello',                       
    b: <pointer to function privateB>,
  },
  ...
}

只有此刻,ab 才有了真正的值。

例子 2:IIFE 里的诡异输出(经典面试题)

;(function () {
  console.log(typeof foo)
  console.log(typeof bar)
  var foo = 'Hello'
  var bar = function () {
    return 'World'
  }
  function foo() {
    return 'good'
  }
  console.log(foo, typeof foo)
})()

输出是:

function
undefined
Hello string

很多人第一眼看不明白。两阶段视角下就清楚了。

创建阶段结束时:

iifeContext = {
  variableEnvironment: {
    arguments: { length: 0 },
    foo: <pointer to function foo>,  
    bar: undefined,                  
  },
  ...
}

注意三个事实:

  1. 函数声明 function foo() 的提升优先级高于 var foo 的提升 —— 找到函数声明后 foo 已经是函数,后面 var foo 同名变量被忽略(不会覆盖)
  2. var bar 只挂名,值是 undefined
  3. function foo 整体已经可调用了

执行阶段逐行跑:

console.log(typeof foo)      // 'function'  (创建阶段就指向 function foo)
console.log(typeof bar)      // 'undefined' (var bar 还没赋值)

var foo = 'Hello'            // 这一行才把 foo 重新赋值为字符串
var bar = function() {...}   // bar 此刻指向函数

// function foo() {...} 已经在创建阶段处理过,这一行执行阶段不再做事

console.log(foo, typeof foo) // 'Hello' 'string'

记住这个口诀:创建阶段"扫一遍声明",执行阶段"跑一遍代码"。 90% 的 JS 提升类面试题都用这一招拆解。

变量提升的真相

"变量提升(Hoisting)"是面试官最爱拿来烤新人的词,本质就是两阶段的副作用:

声明形式 创建阶段 在赋值前访问
function foo() {...} 名字 + 函数体一起挂上 可以正常调用,返回函数本身
var x = 1 名字挂上,值是 undefined 返回 undefined(不报错)
let x = 1 / const x = 1 名字挂上,但标记为未初始化 TDZ 报错:ReferenceError: Cannot access 'x' before initialization
class Foo {...} let TDZ 报错
函数表达式 var f = function() {} fvar 规则,值是 undefined f()f is not a function
箭头函数赋值 const f = () => {} fconst 规则,TDZ f() 报 TDZ

所以严格说,let / const 也"提升"了,只是规范给它们加了一道"必须真的执行到声明那一行才能访问"的栅栏 —— 这道栅栏就是 暂时性死区(Temporal Dead Zone, TDZ)

console.log(a)  // undefined  ← var 提升,值是 undefined
console.log(b)  // ReferenceError  ← let 在 TDZ
var a = 1
let b = 2

let / const 的 TDZ 是 ES6 设计上特意加的,目的就是为了避免 var 这种"先用后声明"的歧义代码。能用 let / const 就别用 var

ES6 之后规范术语小变化

原文(以及很多老教程)里讲的"变量对象(Variable Object, VO)" / "活动对象(Activation Object, AO)" 是 ES5 规范的术语。ES2015(ES6)规范改写了这块,引入了新名字:

老规范(ES3 / ES5) 新规范(ES6+) 管什么
变量对象 VO / 活动对象 AO 词法环境(Lexical Environment) let / const / class
(同上) 变量环境(Variable Environment) var / function 声明
作用域链 环境记录(Environment Record) + 外层指针链 同样的概念,实现细节变了

理解上不影响,因为引擎要做的事情还是"扫描声明 → 赋初值 → 逐行执行"。只是现代规范多了"变量环境"和"词法环境"两个对象,分别管 varlet/const 的提升行为,这样 TDZ 才能在规范层面成立。

平时面试 / 读源码,老术语 VO 用着也没问题,知道它对应新规范的"变量环境"就行。

实战意义:几个能落地的应用

1. 解释经典面试题

console.log(a)
var a = 1

很多人第一反应是"会报错吧?变量都还没声明"。实际输出 undefined —— 因为创建阶段 var a 已经挂上了,值是 undefined,执行 console.log 时拿到的是这个 undefined。

function f() { return 1 }
var f = 2
console.log(f)  // 2

创建阶段 ffunction,执行阶段执行 var f = 2 这一行把 f 重新赋值。所以最终是 2

if (false) {
  function g() {}
}
console.log(typeof g)

这题在不同环境结果不一样(undefinedfunction),因为 ES5 没规范"块内函数声明",各浏览器实现不一。ES6 严格模式下统一为 undefined(块内函数有自己的作用域)。这就是为啥 ESLint 默认禁止 if () { function ... } 这种写法。

2. 解释闭包的"内存泄漏"

闭包之所以能让外层函数变量"活着",就是因为它阻止了外层函数的执行上下文出栈销毁:

function outer() {
  const big = new Array(1000000).fill('x')
  return function inner() {
    return big[0]
  }
}
const fn = outer()

正常来说 outer() 返回后,它的执行上下文应该出栈销毁,big 应该被 GC。但因为 inner 还在引用 big(通过作用域链),引擎不会回收 outer 的变量环境,那个 100 万的数组就一直挂在内存里

不及时释放 fn,内存就回不来。

3. 看懂 Stack Trace 的层次

任何错误的 stack trace 都是调用栈的快照:

TypeError: Cannot read property 'foo' of undefined
    at bar (script.js:10:5)        ← 栈顶,出错的函数
    at foo (script.js:5:3)
    at <anonymous> (script.js:15:1) ← 栈底,全局

读 stack 永远 从上往下 —— 越上越靠近出错点,越下越接近"是谁最初触发的"。

4. 异步代码的"调用栈被清空"

setTimeout / Promise.then 回调里看不到外层调用栈,因为事件循环把任务取出执行时,栈已经清空了:

function foo() {
  setTimeout(function bar() {
    throw new Error('boom')
  }, 100)
}
foo()
// stack trace 里只有 bar,看不到 foo

要解决这个看不到调用链的痛点,Chrome DevTools 有"Async Stack Trace"选项打开后能跨异步边界拼接出"逻辑调用链"。

一句话总结

一段 JS 代码,本质上就是引擎在按"创建 → 执行"两步处理一连串执行上下文,这些上下文在调用栈里按 LIFO 进进出出。

把这一条牢记,你会发现:

  • 变量提升只是创建阶段的副作用
  • TDZ 只是创建阶段标记 + 执行阶段检查
  • 闭包只是某个上下文没能正常出栈
  • 栈溢出只是栈装不下了
  • 作用域 只是上下文里的一个字段

加上前一篇讲的原型链(对象怎么找属性),JS 的内功就基本通了。下次再有人问"varlet 真正的区别",你可以从两阶段讲到 TDZ —— 而不是只背"let 有块级作用域 var 没有"。


下次见。

-EOF-