执行上下文与作用域
2025-10-31 11:24
什么是执行上下文 (Execution Context)
执行上下文是 JavaScript 代码运行的动态环境,包含代码执行所需的所有信息。每当代码运行时,会创建对应的执行上下文,主要分为三类:
- 全局执行上下文
- 代码首次运行时默认创建。
- 生命周期与程序一致。
- 包含全局对象(如浏览器中的
window)和this指向全局对象。
- 函数执行上下文
- 每次函数调用时创建,函数执行完毕后销毁。
- 每个函数调用都会生成独立的上下文。
- Eval 执行上下文
- 创建于
eval()函数调用时。
- 创建于
执行上下文的组成
在 ES2018+ 规范中,执行上下文(Execution Context)是一个 抽象记录(Record),包含以下字段:
- Lexical Environment:词法环境 → 管 变量查找 和 this。
- Variable Environment:变量环境 → 只管 var 声明(历史遗留)。
- CodeEvaluationState:暂停/恢复状态 → 支持 async/await、generator。
- Function:当前函数对象 → 方便调试。
- ScriptOrModule:代码来源 → 支持模块化。
- Realm:“王国” → 隔绝不同全局环境(iframe、worker)。
- Generator:生成器专属 → 追踪 yield 状态。
这些字段中,前两个是管作用域的,中间三个是管执行状态的,后两个是管环境隔离的。当前文章主要是讲执行上下文与作用域所以会围绕前两个来进行讲解。
词法环境和变量环境
在介绍词法环境和变量环境之前,我们先看下在 V8 里 JS 的编译执行过程,大致上可以分为三个阶段:
- V8 引擎刚拿到
执行上下文的时候,会把代码从上到下一行一行的先做分词/词法分析(Tokenizing/Lexing)。分词是指:比如var a = 2;这段代码,会被分词为:vara2和;这样的原子符号(atomic token);词法分析是指:登记变量声明、函数声明、函数声明的形参。 - 在分词结束以后,会做代码解析,引擎将 token 解析翻译成一个 AST(抽象语法树), 在这一步的时候,如果发现语法错误,就会直接报错不会再往下执行。
JSvar greeting = "Hello"; console.log(greeting); greeting = ."Hi"; // SyntaxError: unexpected token . // 没有打印出 hello,而是先报错,说明JS引擎在真正执行代码之前,会做代码解析。
- 引擎生成 CPU 可以执行的机器码。
在第一步里有个词法分析,它用来登记变量声明、函数声明、函数声明的形参,后续代码执行的时候就知道去哪里拿变量的值和函数了,这个登记的地方就是词法环境 和 变量环境。
词法环境和变量环境的组成部分
它们有两个重要的组成部分就是:
- 环境记录(Environment Record):这个就是真正登记变量的地方。子类型:
- 声明式环境记录:用来记录直接有标识符定义的元素,比如变量、常量、let、class、module、import 以及函数声明。又分为:
- 函数式环境记录:用来记录函数的形参和函数内部定义的变量。
- 模块环境记录:用来记录 let、const、class、module、import 以及函数内部定义的变量。
- 对象式环境记录:主要用于 with 和 global 的词法环境
- 声明式环境记录:用来记录直接有标识符定义的元素,比如变量、常量、let、class、module、import 以及函数声明。又分为:
- 对外部词法环境的引用(outer): 它是作用域链能够连起来的关键。
词法环境与我们自己写的代码结构相对应,也就是我们自己代码写成什么样子,词法环境就是什么样子。词法环境是在代码定义的时候决定的,跟代码在哪里调用没有关系。所以说 JavaScript 采用的是词法作用域(静态作用域)。
比如:
JSvar a = 2; let x = 1; const y = 5; function foo() { console.log(a); function bar() { var b = 3; console.log(a * b); } bar(); } function baz() { var a = 10; foo(); } baz();

