深入理解JavaScript执行机制:从一道经典面试题说起
本文将通过一道经典面试题,深入剖析JavaScript的执行机制,包括调用栈、执行上下文、变量环境、词法环境、编译阶段和执行阶段等核心概念。
Event-loop: JS执行机制-event loop
目录
一、引子:一道看似简单的题目
先看这道题,你能正确说出输出吗?
javascript
var a = 1;
function fn(a) {
console.log(a);
var a = 2;
function a() {};
console.log(a);
}
fn(3);
console.log(a);
输出结果:
csharp
[Function: a]
2
1
如果你答错了,或者不清楚为什么,那么这篇文章将帮助你彻底理解JavaScript的执行机制。
二、JavaScript执行机制的核心概念
2.1 执行上下文(Execution Context)
执行上下文是JavaScript代码执行的环境,它包含了代码执行所需的所有信息。
javascript
ExecutionContext = {
// 词法环境:存储 let/const/函数声明
LexicalEnvironment: {
EnvironmentRecord: { /* 标识符绑定 */ },
outer: <外部环境引用>,
ThisBinding: <this值>
},
// 变量环境:存储 var 声明
VariableEnvironment: {
EnvironmentRecord: { /* var变量 */ },
outer: <外部环境引用>,
ThisBinding: <this值>
}
// 可执行的代码
}
执行上下文的类型
- 全局执行上下文:代码运行时首先创建,全局只有一个
- 函数执行上下文:每次函数调用时创建,可以有无数个
- Eval执行上下文:eval函数内部的代码(不推荐使用)
2.2 调用栈(Call Stack)
调用栈是一个后进先出(LIFO)的数据结构,用于管理执行上下文。
scss
执行前:[Global EC]
调用fn(3):[Global EC, fn EC]
fn执行完:[Global EC]
2.3 词法环境 vs 变量环境
| 特性 | 词法环境 | 变量环境 |
|---|---|---|
| 存储内容 | let、const、函数声明 |
var 声明 |
| 块级作用域 | ✅ 支持 | ❌ 不支持 |
| 暂时性死区 | ✅ 存在 | ❌ 不存在 |
| 初始化 | 不初始化 | 初始化为undefined |
2.4 编译阶段 vs 执行阶段
JavaScript代码执行分为两个阶段:
-
编译阶段(创建阶段):
- 创建执行上下文
- 处理变量声明和函数声明
- 确定作用域链
- 确定this指向
-
执行阶段:
- 逐行执行代码
- 变量赋值
- 函数调用
- 表达式计算
三、编译阶段:代码执行前的准备
让我们从编译阶段开始,看看JavaScript引擎做了什么。
3.1 全局执行上下文的创建
javascript
var a = 1;
function fn(a) {
console.log(a);
var a = 2;
function a() {};
console.log(a);
}
编译阶段(全局):
javascript
GlobalExecutionContext = {
LexicalEnvironment: {
EnvironmentRecord: {
fn: <function fn> // 函数声明完整提升
},
outer: null, // 全局作用域外部为null
ThisBinding: <global object>
},
VariableEnvironment: {
EnvironmentRecord: {
a: undefined // var声明提升,初始化为undefined
},
outer: null,
ThisBinding: <global object>
}
}
关键点:
- ✅
var a声明被提升到变量环境,初始化为undefined - ✅
function fn完整提升到词法环境,可以在声明前调用 - ✅ 此时还没有执行任何赋值操作
3.2 函数执行上下文的创建
当执行到 fn(3) 时,创建函数执行上下文。
javascript
function fn(a) {
console.log(a);
var a = 2;
function a() {};
console.log(a);
}
fn(3); // 调用时触发编译
函数执行上下文的创建过程:
步骤1:处理形参
javascript
FunctionEC = {
VariableEnvironment: {
EnvironmentRecord: {
a: 3 // ① 参数赋值:a = 3
}
}
}
步骤2:处理函数声明(优先级最高!)
javascript
FunctionEC = {
LexicalEnvironment: {
EnvironmentRecord: {
a: <function a> // ② 函数声明:a = function a() {}
}
},
VariableEnvironment: {
EnvironmentRecord: {
// a: 3 被覆盖了!
}
}
}
⚠️ 关键:函数声明会覆盖参数!
步骤3:处理var声明
javascript
// var a = 2;
// 由于 a 已经在词法环境中存在(函数声明),var a 被忽略
// 只保留赋值操作(a = 2)到执行阶段
最终的编译结果:
javascript
FunctionExecutionContext = {
LexicalEnvironment: {
EnvironmentRecord: {
a: <function a> // 函数声明
},
outer: <Global Lexical Environment>,
ThisBinding: <global object>
},
VariableEnvironment: {
EnvironmentRecord: {
// var a 的声明被忽略(a已存在)
},
outer: <Global Variable Environment>,
ThisBinding: <global object>
}
}
3.3 提升优先级规则
csharp
函数声明 > 函数参数 > var声明
详细规则:
- 函数声明:最高优先级,完整提升
- 函数参数:在函数执行上下文中,参数先被赋值
- var声明:如果变量名已存在,声明被忽略,只保留赋值
- let/const:不提升初始化,存在暂时性死区
四、执行阶段:逐行运行代码
编译阶段完成后,进入执行阶段。
4.1 全局代码执行
javascript
// 此时的调用栈:[Global EC]
var a = 1; // 执行赋值:GlobalEC.VariableEnvironment.a = 1
// 从 undefined 变为 1
function fn(a) { ... } // 已在编译阶段处理,跳过
fn(3); // 函数调用,创建新的执行上下文
全局变量a的变化:
ini
编译阶段:a = undefined
执行阶段:a = 1 ← 赋值操作
4.2 函数代码执行
调用 fn(3) 时,进入函数执行上下文。
调用栈变化:
css
[Global EC] → [Global EC, fn EC]
此时fn执行上下文中的a:
javascript
a = function a() {} // 编译阶段的结果
第1行:console.log(a)
javascript
console.log(a); // 输出:[Function: a]
为什么输出函数?
- 编译阶段,函数声明
function a() {}已经覆盖了参数a = 3 - 此时
a的值就是这个函数
变量a的状态:
ini
编译阶段:a = 3 (参数)
↓
编译阶段:a = function a() {} (函数声明覆盖)
↓
执行到这里:a = function a() {}
第2行:var a = 2
javascript
var a = 2;
这行代码的本质:
- 编译阶段:
var a的声明已被忽略(a已存在) - 执行阶段:只执行赋值
a = 2
变量a的变化:
ini
之前:a = function a() {}
执行赋值后:a = 2
第3行:function a() {}
javascript
function a() {};
这行代码的处理:
- 编译阶段已经完整处理
- 执行阶段直接跳过(什么都不做)
第4行:console.log(a)
javascript
console.log(a); // 输出:2
为什么输出2?
var a = 2已经将a的值改为2- 此时输出的就是数字2
变量a的完整生命周期:
css
编译阶段:
步骤1:a = 3 (参数)
步骤2:a = function a() {} (函数声明覆盖)
步骤3:var a 被忽略
执行阶段:
第1行:console.log(a) → 输出 function a() {}
第2行:a = 2 (赋值)
第3行:function a() {} (跳过)
第4行:console.log(a) → 输出 2
4.3 函数执行完毕
javascript
fn(3); // 函数执行完毕,出栈
调用栈变化:
css
[Global EC, fn EC] → [Global EC]
函数执行上下文被销毁,局部变量a也随之消失。
4.4 继续执行全局代码
javascript
console.log(a); // 输出:1
为什么输出1?
- 此时访问的是全局变量a
- 全局变量a的值在执行
var a = 1时被赋值为1 - 函数内部的a已经随函数执行上下文销毁
五、调用栈的完整变化过程
让我们用可视化的方式看看调用栈的变化。
5.1 时间线视图
yaml
时刻0:代码开始执行
┌─────────────────────────┐
│ Global EC │
│ VariableEnvironment: │
│ a: undefined │
│ LexicalEnvironment: │
│ fn: <function> │
└─────────────────────────┘
时刻1:var a = 1 执行
┌─────────────────────────┐
│ Global EC │
│ VariableEnvironment: │
│ a: 1 ← 赋值 │
│ LexicalEnvironment: │
│ fn: <function> │
└─────────────────────────┘
时刻2:fn(3) 调用 - 创建函数执行上下文
┌─────────────────────────┐
│ fn EC │
│ LexicalEnvironment: │
│ a: function a() {} │ ← 编译阶段的结果
│ outer: Global EC │
└─────────────────────────┘
┌─────────────────────────┐
│ Global EC │
│ a: 1 │
└─────────────────────────┘
时刻3:第一个 console.log(a)
┌─────────────────────────┐
│ fn EC │
│ a: function a() {} │ ← 输出这个
└─────────────────────────┘
┌─────────────────────────┐
│ Global EC │
│ a: 1 │
└─────────────────────────┘
输出:[Function: a]
时刻4:var a = 2 执行(只执行赋值)
┌─────────────────────────┐
│ fn EC │
│ a: 2 ← 赋值改变 │
└─────────────────────────┘
┌─────────────────────────┐
│ Global EC │
│ a: 1 │
└─────────────────────────┘
时刻5:第二个 console.log(a)
┌─────────────────────────┐
│ fn EC │
│ a: 2 ← 输出这个 │
└─────────────────────────┘
┌─────────────────────────┐
│ Global EC │
│ a: 1 │
└─────────────────────────┘
输出:2
时刻6:fn执行完毕,出栈
┌─────────────────────────┐
│ Global EC │
│ a: 1 │
└─────────────────────────┘
时刻7:最后的 console.log(a)
┌─────────────────────────┐
│ Global EC │
│ a: 1 ← 输出这个 │
└─────────────────────────┘
输出:1
5.2 作用域链的查找过程
javascript
function fn(a) {
console.log(a); // 在当前fn EC中查找a ✅ 找到
var a = 2;
console.log(a); // 在当前fn EC中查找a ✅ 找到
}
fn(3);
console.log(a); // 在Global EC中查找a ✅ 找到
查找规则:
- 首先在当前执行上下文的词法环境中查找
- 如果找不到,通过outer引用到外层环境查找
- 一直查找到全局执行上下文
- 如果还找不到,返回ReferenceError
作用域链:
sql
fn EC → Global EC → null
六、完整的执行流程可视化
6.1 内存结构图
yaml
┌───────────────────────────────────────────────────┐
│ 堆内存 (Heap) │
├───────────────────────────────────────────────────┤
│ 0x001: function fn(a) { ... } │
│ 0x002: function a() {} │
└───────────────────────────────────────────────────┘
┌───────────────────────────────────────────────────┐
│ 调用栈 (Call Stack) │
├───────────────────────────────────────────────────┤
│ ┌─────────────────────────────────────────────┐ │
│ │ Function EC (fn) │ │
│ │ ───────────────────────────────────────── │ │
│ │ LexicalEnvironment: │ │
│ │ a: 0x002 → [赋值后] → 2 │ │
│ │ outer: → Global EC │ │
│ └─────────────────────────────────────────────┘ │
│ ┌─────────────────────────────────────────────┐ │
│ │ Global EC │ │
│ │ ───────────────────────────────────────── │ │
│ │ VariableEnvironment: │ │
│ │ a: undefined → 1 │ │
│ │ LexicalEnvironment: │ │
│ │ fn: 0x001 │ │
│ └─────────────────────────────────────────────┘ │
└───────────────────────────────────────────────────┘
6.2 完整的执行流程图
javascript
开始
↓
┌────────────────────────────────────┐
│ 全局编译阶段 │
│ - 创建Global EC │
│ - var a: undefined │
│ - function fn: <function> │
└────────────────────────────────────┘
↓
┌────────────────────────────────────┐
│ 全局执行阶段 │
│ - var a = 1 (赋值) │
│ - 调用 fn(3) │
└────────────────────────────────────┘
↓
┌────────────────────────────────────┐
│ 函数编译阶段 │
│ - 创建fn EC │
│ - 参数: a = 3 │
│ - 函数声明: a = function a() {} │
│ (覆盖参数!) │
│ - var a 声明被忽略 │
└────────────────────────────────────┘
↓
┌────────────────────────────────────┐
│ 函数执行阶段 │
│ - console.log(a) │
│ → 输出 [Function: a] │
│ - var a = 2 (赋值) │
│ - function a() {} (跳过) │
│ - console.log(a) │
│ → 输出 2 │
└────────────────────────────────────┘
↓
┌────────────────────────────────────┐
│ 函数执行完毕 │
│ - fn EC 出栈销毁 │
│ - 返回Global EC │
└────────────────────────────────────┘
↓
┌────────────────────────────────────┐
│ 继续全局执行 │
│ - console.log(a) │
│ → 输出 1 │
└────────────────────────────────────┘
↓
结束
七、深度思考与扩展
7.1 为什么要分编译阶段和执行阶段?
性能优化:
javascript
function hotFunction() {
// 这个函数被频繁调用
let x = 1;
let y = 2;
return x + y;
}
// V8引擎的处理:
// 1. 第一次:解释执行(编译 → 执行)
// 2. 多次调用后:识别为热点代码
// 3. TurboFan优化:编译为优化的机器码
// 4. 后续调用:直接执行机器码(超快!)
错误提前发现:
javascript
// 编译阶段就能发现语法错误
function test() {
let x = 1;
let x = 2; // SyntaxError: Identifier 'x' has already been declared
}
// 不需要等到执行到这一行才报错
7.2 变量提升的设计初衷
函数提升的优点:
javascript
// 可以先调用后声明,代码组织更灵活
init();
run();
cleanup();
function init() { /* ... */ }
function run() { /* ... */ }
function cleanup() { /* ... */ }
// 主逻辑在上方,实现细节在下方,可读性更好
var提升的问题:
javascript
console.log(x); // undefined(不会报错,容易产生bug)
var x = 1;
// 如果用let/const
console.log(y); // ReferenceError(立即发现问题)
let y = 1;
ES6的改进:let/const
- 引入暂时性死区(TDZ)
- 强制先声明后使用
- 减少隐藏bug
7.3 闭包与执行上下文的关系
javascript
function createCounter() {
let count = 0; // 存储在createCounter的词法环境
return function increment() {
count++;
console.log(count);
};
}
const counter = createCounter();
counter(); // 1
counter(); // 2
// 为什么count没有被销毁?
// 因为increment函数的outer引用指向createCounter的词法环境
// 形成闭包,保持了对count的引用
闭包的本质:
sql
increment EC
↓ outer
createCounter EC (已执行完,但未销毁)
↓ outer
Global EC
7.4 this绑定与执行上下文
javascript
const obj = {
name: 'obj',
method: function() {
console.log(this.name);
function inner() {
console.log(this.name); // undefined (this指向global)
}
const arrow = () => {
console.log(this.name); // 'obj' (箭头函数继承外层this)
};
inner();
arrow();
}
};
obj.method();
this绑定时机:
- 普通函数:执行时确定(动态绑定)
- 箭头函数:定义时确定(词法绑定)
7.5 常见面试陷阱
陷阱1:函数声明的位置
javascript
function fn(a) {
console.log(a); // ?
var a = 2;
console.log(a); // ?
function a() {};
console.log(a); // ?
}
fn(3);
// 输出:
// [Function: a]
// 2
// 2 ← 注意:不是函数!
// 原因:函数声明在编译阶段就处理了
// 执行阶段function a() {} 这一行什么都不做
陷阱2:重复声明
javascript
function fn() {
console.log(x); // ?
var x = 1;
var x = 2;
console.log(x); // ?
}
fn();
// 输出:
// undefined
// 2
// 解析:
// 编译阶段:var x (只声明一次)
// 执行阶段:x = 1, x = 2 (两次赋值)
陷阱3:参数与var
javascript
function fn(a) {
console.log(a); // ?
var a;
console.log(a); // ?
}
fn(1);
// 输出:
// 1
// 1
// 解析:
// var a 的声明被忽略(a作为参数已存在)
// var a; 这行相当于什么都不做
7.6 与其他语言的对比
JavaScript(变量提升):
javascript
console.log(x); // undefined
var x = 1;
Java(不允许):
java
System.out.println(x); // 编译错误
int x = 1;
Python(不允许):
python
print(x) # NameError
x = 1
JavaScript的变量提升是一种特殊机制,理解它是掌握JS的关键。
八、总结与实战建议
8.1 核心概念总结
kotlin
JavaScript执行机制
│
├─ 执行上下文
│ ├─ 全局执行上下文(只有一个)
│ ├─ 函数执行上下文(每次调用创建)
│ └─ Eval执行上下文(不推荐)
│
├─ 执行上下文的组成
│ ├─ 词法环境(LexicalEnvironment)
│ │ └─ 存储 let/const/函数声明
│ ├─ 变量环境(VariableEnvironment)
│ │ └─ 存储 var 声明
│ └─ this绑定
│
├─ 执行阶段
│ ├─ 编译阶段(创建阶段)
│ │ ├─ 创建执行上下文
│ │ ├─ 处理声明(提升)
│ │ ├─ 确定作用域链
│ │ └─ 确定this
│ └─ 执行阶段
│ ├─ 逐行执行代码
│ ├─ 变量赋值
│ └─ 函数调用
│
└─ 调用栈(Call Stack)
└─ 管理所有执行上下文(LIFO)
8.2 提升规则总结
javascript
// 提升优先级(从高到低)
1. 函数声明 → 完整提升
2. 函数参数 → 赋值
3. var声明 → 提升并初始化为undefined
4. let/const声明 → 提升但不初始化(TDZ)
// 覆盖规则
函数声明 > 参数 > var声明
8.3 变量a的完整生命周期(回到原题)
javascript
var a = 1;
function fn(a) {
console.log(a);
var a = 2;
function a() {};
console.log(a);
}
fn(3);
console.log(a);
全局变量a:
ini
编译:a = undefined
执行:a = 1
最终:a = 1
函数参数a:
ini
编译:a = 3 (参数)
↓
a = function a() {} (函数声明覆盖)
↓
var a 被忽略
执行:a = function a() {} (第一个console.log)
↓
a = 2 (赋值)
↓
a = 2 (第二个console.log)
8.4 实战建议
1. 避免变量提升带来的问题
javascript
// ❌ 不好的写法
console.log(x);
var x = 1;
// ✅ 好的写法
let x = 1;
console.log(x);
2. 使用let/const代替var
javascript
// ❌ 不好的写法
for (var i = 0; i < 3; i++) {
setTimeout(() => console.log(i), 0);
}
// 输出:3 3 3
// ✅ 好的写法
for (let i = 0; i < 3; i++) {
setTimeout(() => console.log(i), 0);
}
// 输出:0 1 2
3. 函数声明 vs 函数表达式
javascript
// ✅ 函数声明:可以先调用后声明
foo();
function foo() {}
// ❌ 函数表达式:必须先声明后调用
bar(); // TypeError
var bar = function() {};
// ✅ 正确写法
const bar = function() {};
bar();
4. 理解作用域链
javascript
// 理解变量查找规则
function outer() {
let x = 1;
function inner() {
let y = 2;
console.log(x); // 1 (沿作用域链向上查找)
console.log(y); // 2 (当前作用域)
}
inner();
// console.log(y); // ReferenceError (y不在作用域链上)
}
5. 利用闭包保存状态
javascript
function createCounter() {
let count = 0;
return {
increment() {
return ++count;
},
decrement() {
return --count;
},
getCount() {
return count;
}
};
}
const counter = createCounter();
console.log(counter.increment()); // 1
console.log(counter.increment()); // 2
console.log(counter.getCount()); // 2
8.5 面试答题模板
问:请解释这段代码的输出结果
答题框架:
-
分析编译阶段
- 识别所有声明
- 确定提升规则
- 说明优先级
-
分析执行阶段
- 逐行执行
- 说明每次赋值的影响
- 解释输出结果
-
总结关键点
- 强调核心概念
- 说明易错点
示例回答:
这段代码涉及变量提升和函数声明提升。在编译阶段,全局的var a被提升并初始化为undefined,function fn被完整提升。当调用fn(3)时,创建函数执行上下文,参数a=3,但函数声明function a(){}的优先级更高,会覆盖参数。因此第一个console.log输出函数,var a = 2执行赋值后,第二个console.log输出2。最后回到全局作用域,输出全局的a=1。
8.6 延伸阅读
-
V8引擎的优化技术
- 隐藏类(Hidden Classes)
- 内联缓存(Inline Caching)
- TurboFan编译器
-
事件循环机制
- 宏任务与微任务
- Promise执行时机
- async/await原理
-
内存管理
- 垃圾回收算法
- 内存泄漏排查
- 性能优化
-
ES6+新特性
- 块级作用域
- 箭头函数
- 类和继承
结语
JavaScript的执行机制看似复杂,但只要理解了执行上下文 、调用栈 、编译阶段 和执行阶段这些核心概念,就能透彻理解代码的运行过程。
记住这几个关键点:
- 编译阶段:处理声明,建立环境
- 执行阶段:逐行执行,变量赋值
- 调用栈:管理执行上下文,后进先出
- 作用域链:变量查找的路径
- 提升规则:函数声明 > 参数 > var声明
掌握这些知识,不仅能轻松应对面试,更能写出高质量的JavaScript代码!
参考资源:
附录:完整的测试代码
javascript
// ============================================
// 原始题目
// ============================================
var a = 1;
function fn(a) {
console.log(a);
var a = 2;
function a() {};
console.log(a);
}
fn(3);
console.log(a);
// 输出:
// [Function: a]
// 2
// 1
// ============================================
// 变体1:改变函数声明位置
// ============================================
function test1(a) {
function a() {}; // 函数声明提升,位置不影响结果
console.log(a);
var a = 2;
console.log(a);
}
test1(3);
// 输出:
// [Function: a]
// 2
// ============================================
// 变体2:多个函数声明
// ============================================
function test2(a) {
function a() { return 1; };
function a() { return 2; }; // 后声明的覆盖前面的
console.log(a);
}
test2(3);
// 输出:
// [Function: a] (return 2的那个)
// ============================================
// 变体3:使用let/const
// ============================================
function test3(a) {
console.log(a); // 3 (参数)
// let a = 2; // SyntaxError: Identifier 'a' has already been declared
// const a = 2; // SyntaxError: Identifier 'a' has already been declared
}
test3(3);
// ============================================
// 变体4:没有函数声明
// ============================================
function test4(a) {
console.log(a); // 3
var a = 2;
console.log(a); // 2
}
test4(3);
// 输出:
// 3
// 2
运行上述代码,验证你的理解! 🚀