深入理解 JavaScript 执行机制:从编译到执行的全过程
JavaScript 的执行机制是每个前端开发者的必修课。理解了变量提升、调用栈、作用域和块级作用域,才能真正读懂代码的执行流程。本文将通过原理讲解 + 代码实战的方式,带你彻底搞懂 JS 的执行机制。
一、从一个例子说起
先看一段简单代码,你能否说出它的输出顺序和原因?
js
showName('XXX');
console.log(myname);
var myname = '张三';
function showName(name) {
console.log(name);
var b = 1;
console.log('showName run', name);
}
如果你能毫不犹豫地说出答案并解释原因,说明你对 JS 执行机制已经有了不错的理解。如果还有些犹豫,那这篇文章就是为你准备的。
答案是 :先输出 'XXX' 和 'showName run XXX',再输出 undefined。
为什么 showName 可以在定义之前调用?为什么 myname 在赋值之前访问不是报错而是 undefined?这背后就是 JS 的编译与执行机制。
二、执行上下文(Execution Context)
每当你运行一段 JS 代码,V8 引擎都会创建一个执行上下文对象来管理这段代码的执行。它由三个部分组成:
| 组成部分 | 存放内容 |
|---|---|
| 变量环境(Variable Environment) | var 和 function 声明的变量 |
| 词法环境(Lexical Environment) | let 和 const 声明的变量,支持块级作用域 |
| 可执行代码 | 从上到下顺序执行的代码逻辑 |
简单理解:执行上下文就是一个"运行环境快照",记录了当前作用域里有哪些变量、它们的值是什么、代码执行到哪了。
三、编译阶段详解
JS 不是纯解释型语言,它是先编译,再执行的。编译总是发生在执行的前一刻。
3.1 全局代码的编译过程
以开头的代码为例,当 V8 拿到这段全局代码时,编译阶段做了下面的事:
javascript
第 1 步:创建全局执行上下文对象
第 2 步:扫描所有 var 声明,将变量名作为 key,value 设为 undefined
→ myname = undefined
第 3 步:扫描所有 function 声明,将函数名作为 key,value 设为完整的函数体
→ showName = function showName(name) { ... }
(注意:全局作用域没有形参,所以跳过了「统一形参和实参」这一步)
经过编译后,在 V8 眼中代码变成了这样:
js
// ⬇⬇⬇ 编译阶段的结果 ⬇⬇⬇
var myname; // 提升到顶部,值为 undefined
function showName(name) { // 整个函数体提升到顶部
var b; // 函数内部的 var 也提升了
console.log(name);
b = 1;
console.log('showName run', name);
}
// ⬆⬆⬆ 编译阶段的结果 ⬆⬆⬆
// ⬇⬇⬇ 执行阶段(从上到下) ⬇⬇⬇
showName('XXX'); // ✅ 函数已存在,正常调用
console.log(myname); // undefined(声明了但还未赋值)
myname = '张三'; // 赋值发生在原地
3.2 函数体内的编译过程
函数被调用时,也会经历一次编译,而且比全局多了一个关键步骤------统一形参和实参。
来看这个例子:
js
function fn(a) {
console.log(a); // 输出什么?
var a = 2;
function a() {}
var b = a;
console.log(a); // 输出什么?
}
fn(3);
注意 :调用是
fn(3),传的是数字3,和任何外层变量都没有关系。函数的形参a属于 fn 自己的作用域,是一个全新的变量。
当 fn(3) 被调用时,引擎为 fn 从零开始创建一个全新的执行上下文。编译过程如下:
css
第 1 步:创建 fn 的执行上下文对象
(全新的、空白的上下文,和全局没有任何变量共享)
第 2 步:找形参和 var 声明,将变量名登记为 key,value 暂设为 undefined
- 形参 a → a = undefined(先占位,等实参来覆盖)
- var a → a 已存在,跳过(不会重复声明)
- var b → b = undefined
第 3 步:统一形参和实参 ← 函数独有的步骤!
- 实参是 3 → a = 3(用实参的值覆盖上一步的 undefined)
第 4 步:找 function 声明
- function a(){} → a = [Function: a](函数声明最后处理,覆盖了 3)
为什么第 2 步 a 是
undefined? 因为每个函数调用都会创建一个全新的执行上下文,从零开始初始化,和全局变量、外层作用域没有任何直接关系。第 2 步只是"登记名字、先占个位",真正的值在第 3 步(实参)或第 4 步(函数声明)才确定下来。
然后进入执行阶段:
css
console.log(a) → [Function: a] // a 被函数声明覆盖了
a = 2 // 赋值,a 变成数字 2
function a(){} → 编译阶段已处理,跳过
b = a → b = 2
console.log(a) → 2
输出结果:[Function: a] 和 2。
关键记忆点:编译阶段的处理顺序是「形参/var(值为 undefined)→ 实参赋值 → 函数声明(值为函数体)」。函数声明最后处理,所以它的优先级最高,会覆盖同名的形参和 var 变量。
3.3 函数声明 vs var 声明优先级
这道面试题你一定见过:
js
console.log(func); // ?
var func = 345;
function func() {
}
var func = 123;
答案是 [Function: func]。
原因很直接------编译阶段函数声明覆盖了 var 声明:
swift
编译:
步骤 2:var func → func = undefined
步骤 3:全局,跳过
步骤 4:function func → func = [Function: func](覆盖 undefined)
第二个 var func → func 已存在,跳过
执行:
console.log(func) → [Function: func]
func = 345 → func 现在是 345
func = 123 → func 现在是 123
四、调用栈(Call Stack)
现在理解了执行上下文的概念,我们再来看调用栈------它就是管理多个执行上下文的数据结构。
调用栈是一个**后进先出(LIFO)**的栈,栈顶永远指向当前正在执行的函数。我们用上面 fn(3) 的例子来追踪调用栈的变化:
ini
① 初始状态:全局执行上下文在栈底
┌──────────────────────┐
│ 全局执行上下文 │ ← 栈顶 & 栈底
│ a = 1, fn = func │
└──────────────────────┘
② 执行 fn(3),fn 的上下文压入栈顶
┌──────────────────────┐
│ fn 执行上下文 │ ← 栈顶(正在执行)
│ a = [Function: a] │
│ b = undefined │
├──────────────────────┤
│ 全局执行上下文 │ ← 暂时挂起
└──────────────────────┘
③ fn 执行完毕,fn 的上下文弹出并销毁
┌──────────────────────┐
│ 全局执行上下文 │ ← 栈顶(继续执行全局代码)
└──────────────────────┘
核心规则就三条:
- 编译总是发生在执行的前一刻------JS 是"先编译,再执行"
- 全局代码和每个函数调用都会生成执行上下文并压入调用栈
- 函数执行完毕后,它的执行上下文弹出栈并销毁
调用栈就像一叠盘子:你只能往最上面放盘子(函数调用),也只能从最上面取盘子(函数返回)。最底下的盘子(全局上下文)要等程序结束时才拿走。
五、var 与函数作用域
var 声明的变量只有函数作用域,没有块级作用域。这是很多 bug 的根源:
js
function varTest() {
var x = 1;
if (true) {
var x = 2; // 和外面的 x 是同一个变量!
console.log(x); // 2
}
console.log(x); // 2(不是 1!)
}
varTest();
// 输出:2 2
if 的花括号 {} 对 var 来说形同虚设,里面的 var x = 2 修改的就是函数作用域中唯一的那个 x。
六、let / const 与块级作用域
ES6 引入了 let 和 const,它们支持块级作用域(Block Scope) ,任何一对 {} 都会形成一个独立的作用域。
6.1 基础对比
js
function varTest() {
var x = 1;
if (true) {
let x = 2; // 这是一个全新的 x,仅在当前 {} 内有效
console.log(x); // 2
}
console.log(x); // 1(外面的 x 不受影响)
}
varTest();
// 输出:2 1
6.2 深入:var 与 let 在同一个函数中混用
这块最容易混淆,我们用一个稍复杂的例子彻底讲清楚:
js
function foo() {
var a = 1; // 变量环境 --- 函数作用域
let b = 2; // 词法环境 --- 函数作用域
{fined
let b = 3; // 新建词法环境 --- 块级作用域,覆盖外层的 b
var c = 4; // var 无视块,提升到函数作用域
let d = 5; // 块级作用域,仅在此 {} 内有效
console.log(a); // 1 ← 当前块没有 a,沿作用域链向外找
console.log(b); // 3 ← 当前块有自己的 b,直接用
}
console.log(b); // 2 ← 离开块,块内的 b 失效
console.log(c); // 4 ← var 被提升到函数作用域,块外也能访问
console.log(d); // ❌ ReferenceError: d is not defined
}
foo();
输出:
vbnet
1
3
2
4
ReferenceError: d is not defined
变量的查找顺序 是这样的:引擎从当前代码所在的词法环境 开始查找,如果没找到,就沿着 outer 引用 往外一层找,直到全局作用域。不同层级的 {} 产生不同的词法环境,这些环境通过 outer 引用形成一条链------这就是常说的作用域链。
一句话总结 :
var无视{},始终归属于最近的函数 ;let/const严格限制在当前{}内。
七、暂时性死区(TDZ)
7.1 纠正一个常见误区
你可能听过「let 和 const 不会变量提升」------这个说法不准确。
let 和 const 也会被提升(hoist) ,但与 var 有本质区别:
| 特性 | var | let / const |
|---|---|---|
| 是否提升 | ✅ 是 | ✅ 是 |
| 提升后的状态 | 初始化为 undefined |
未初始化(uninitialized) |
| 声明前访问 | 返回 undefined |
抛出 ReferenceError |
从进入作用域到 let/const 声明语句实际执行之前的这段时间,变量处于暂时性死区(Temporal Dead Zone, TDZ),任何访问都会报错:
js
console.log(x); // undefined --- var 提升且初始化为 undefined
var x = 1;
console.log(y); // ❌ ReferenceError: Cannot access 'y' before initialization
let y = 2; // y 被提升了,但处于 TDZ,还不能用
7.2 不可重复声明
js
let a = 1;
function a() {} // ❌ SyntaxError: Identifier 'a' has already been declared
var 允许反复声明同一个变量名,而 let/const 在同一个作用域内完全禁止重复声明。
let和const的 TDZ、块级作用域、不可重复声明等设计,本质上都是在修正var在 JavaScript 早期设计中的历史遗留问题,让代码行为更可控、更符合直觉。
八、完整知识图谱
javascript
JavaScript 执行机制
│
├── 执行上下文(Execution Context)
│ ├── 变量环境 → 存 var / function
│ ├── 词法环境 → 存 let / const,支持块级作用域
│ └── 可执行代码
│
├── 编译阶段(发生在执行前一刻)
│ ├── ① 创建执行上下文对象
│ ├── ② 形参 + var 声明的变量名 → 值设为 undefined
│ ├── ③ 统一形参与实参(仅函数体内有此步骤)
│ └── ④ 函数声明 → 函数名 = 函数体(优先级最高)
│
├── 调用栈(管理多个执行上下文)
│ ├── LIFO 结构,栈顶 = 当前正在执行
│ └── 函数调用 = 压入,函数返回 = 弹出并销毁
│
├── var 特性
│ ├── 编译阶段提升 + 初始化为 undefined
│ ├── 函数作用域({} 无fined法限制它)
│ └── 允许重复声明
│
└── let / const 特性
├── 也会提升,但不初始化 → 暂时性死区(TDZ)
├── 块级作用域(每个 {} 都是独立的作用域)
└── 同一作用域内禁止重复声明
九、面试高频考题自测
题目 1:变量提升优先级
js
console.log(a);
var a = 1;
function a() {}
console.log(a);
点击查看答案
输出:
csharp
[Function: a]
1
解析 :编译阶段函数声明覆盖 var 声明,第一个 console.log(a) 输出函数。执行阶段 a = 1 覆盖了函数,第二个 console.log(a) 输出 1。
题目 2:块级作用域经典题
js
for (var i = 0; i < 3; i++) {
setTimeout(() => console.log(i), 0);
}
for (let i = 0; i < 3; i++) {
setTimeout(() => console.log(i), 0);
}
点击查看答案
var 版本输出:3 3 3(循环中共享同一个 i,setTimeout 执行时循环已结束,i = 3)
let 版本输出:0 1 2(每次迭代都创建一个独立的块级作用域,各自持有当时的 i 值)
这道题是面试中的"常青树",本质考察的就是 var 的函数作用域 vs let 的块级作用域。
题目 3:函数形参 + 内部声明
js
function bar(x) {
console.log(x);
var x = 10;
function x() {}
console.log(x);
}
bar(5);
```fined
<details>
<summary>点击查看答案</summary>
**输出**:
Function: x 10
markdown
**解析**:
- 编译阶段:形参 `x = 5` → 函数声明覆盖为 `[Function: x]`
- 执行阶段:第一个 `console.log(x)` 输出函数 → `x = 10` 赋值 → 第二个 `console.log(x)` 输出 `10`
</details>
## 十、总结
1. **JS 先编译再执行**:编译阶段处理声明(var/let/const/function),执行阶段才运行赋值和其他逻辑。
2. **函数声明优先级最高**:编译阶段最后处理函数声明,会覆盖同名的形参和 var 变量。
3. **var 只有函数作用域**:`{}` 块对它无效,容易造成变量污染。
4. **let/const 支持块级作用域**:每个 `{}` 都是独立的作用域,由词法环境实现。
5. **let/const 也会提升**:但不会被初始化为 `undefined`,而是进入暂时性死区(TDZ),声明前访问会报 ReferenceError。
6. **调用栈管理一切**:全局 + 每个函数调用 = 一个执行上下文,压入调用栈,执行完弹出销毁。
---
> 本文所有代码示例均可直接在浏览器控制台或 Node.js 中运行验证。建议你亲手敲一遍,边运行边在脑中模拟编译和执行的过程,理解会深刻很多。