Skip to main content
执行上下文我画过几遍图才记住:let 不是不提升,而是进了暂时性死区;[[Environment]] 和 outer 指针把作用域链串起来。

Q1: 执行上下文的生命周期

1

JS 的代码不是逐行无脑执行的

在执行任何代码前,引擎都会为其创建一个执行上下文(Execution Context, 简称 EC)。JS 中主要有三种上下文:全局执行上下文、函数执行上下文、eval 上下文。
2

创建阶段: 代码执行前的一瞬间

引擎会”扫视”当前作用域的全部代码,完成三件事:绑定 this、创建词法环境(存放 let/const/函数声明)、创建变量环境(存放 var 声明)。
3

执行阶段: 逐行执行

引擎开始从上到下逐行执行代码,完成变量的赋值、函数的调用。
4

销毁阶段: 函数执行完毕

函数执行上下文会从执行上下文栈中弹出,等待垃圾回收(闭包除外)。
5

关键洞察: 变量提升是创建阶段的副产品

所谓变量提升,是引擎在创建阶段扫描 AST 并在内存中提前注册变量的必然现象。
执行上下文解决的核心问题:
执行上下文就像考试前老师发卷子的过程。创建阶段相当于老师先把卷子翻一遍:把姓名栏(this)填好,把每道题的分数区域(变量)标出来,把填空题区(var)和简答题区(let/const)分开放好。真正开考(执行阶段)时,学生只需要按区域填答案。如果卷子没准备好就让学生开始写(不创建执行上下文),学生根本不知道该在哪里写什么。
执行上下文的核心结构:
执行上下文是代码执行前的”准备环境”,变量提升是创建阶段扫描代码的副产品。

var 的半吊子提升

var 声明会被提升,并且在创建阶段就被强行初始化为 undefined。
这种设计极易掩盖 Bug。

let/const 与 TDZ

let 和 const 同样会被提升!但在执行到声明行之前,它们处于未初始化状态(TDZ)。
报错比静默失败更安全。
函数声明的提升,优先级高于变量声明! 并且在创建阶段,函数不仅被提升,它的内部逻辑也被完整加载到了内存中。而如果是函数表达式(var fn = function(){}),则按照 var 的规则只提升一个 undefined。

Q2: 变量提升(Hoisting)与 TDZ 陷阱

“提升”是 JS 中最经典的坑。它的本质就是在创建阶段,引擎已经把变量和函数注册进了内存中,但还没运行到赋值的那一行。
为什么 test() 中的 console.log(x) 不去找外层的 x = 1? 在 test 的创建阶段,引擎扫描到里面有 let x = 2,于是把局部的 x 提升到了顶部并锁入 TDZ。执行时,由于还没执行到赋值语句,x 还在死区里,直接报错。这完美证明了 let 也是会提升的。

TDZ 的底层实现:the_hole 值

V8 引擎用特殊的 the_hole 值来实现暂时性死区:
当引擎在 TDZ 期间访问变量时,检测到 the_hole 值即抛出 ReferenceError。这比 var 的 undefined 更安全——至少你知道变量还没初始化。

函数提升与变量提升的优先级

函数声明的提升优先级高于变量声明,这是引擎在创建阶段的处理顺序:

Q3: V8 引擎视角的 ES6 上下文分离

在早期的 ES3 中,执行上下文只有一个古老的 Variable Object (VO)。但到了 ES6,规范做出了一个极其巧妙的双轨设计:
查询变量时的底层逻辑: V8 会首先在词法环境的栈顶开始向下查找(找 let/const),如果整个词法环境栈都找遍了没找到,才会去变量环境里找(找 var)。如果还没找到,顺着 outer 指针去上一层执行上下文找(作用域链)。

词法环境的栈结构

词法环境是一个栈结构,进入 {} 块时压栈,离开时弹栈:

作用域链的 outer 指针

每个词法环境都有一个 outer 指针,指向父级词法环境:

