上一篇 JS 原型与原型链 讲了"对象怎么找属性"。这一篇换一个视角,讲"代码怎么跑起来"。
JS 工程师面试 / 进阶绕不过的几道送命题:
- 变量提升到底"提"了什么?
var/let/const在执行时究竟有什么差别?- 为什么递归会"栈溢出"?
console.log这一行真正执行之前,引擎做了哪些准备工作?
这些问题的答案,全在"执行上下文 + 调用栈"这一对兄弟概念里。这一篇把它俩讲透。
一句话定义
执行上下文(Execution Context) = JS 引擎在执行一段代码之前,把当前环境的"作用域、变量、this 指向"等信息打包成的一个抽象对象。
调用栈(Call Stack) = 这些执行上下文按 LIFO(后进先出) 顺序管理的栈结构。
代码在哪个上下文里跑、上下文又怎么进出栈,这两件事决定了 JS 的几乎所有运行时行为。
执行环境有哪几种
JS 把代码分成三种执行环境,每种对应一个执行上下文:
| 环境 | 对应上下文 | 数量 | 状态 |
|---|---|---|---|
| 全局环境 | 全局执行上下文 | 一个程序只有 1 个 | 进入 JS 就创建,关页面才销毁 |
| 函数环境 | 函数执行上下文 | 每次调用都新建 | 函数执行完出栈销毁(除非有闭包) |
| eval 环境 | eval 执行上下文 | 调用一次新建一次 | 已不推荐使用,本文不展开 |
JS 引擎执行流程大致是:
- 进入 JS 文件 → 创建全局执行上下文 → 入栈
- 遇到函数调用 → 创建该函数的执行上下文 → 入栈
- 函数执行完 → 该上下文出栈销毁
- 全局代码跑完 → 全局上下文一直在栈底,直到页面关闭
调用栈:一个 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在不同函数里实测会有几千的差距。
栈溢出的常见原因:
- 递归忘了终止条件(99% 的栈溢出都是这种)
- A 调 B,B 又调 A 的互相递归
- getter / setter 里访问了自己(
get x() { return this.x }这种) - 模板引擎 / JSON 转换时遇到循环引用
对策:
- 递归改循环
- 大深度递归用 trampoline(把递归改成"返回下一次要调用的函数",外层 while 循环里跑)
- 拆分成异步任务(
queueMicrotask/setTimeout)分批处理,每次都清空栈 - ES6 规范的尾调用优化(TCO) 理论上能解 —— 但 V8 至今没开(Safari 是唯一打开 TCO 的浏览器)
执行上下文的生命周期
每个执行上下文都有两个阶段,顺序固定:
┌──────────────────┐ ┌──────────────────┐
│ 创建阶段 │ ────► │ 执行阶段 │
│ (Creation) │ │ (Execution) │
└──────────────────┘ └──────────────────┘很多 JS 行为(变量提升、TDZ、this 指向等)都是这两阶段的"副作用"。
创建阶段干了什么
引擎进入一段代码时,还没真的执行任何一行,但已经把环境准备好了:
- 创建变量环境(老规范叫"变量对象 VO",ES6 之后叫"词法环境 + 变量环境")
- 初始化
arguments对象(函数环境才有) - 处理形参,把传入的实参绑进去
- 扫描所有函数声明 —— 名字 + 函数体一起挂上(所以函数能整体提升)
- 扫描所有
var变量声明 —— 只挂名字,值是undefined - 扫描所有
let/const/class声明 —— 挂名字,但标记为"未初始化"(进入 TDZ,访问就报错)
- 初始化
- 确定
this指向(由调用方式决定) - 建立作用域链(由代码写在哪里决定,词法作用域)
把整个上下文抽象成对象,大致是这样的:
executionContext = {
variableEnvironment: {
arguments: { ... },
foo: <pointer to function foo>,
a: undefined,
b: undefined,
},
lexicalEnvironment: {
c: <uninitialized>,
d: <uninitialized>,
},
scopeChain: [...],
this: <某个对象>,
}执行阶段干了什么
创建阶段铺好底子,逐行执行:
- 给变量赋具体值(
var a = 'Hello'这一步,a才真的变成'Hello') - 函数表达式赋值(
var b = function() {...}这一步,b才指向函数) - 调用其他函数(产生新的执行上下文,入栈)
- 顺序执行其他逻辑
跟着例子看两阶段
例子 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 a和var 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>,
},
...
}只有此刻,a 和 b 才有了真正的值。
例子 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,
},
...
}注意三个事实:
- 函数声明
function foo()的提升优先级高于var foo的提升 —— 找到函数声明后foo已经是函数,后面var foo同名变量被忽略(不会覆盖) var bar只挂名,值是undefinedfunction 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() {} |
f 走 var 规则,值是 undefined |
f() 报 f is not a function |
箭头函数赋值 const f = () => {} |
f 走 const 规则,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 = 2let / 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) + 外层指针链 | 同样的概念,实现细节变了 |
理解上不影响,因为引擎要做的事情还是"扫描声明 → 赋初值 → 逐行执行"。只是现代规范多了"变量环境"和"词法环境"两个对象,分别管 var 和 let/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创建阶段 f 是 function,执行阶段执行 var f = 2 这一行把 f 重新赋值。所以最终是 2。
if (false) {
function g() {}
}
console.log(typeof g)这题在不同环境结果不一样(undefined 或 function),因为 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 的内功就基本通了。下次再有人问"var 和 let 真正的区别",你可以从两阶段讲到 TDZ —— 而不是只背"let 有块级作用域 var 没有"。
下次见。
-EOF-