执行上下文我画过几遍图才记住:let不是不提升,而是进了暂时性死区;[[Environment]]和outer指针把作用域链串起来。
Q1: 执行上下文的生命周期
1
JS 的代码不是逐行无脑执行的
在执行任何代码前,引擎都会为其创建一个执行上下文(Execution Context, 简称 EC)。JS 中主要有三种上下文:全局执行上下文、函数执行上下文、eval 上下文。
2
创建阶段: 代码执行前的一瞬间
引擎会”扫视”当前作用域的全部代码,完成三件事:绑定
this、创建词法环境(存放 let/const/函数声明)、创建变量环境(存放 var 声明)。3
执行阶段: 逐行执行
引擎开始从上到下逐行执行代码,完成变量的赋值、函数的调用。
4
销毁阶段: 函数执行完毕
函数执行上下文会从执行上下文栈中弹出,等待垃圾回收(闭包除外)。
5
关键洞察: 变量提升是创建阶段的副产品
所谓变量提升,是引擎在创建阶段扫描 AST 并在内存中提前注册变量的必然现象。
执行上下文是代码执行前的”准备环境”,变量提升是创建阶段扫描代码的副产品。
var 的半吊子提升
var 声明会被提升,并且在创建阶段就被强行初始化为 undefined。let/const 与 TDZ
let 和 const 同样会被提升!但在执行到声明行之前,它们处于未初始化状态(TDZ)。Q2: 变量提升(Hoisting)与 TDZ 陷阱
“提升”是 JS 中最经典的坑。它的本质就是在创建阶段,引擎已经把变量和函数注册进了内存中,但还没运行到赋值的那一行。test 的创建阶段,引擎扫描到里面有 let x = 2,于是把局部的 x 提升到了顶部并锁入 TDZ。执行时,由于还没执行到赋值语句,x 还在死区里,直接报错。这完美证明了 let 也是会提升的。
TDZ 的底层实现:the_hole 值
V8 引擎用特殊的the_hole 值来实现暂时性死区:
the_hole 值即抛出 ReferenceError。这比 var 的 undefined 更安全——至少你知道变量还没初始化。
函数提升与变量提升的优先级
函数声明的提升优先级高于变量声明,这是引擎在创建阶段的处理顺序:Q3: V8 引擎视角的 ES6 上下文分离
在早期的 ES3 中,执行上下文只有一个古老的Variable Object (VO)。但到了 ES6,规范做出了一个极其巧妙的双轨设计:
let/const),如果整个词法环境栈都找遍了没找到,才会去变量环境里找(找 var)。如果还没找到,顺着 outer 指针去上一层执行上下文找(作用域链)。
词法环境的栈结构
词法环境是一个栈结构,进入{} 块时压栈,离开时弹栈:
作用域链的 outer 指针
每个词法环境都有一个outer 指针,指向父级词法环境:
Q4: 无限递归一定会溢栈吗?
大多数人只知道”无限递归会栈溢出”,但实际更在意的是:你是否了解尾调用优化(TCO)以及 V8 的实际行为? 答案是:不一定。在支持尾调用优化的引擎中,尾递归可以持续执行而不溢栈。但 V8 没有实现 TCO(规范只允许在严格模式下优化尾调用,V8 出于调试与性能考量选择不启用),所以实际项目中无限递归一定会溢栈。尾调用优化(TCO)的原理
尾调用优化的核心思想是:如果一个函数的最后一步是调用另一个函数(或自身),引擎可以复用当前栈帧,而不是新建一个。 这意味着栈深度不会随着递归层数增加而增长。V8 为什么不默认启用 TCO?
TCO 在 ES2015 规范中被正式引入,但 V8 团队出于以下原因选择不实现:- 调试困难:TCO 会消除栈帧,导致错误堆栈信息不完整,开发者无法看到完整的调用链
- 性能争议:TCO 需要引擎在调用时判断是否为尾调用,这个判断本身有开销
- 隐式行为:TCO 是隐式的优化,开发者可能无意中写出尾递归代码,导致行为在不同引擎间不一致
底层源码:V8 的 LexicalEnvironment 实现
为什么 ES6 既要支持老旧的 var,又要引入严谨的
let?引擎底层是如何把它们区分开的?
在 V8 引擎中,一个 Execution Context 被硬生生劈成了两半(详见 ECMAScript 规范:Lexical Environments):
- 词法环境(Lexical Environment):这是一个栈结构!专门用来存放
let、const和块级函数。当代码执行进入一个{}块时,V8 会在词法环境的栈顶压入一个新的环境记录;块执行完,直接弹栈销毁。这就是块级作用域的底层实现原理。 - 变量环境(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 (活动对象) 的区别是什么?
VO (变量对象) 和 AO (活动对象) 的区别是什么?
答题要点:
区分老旧标准与引擎实现状态。高分回答:
“VO 和 AO 是非常古老的 ES3 规范名词了(现在的规范统称 Environment Record)。VO 指的是在创建阶段,引擎收集到的所有变量和函数声明的集合。当函数正式进入执行阶段时,这个 VO 就被激活了,内部属性可以被访问和修改,这时候它就被叫做 AO(活动对象)。它们本质上是同一个结构在不同生命周期阶段的名字。”
闭包中的变量存放在哪里?堆还是栈?
闭包中的变量存放在哪里?堆还是栈?
答题要点:
逃逸分析与堆内存。高分回答:
“通常我们认为基础数据类型存放在执行上下文栈中。但 JS 引擎(如 V8)极其聪明,它在编译阶段会做词法逃逸分析。如果发现一个局部变量被内部函数(闭包)引用并作为返回值抛出,说明它的生命周期超越了当前函数的执行上下文栈。此时,V8 会硬生生地把这个变量从栈上’拔’出来,强行分配到堆内存(Heap)中去。这就是为什么即使外层函数执行完毕弹出(pop),闭包依然能访问到那个变量的原因。”
let 和 const 有变量提升吗?
let 和 const 有变量提升吗?
答题要点:
点出 TDZ 的本质。高分回答:
“有。
let 和 const 也会提升,但不会初始化。在声明语句之前访问会进入’暂时性死区’(TDZ),抛出 ReferenceError。TDZ 的本质是 V8 在进入块级作用域时,将变量初始化为特殊的 the_hole(空洞)值,在遇到赋值语句前访问它,V8 检测到 the_hole 即抛出 ReferenceError。”作用域链的本质是什么?
作用域链的本质是什么?
答题要点:
说出 outer 指针链。高分回答:
“作用域链的本质是词法环境和变量环境内部的
outer 引用指针不断向上回溯的过程。每个执行上下文都有自己的词法环境,每个词法环境都有一个 outer 指向上一层的词法环境。查找变量时,从当前词法环境开始,沿着 outer 链逐层向上查找,直到找到或者到达全局环境(outer 为 null)。这就是为什么内部函数能访问外部变量——不是因为变量被’复制’了,而是因为 outer 指针链把所有层级的环境串联起来了。”eval 和 with 对执行上下文有什么影响?
eval 和 with 对执行上下文有什么影响?
答题要点:
说明 eval 和 with 如何破坏作用域链。高分回答:
“with:将对象属性添加到作用域链的前端。这两个特性会导致 V8 无法在编译期确定变量绑定,被迫在运行时动态查找,性能极差。这也是它们被严格模式禁用的原因。”
eval 和 with 都会动态修改作用域链,导致 V8 无法进行编译期优化:eval:在当前执行上下文中动态执行代码,可以访问和修改局部变量。箭头函数的执行上下文有什么特殊之处?
箭头函数的执行上下文有什么特殊之处?
答题要点:
说明箭头函数没有自己的 this 和 arguments。高分回答:
“箭头函数的执行上下文有两个特殊之处:1. 没有自己的 this 绑定
箭头函数不会创建自己的 2. 没有自己的 arguments 对象
箭头函数访问 这是因为箭头函数的执行上下文中没有
this,它会捕获外层执行上下文的 this:arguments 会沿着作用域链查找外层的 arguments:this 绑定和 arguments 对象,它们会通过 outer 指针链查找外层环境。”如何手动创建一个新的执行上下文?
如何手动创建一个新的执行上下文?
答题要点:
说明 Function 构造函数和 eval 的区别。高分回答:
“在 JavaScript 中,有三种方式可以创建新的执行上下文:1. 函数调用(最常见)2. Function 构造函数3. eval(不推荐)关键区别:
- 函数调用:创建独立的执行上下文,有自己的词法环境和变量环境
- Function 构造函数:创建的函数总是运行在全局作用域中,无法访问外层变量
- eval:在当前执行上下文中执行代码,可以访问和修改局部变量(非严格模式)
执行上下文与调用栈的关系是什么?
执行上下文与调用栈的关系是什么?
答题要点:
说明执行上下文如何在调用栈中管理。高分回答:
“执行上下文与调用栈(Call Stack)的关系是:调用栈是管理执行上下文的数据结构。调用栈的工作原理:栈溢出(Stack Overflow):每次函数调用都会创建新的执行上下文并压入调用栈。如果递归层数过深,调用栈会超出内存限制,导致栈溢出。“
延伸阅读与引用参考
- ECMAScript 规范:Lexical Environments:ECMA 官方规范中对词法环境和变量环境的严谨定义。 [1]
- V8 Blog: Understanding V8’s Bytecode:了解 V8 Ignition 解释器是如何在底层处理变量提升与作用域逃逸的。 [2]
- MDN Web Docs: Closures:MDN 对闭包和作用域链的权威解释。 [3]