Q4: 无限递归一定会溢栈吗?

大多数人只知道”无限递归会栈溢出”,但实际更在意的是:你是否了解尾调用优化(TCO)以及 V8 的实际行为? 答案是:不一定。在支持尾调用优化的引擎中,尾递归可以持续执行而不溢栈。但 V8 没有实现 TCO(规范只允许在严格模式下优化尾调用,V8 出于调试与性能考量选择不启用),所以实际项目中无限递归一定会溢栈。

尾调用优化(TCO)的原理

尾调用优化的核心思想是:如果一个函数的最后一步是调用另一个函数(或自身),引擎可以复用当前栈帧,而不是新建一个。 这意味着栈深度不会随着递归层数增加而增长。

V8 为什么不默认启用 TCO?

TCO 在 ES2015 规范中被正式引入,但 V8 团队出于以下原因选择不实现:
  1. 调试困难:TCO 会消除栈帧,导致错误堆栈信息不完整,开发者无法看到完整的调用链
  2. 性能争议:TCO 需要引擎在调用时判断是否为尾调用,这个判断本身有开销
  3. 隐式行为:TCO 是隐式的优化,开发者可能无意中写出尾递归代码,导致行为在不同引擎间不一致
V8 默认的调用栈大小约为:
  • 64 位系统:主线程约 984KB(~10000-15000 帧),Worker 线程约 512KB
  • 32 位系统:约 492KB
每个栈帧的大小取决于函数的局部变量数量和复杂度。简单函数的栈帧约 64-128 字节,复杂函数可能更大。这意味着即使是非无限递归,深度超过几千层也可能溢栈。
普通递归就像接力赛——每一棒(栈帧)都要有自己的运动员站在跑道上等着,人多了跑道就站不下了。尾调用优化就像传送带——只有一个位置,每个箱子(计算)处理完后直接传送走,下一个箱子占据同一个位置,永远不会”站满”。但 V8 目前没有给 JavaScript 装上传送带,所以我们还是得自己用 trampoline 手动改造。

底层源码:V8 的 LexicalEnvironment 实现

在 V8 引擎中,一个 Execution Context 被硬生生劈成了两半(详见 ECMAScript 规范:Lexical Environments):
  1. 词法环境(Lexical Environment):这是一个栈结构!专门用来存放 let、const 和块级函数。当代码执行进入一个 {} 块时,V8 会在词法环境的栈顶压入一个新的环境记录;块执行完,直接弹栈销毁。这就是块级作用域的底层实现原理。
  2. 变量环境(Variable Environment):专门用来存放 var 声明。它不关心块级 {},只绑定在当前函数级别的上下文中。
这套双环境设计,完美解释了 let 不能重复声明、能形成块级作用域,而 var 会穿透代码块且能重复声明的底层真相。

十、及格回答 vs 满分回答

问题:“请谈谈你对执行上下文和变量提升的理解?”

及格回答(60-70分):
“代码在执行前会有个提升过程。用 var 声明的变量和普通 function 声明会被提升到作用域最顶部。var 提升后是 undefined,let 不会提升。”
问题出在:知识陈旧,且有硬伤。 以为 let 不提升是最大的错误。 满分回答(90-100分):
“执行上下文严格分为’创建阶段’与’执行阶段’。所谓变量提升,是引擎在创建阶段扫描 AST 并在内存中提前注册变量的必然现象。[1]”
“对于 var,引擎会在提升阶段强行塞入 undefined;而对于 let/const,引擎虽然做了提升,但会将其标记为 uninitialized(未初始化),形成暂时性死区(TDZ),强行访问则抛出 ReferenceError。[2]”
“为兼容旧规并引入块级作用域,ES6 后 V8 将上下文拆分为’词法环境’与’变量环境’。let 受控于词法环境栈的推入与弹出,完美隔绝块级作用域;var 则驻留于变量环境。作用域链的本质,正是这两个环境内部的 outer 引用指针不断向上回溯的过程。[3]“