JS// === 执行上下文(双环境!)=== GlobalExecContext = { LexicalEnvironment: GlobalLexicalEnv, // let/const VariableEnvironment: GlobalVariableEnv, // var + 函数提升 // ... 其他 5 个字段 } // === 词法环境(LE)=== GlobalLexicalEnv = { EnvironmentRecord: DeclarativeRecord { // let/const/class x: 1, y: 5 }, outer: null } // === 变量环境(VE)=== GlobalVariableEnv = { EnvironmentRecord: ObjectRecord { // var + 全局对象 BindingObject: globalThis, // window/self a: 2, foo: <function>, baz: <function>, parseInt: <function>, Array: <constructor>, // ... 内置对象 }, outer: null } // === baz 函数上下文 === bazExecContext = { LexicalEnvironment: bazLexicalEnv, VariableEnvironment: bazVariableEnv, } bazLexicalEnv = { EnvironmentRecord: FunctionRecord { this: globalThis, // 普通函数绑定 a: 10, // 局部变量 foo: <function> // 全局 foo }, outer: GlobalLexicalEnv // 外部作用域引用,指向全局 } // === foo 函数上下文 === fooExecContext = { LexicalEnvironment: fooLexicalEnv, VariableEnvironment: fooVariableEnv, // 初始复制 GlobalVE } fooLexicalEnv = { EnvironmentRecord: FunctionRecord { this: globalThis, // 普通函数绑定 bar: <function> // 局部 bar }, outer: GlobalLexicalEnv } // === bar 内 === barLexicalEnv = { EnvironmentRecord: FunctionRecord { this: globalThis, b: 3 // 局部变量 b }, outer: fooLexicalEnv }
我们可以看到词法环境和我们代码的定义一一对应,每个词法环境都有一个 outer 指向上一层的词法环境,当运行上面代码,函数 bar 的词法环境里没有变量 a,所以就会到它的上一层词法环境(foo 函数词法环境)里去找,foo 函数词法环境里也没有变量 a,就接着去 foo 函数词法环境的上一层(全局词法环境)去找,在全局词法环境里 var a=2,沿着 outer 一层一层词法环境找变量的值就是作用域链。在沿着作用域链向上找变量的时候,找到第一个就停止往上找,如果到全局词法环境里还是没有找到,因为全局词法环境里的 outer 是 null,没办法再往上找,就会报 ReferenceError。
变量提升、函数提升与暂时性死区(TDZ)
在前面我们提到过,V8 引擎执行代码的大致可以分为三步,先做分词和词法分析,然后解析生成 AST,最后生成机器码执行代码。在词法分析的时候会生成词法环境和变量环境登记变量,对于变量声明和函数声明,词法环境和变量环境的处理是不一样的。
- 变量环境:
- 编译阶段:添加到变量环境,并初始化为
undefined。 - 执行阶段:赋值。
- 词法环境:
- 编译阶段:添加到词法环境,并加到暂时性死区(TDZ)里。
- 执行阶段:TDZ 检查,TDZ 检查通过后,从词法环境里获取变量的值。
看例子:
JSconsole.log(v1); // undefined → var 提升 var v1 = 1; console.log(f1); // [Function: f1] → 函数声明完整提升 f1(); // 正常运行, 因为函数声明提升 + 函数执行上下文 function f1() { console.log("函数声明"); } console.log(v2); // undefined → var 提升 // v2(); // TypeError var v2 = function () { console.log("函数表达式"); }; console.log(l1); // ReferenceError → TDZ let l1 = 1; console.log(l2); // ReferenceError → TDZ let l2 = function () {};
暂时性死区(TDZ)的本质: 从块开始到
let/const声明语句执行完成之间的区域,变量存在但故意不可访问,这就是 TDZ。 引擎在编译阶段就知道它们存在,但用 TDZ “锁住”,防止“提前使用”。
作用域与作用域链:词法环境的运行时真相
前面我们一直在说“outer 指针”和“沿着 outer 一层一层找”,那到底什么是作用域?什么是作用域链? 作用域就是当前执行上下文里“能访问到的所有变量的集合”, 而作用域链是当前执行上下文中,LexicalEnvironment 运行时栈顶开始,沿着每个环境的 outer 字段一路向外(直到 outer 为 null)形成的单向链表 VariableEnvironment 从不参与这条链。
为什么 词法环境有 outer 而 变量环境没有 outer?
VariableEnvironment 本身没有独立的作用域链, 但它“借”了 LexicalEnvironment 的 outer 链来完成查找。 看个例子:
JSfn(); // 能打印 undefined function fn() { console.log(a); // undefined } var a = 2;
上述例子可能会让人觉得能打印 a 是 var 声明 → 属于 VE → 所以 VE 有自己的作用域链 → 能找到 a。
但实际情况是按照规范原文官方规范原文(ECMAScript §9.2.5):
If the identifier is not found in the LexicalEnvironment chain, and we are in global code, then resolve it against the global VariableEnvironment (i.e., globalThis).
也就是说实际查找情况是当执行到 console.log(a) 时:
-
引擎先去当前 LexicalEnvironment(LE) 里找 a, 没找到;
-
沿着 LE 的
outer往上走(函数 LE → 全局 LE), 全局 LE 里也没有 a(因为 var a 不进 LE); -
走到全局 LE 的 outer = null,按理说应该报 ReferenceError;
-
但规范规定了一个“兜底机制”:
- 如果在整个 LE 链上都没找到标识符;
- 且当前环境是全局环境;
- 引擎会额外去全局的 VariableEnvironment(也就是 globalThis)里再找一次。
这就是为什么能打印 a 的原因。
VariableEnvironment 没有自己的作用域链! 它只是一个“历史遗留的单例对象”。 真正的作用域链永远只由 LexicalEnvironment + outer 构成。 var 和函数声明能被访问,只是因为在全局环境下,引擎加了一个“全局兜底规则”: LE 链找不到 → 再去全局 VE(即 globalThis)里找一次。 这就是为什么 var 看起来“有全局作用域”,但在函数内部 var 找不到外层 var 的根本原因。
作用域分类
JavaScript 的作用域在运行时一共有 3 种(开发者最常说的):
- 全局作用域(Global Scope)
- 函数作用域(Function Scope)
- 块级作用域(Block Scope,ES6 引入)
但在引擎底层,这 3 种作用域的实现方式完全不同:
作用域类型 由谁实现? 备注 全局作用域 VE(全局) + LE(全局) 两者都存在 函数作用域 VE(函数级) + LE(函数级) 两者都存在 块级作用域 只有 LE(通过运行时栈实现) VE 完全没有
核心结论:VariableEnvironment(VE)永远只有全局和函数作用域,永远没有块级作用域!
为什么 VE 没有块级作用域?—— 因为它根本不是“栈”
因为 ES6 之前只有 VE 一种环境, 它是 单例对象,没有 outer 链,也没有 push/pop 能力,所以只能做到函数级作用域,不可能实现块级。此时会有一下问题:
JSconsole.log(a); // undefined var a = 1; { var a = 2; console.log(a); // 2 } console.log(a); // 2
可以看到 变量提升会导致 a 可以在被声明前访问, 且在块中声明的 a 会覆盖外层声明的 a。 这是因为 VE 是单例对象,块中声明的 a 会被 VE 覆盖。为了解决这个问题,但又避免影响之前的 VE, ES6 引入了 LE,LE 是一个栈结构,可以 push/pop,所以可以做到块级作用域。并引入 let/const 作为全新的声明方式, let/const 声明的变量会进入 LE,而不是 VE。即解决了 变量提升,又避免了块级作用域的实现问题。
ES5 时代只有 VariableEnvironment 一种环境,它是 单例对象,没有 outer 链,也没有 push/pop 能力,所以只能做到函数级作用域,不可能实现块级。
ES6 要引入let/const的块级作用域,就必须造一个全新的、可伸缩的结构——于是诞生了 LexicalEnvironment + 运行时栈机制。
为了向前兼容,旧的var和函数声明继续留在 VE 里,新的let/const全部交给 LE 管理。
这就是双环境模型的根本由来:VE 是历史包袱,LE 是现代王者。 VE 没有块级作用域,不是 bug,而是历史设计如此。 它从出生那天起就注定只能做全局和函数级作用域。 块级作用域的全部魔法,都由 LexicalEnvironment 的运行时栈实现。
LE 运行时栈
为了好理解 LE 运行时栈,我们先看一个例子:
JSlet a = 1; { let a = 2; console.log(a); } // 此时的执行上下文栈结构如下: GlobalExecContext = { LexicalEnvironment: GlobalLexicalEnv, // 当运行到块中时 会暂时替换为 BlockLexicalEnv, 块结束后会被替换回 GlobalLexicalEnv, // 也就是 push进栈, 再pop出栈 } GlobalLexicalEnv = { EnvironmentRecord: DeclarativeRecord { a: 1, }, outer: null } BlockLexicalEnv = { EnvironmentRecord: DeclarativeRecord { a: 2, }, outer: GlobalLexicalEnv }
执行上下文保存在哪里
执行上下文在运行时会被保存在 执行栈(Call Stack)中,执行栈是 JavaScript 引擎运行代码的核心结构,它是一个 LIFO(后进先出) 的栈,用于管理执行上下文的进出。
- 本质:每次进入一个执行上下文(全局、函数、eval),就 push 一个新栈帧;执行完就 pop。
- 为什么重要:栈空时,Event Loop 才开始处理异步任务。
- 与 LE/VE 的关系:执行栈管“谁在跑”,LE/VE 管“跑的时候能看到什么”。
| 特性 | 描述 | 示例 |
|---|---|---|
| push | 进入函数/全局 | foo() 调用 → push fooExecContext |
| pop | 函数返回/抛错 | foo() 结束 → pop |
| 栈帧内容 | 完整执行上下文(LE + VE + this 等) | 每个栈帧有自己的 LE/VE |
| 溢出 | 递归太深 → StackOverflowError | 无限递归 |
代码示例:
JSCallStack = []; // 全局启动 CallStack.push(GlobalExecContext); execute(); // 跑代码 function foo() { ... } CallStack.push(fooExecContext); // 进入 foo execute(); CallStack.pop(); // foo 结束
此时的步骤就是:
-
推入栈(Push):
- 每次进入一个执行上下文(如全局启动、函数调用、eval),引擎就会创建一个新栈帧,推入执行栈顶。
- 栈帧包含完整执行上下文(LE + VE + this + 局部变量 + 返回地址等)。
-
运行过程:
- 引擎始终执行栈顶的上下文。
- 如果遇到函数调用,就 push 新上下文(“跳入”子函数)。
- 栈是 LIFO(后进先出),所以子函数先运行完。
-
释放(Pop):
- 函数返回 / 全局结束 / 抛错时,栈顶上下文弹出。
- 内存释放:局部变量、LE/VE 被 GC(垃圾回收),outer 指针可能被闭包保留。
例子:
JSconsole.log("1 - 全局开始"); function foo() { console.log("2 - foo 开始"); bar(); console.log("5 - foo 结束"); } function bar() { console.log("3 - bar 开始"); console.log("4 - bar 结束"); } foo(); console.log("6 - 全局结束"); // 全局启动: [Global] // 进入 foo: [foo, Global] // 进入 bar: [bar, foo, Global] // bar 结束: pop bar → [foo, Global] // foo 结束: pop foo → [Global] // 全局结束: pop Global → [] // Global Execution Context(全局执行上下文)不会在脚本运行期间弹出栈。 // 真正情况下 它会一直存在:浏览器中直到页面关闭/刷新,Node.js 中直到进程退出
为什么会这样设计?
- 单线程安全:栈确保代码顺序执行,避免混乱。
- 递归/嵌套:栈深度太深 → StackOverflowError(栈溢出)。
- 与 Event Loop 联动:栈空时,Event Loop 才处理异步(宏/微任务)。
当出现异步和闭包时执行栈会发生什么
有异步任务时
例子:
JSconsole.log("1 - 同步"); async function foo() { console.log("2 - async 开始"); await new Promise((resolve) => setTimeout(resolve, 0)); // await “暂停” console.log("4 - async 恢复"); } foo(); // async 调用 console.log("3 - 同步继续"); // 输出: 1 2 3 4
JavaScript 单线程,执行栈管理同步执行。async/await 基于 Promise + Event Loop,不阻塞栈。
- 脚本启动:push 全局上下文到栈,跑 '1'(同步)。
- foo() 调用:async 函数像同步一样 push foo 上下文到栈顶,跑 '2'(同步部分)。
- 遇 await:
- await new Promise(...):Promise 立即创建,但 setTimeout 是异步宏任务(Web API 处理)。
- await “暂停”:foo 上下文 pop 出栈(让出控制权),Promise 回调(resolve)推入宏任务队列。
- 栈顶回全局上下文,继续跑 '3'(同步)。
- 栈空:当前 Loop 结束,Event Loop 检查:
- 微任务队列空(无)。
- 取宏任务:setTimeout 执行,resolve Promise。
- resolve 触发 await 恢复回调作为微任务推入队列。
- 下一轮 Loop:
- 栈空,清微任务:push foo 恢复上下文到栈,跑 '4'(从 await 后继续)。
- pop foo,脚本结束。
TEXT启动: [Global] → 跑 '1' foo 调用: [foo, Global] → 跑 '2' → await 暂停 → pop foo 继续: [Global] → 跑 '3' → 栈空 Event Loop: 取宏任务 resolve → 推微任务恢复 清微任务: [foo恢复, Global] → 跑 '4' → pop foo 结束: [Global] → pop
await 会让 async 函数的执行上下文从栈中 pop,等待 Promise 完成时,事件循环会在微任务队列中重新安排一个任务,push 一个新的“恢复上下文”上栈,从暂停行继续执行。
有闭包时
首先要了解什么是闭包: 闭包(closure)是指一个函数能够访问并操作其外部作用域中的变量,即使这个函数在其外部作用域之外执行。它的特性是:
- 访问外部变量:闭包允许内部函数访问外部函数的变量,即使外部函数已经执行完毕。
- 持久化状态:闭包可以让外部函数的局部变量在外部函数返回后仍然存在。
- 避免全局变量污染:通过使用闭包,可以避免使用全局变量,从而减少命名冲突的风险。 看例子:
JSfunction outer() { let count = 0; return function inner() { count++; console.log(count); } } const fn = outer(); fn(); // 1 fn(); // 2
这里可以看到 outer() 执行完后会pop,其中的 执行上下文会被销毁,LE 会被垃圾回收才对,但是当我们运行 fn() 时,会继续沿用 outer 的 LE。这就是闭包。 其中 outer 的 执行上下文已经被销毁了, 但是 LE 被 fn 引用了, 所以 fn() 访问 outer 的变量 count。
即使执行上下文被从执行栈中 pop 掉,只要有函数保留对其变量的引用,该执行上下文的词法环境(Lexical Environment)仍然会被保留,不会被 GC 回收。 也即是说:
- 执行栈(Call Stack)只管执行上下文的 运行生命周期
- 闭包让某些词法环境(LE)拥有 延长生命周期
- 只要外部函数的变量还被内部函数引用,就不会被清理
最后
JavaScript 的执行上下文与作用域,看似复杂,其实只有 两个核心主角:
- VariableEnvironment(VE):ES5 的历史孤岛
- 单例对象、无 outer、无栈、只认识
var和函数声明 - 永远只有全局和函数作用域,块对它完全透明
- 单例对象、无 outer、无栈、只认识
- LexicalEnvironment(LE):ES6 的现代王者 1. 运行时栈、可 push/pop、有 outer 链 2. 实现了真正的块级作用域、TDZ、每次循环独立变量、闭包 变量提升、函数提升、块级作用域、闭包、let 的魔法,这一切的底层实现,都只是这两兄弟在分工合作.
执行栈是 JS 单线程的“脊梁”,负责谁先执行谁后执行; 词法环境是上下文的“视野”,决定当前代码能看到哪些变量; 当执行栈空了,Event Loop 才能开始调度异步任务。
_当你真正理解了 VE 是单例、LE 是栈这一句话,你就真正掌握了 JavaScript 的灵魂。