常见追问

答题要点: 区分老旧标准与引擎实现状态。高分回答: “VO 和 AO 是非常古老的 ES3 规范名词了(现在的规范统称 Environment Record)。VO 指的是在创建阶段,引擎收集到的所有变量和函数声明的集合。当函数正式进入执行阶段时,这个 VO 就被激活了,内部属性可以被访问和修改,这时候它就被叫做 AO(活动对象)。它们本质上是同一个结构在不同生命周期阶段的名字。”
答题要点: 逃逸分析与堆内存。高分回答: “通常我们认为基础数据类型存放在执行上下文栈中。但 JS 引擎(如 V8)极其聪明,它在编译阶段会做词法逃逸分析。如果发现一个局部变量被内部函数(闭包)引用并作为返回值抛出,说明它的生命周期超越了当前函数的执行上下文栈。此时,V8 会硬生生地把这个变量从栈上’拔’出来,强行分配到堆内存(Heap)中去。这就是为什么即使外层函数执行完毕弹出(pop),闭包依然能访问到那个变量的原因。”
答题要点: 点出 TDZ 的本质。高分回答: “有。let 和 const 也会提升,但不会初始化。在声明语句之前访问会进入’暂时性死区’(TDZ),抛出 ReferenceError。TDZ 的本质是 V8 在进入块级作用域时,将变量初始化为特殊的 the_hole(空洞)值,在遇到赋值语句前访问它,V8 检测到 the_hole 即抛出 ReferenceError。”
答题要点: 说出 outer 指针链。高分回答: “作用域链的本质是词法环境和变量环境内部的 outer 引用指针不断向上回溯的过程。每个执行上下文都有自己的词法环境,每个词法环境都有一个 outer 指向上一层的词法环境。查找变量时,从当前词法环境开始,沿着 outer 链逐层向上查找,直到找到或者到达全局环境(outer 为 null)。这就是为什么内部函数能访问外部变量——不是因为变量被’复制’了,而是因为 outer 指针链把所有层级的环境串联起来了。”
答题要点: 说明 eval 和 with 如何破坏作用域链。高分回答: “eval 和 with 都会动态修改作用域链,导致 V8 无法进行编译期优化:eval:在当前执行上下文中动态执行代码,可以访问和修改局部变量。
with:将对象属性添加到作用域链的前端。
这两个特性会导致 V8 无法在编译期确定变量绑定,被迫在运行时动态查找,性能极差。这也是它们被严格模式禁用的原因。”
答题要点: 说明箭头函数没有自己的 this 和 arguments。高分回答: “箭头函数的执行上下文有两个特殊之处:1. 没有自己的 this 绑定 箭头函数不会创建自己的 this,它会捕获外层执行上下文的 this:
2. 没有自己的 arguments 对象 箭头函数访问 arguments 会沿着作用域链查找外层的 arguments:
这是因为箭头函数的执行上下文中没有 this 绑定和 arguments 对象,它们会通过 outer 指针链查找外层环境。”
答题要点: 说明 Function 构造函数和 eval 的区别。高分回答: “在 JavaScript 中,有三种方式可以创建新的执行上下文:1. 函数调用(最常见)
2. Function 构造函数
3. eval(不推荐)
关键区别:
  • 函数调用:创建独立的执行上下文,有自己的词法环境和变量环境
  • Function 构造函数:创建的函数总是运行在全局作用域中,无法访问外层变量
  • eval:在当前执行上下文中执行代码,可以访问和修改局部变量(非严格模式)
答题要点: 说明执行上下文如何在调用栈中管理。高分回答: “执行上下文与调用栈(Call Stack)的关系是:调用栈是管理执行上下文的数据结构。调用栈的工作原理:
栈溢出(Stack Overflow):
每次函数调用都会创建新的执行上下文并压入调用栈。如果递归层数过深,调用栈会超出内存限制,导致栈溢出。“

延伸阅读与引用参考