Appearance
JavaScript 中的执行上下文、调用栈与事件循环
很多 JavaScript 问题,最后都会落到一句话:这段代码到底是按什么顺序执行的。
如果不理解执行上下文、调用栈和事件循环,很多现象都只能靠记结论。
1. JavaScript 为什么要讲执行机制
因为 JavaScript 不只是“把代码从上往下读一遍”那么简单。
真实执行过程中,至少会涉及:
- 变量和函数先如何进入可访问状态
- 当前到底是谁在执行
- 函数调用时上下文如何入栈和出栈
- 异步任务为什么不会立刻执行
2. 执行上下文是什么
执行上下文可以看成 一段代码在真正开始执行时,JavaScript 引擎为它准备好的运行环境。
这套环境里通常至少要解决几件事:
- 当前作用域里有哪些变量
- 当前函数的
this应该是什么 - 当前代码接下来如何被执行
常见执行上下文主要包括:
- 全局执行上下文
- 函数执行上下文
eval执行上下文
工程里最常用到的,主要是前两个。
2.1 执行上下文不是“一创建就立刻逐行跑代码”
更接近真实过程的理解方式通常是分成两个阶段:
创建阶段执行阶段
创建阶段更关注:
- 当前作用域里有哪些声明
- 函数声明应该先绑定到哪里
var、let、const应该如何进入环境记录- 当前函数的
this如何确定
执行阶段才会真正开始:
- 从上到下执行语句
- 进行赋值
- 调用函数
- 计算表达式
也就是说:很多看起来像“执行时突然发生的现象”,其实根源在执行前的创建阶段。
2.2 可以把执行上下文想成一张运行时表格
如果把它讲得更直观一点,执行上下文可以粗略理解成一张运行时登记表:
| 关注点 | 它在处理什么 |
|---|---|
| 变量环境 | 当前有哪些标识符已经进入可访问体系 |
| 词法环境 | 当前作用域链应该如何向外查找 |
this 绑定 | 当前函数执行时上下文对象是什么 |
| 代码执行指针 | 当前代码从哪里开始往下执行 |
这张表不是让你背规范术语,而是帮助你建立一个稳定认识:JavaScript 在执行代码前,必须把“当前环境里能看到什么”整理清楚。
3. 为什么会有“提升”
很多人把提升理解成“变量真的被提到上面去了”。
实际写项目时,更多是这样:代码执行前,执行上下文会先建立当前作用域里的声明信息。
这就是为什么:
- 函数声明在定义前就能调用
var声明的变量在赋值前访问到的是undefinedlet和const虽然也会先进入环境记录,但在初始化前不能直接访问
3.1 变量提升到底提升了什么
“提升”最容易让人误会的地方在于,好像所有代码都会被整体搬到上面。
实际上,不同声明进入创建阶段后的状态并不一样:
| 写法 | 创建阶段发生什么 | 在赋值前访问会怎样 |
|---|---|---|
function foo() {} | 函数声明整体可用 | 可以直接调用 |
var a | 变量名已登记,初始值是 undefined | 得到 undefined |
let b | 变量名已登记,但未初始化 | 报错 |
const c | 变量名已登记,但未初始化 | 报错 |
例如:
js
console.log(a) // undefined
var a = 1
foo() // ok
function foo() {
console.log('run foo')
}
console.log(b) // ReferenceError
let b = 2真正要记住的不是“谁被搬上去了”,而是:创建阶段先登记了声明,但不同声明被登记后的可用状态并不一样。
3.2 let 和 const 为什么会有暂时性死区
很多人会把暂时性死区理解成“let 和 const 没有提升”。
这不准确。
把这件事说完整一点:
let和const也会在创建阶段进入当前词法环境- 但在真正执行到声明语句之前,它们都处于“未初始化”状态
- 这个从作用域开始到变量初始化之前的区域,就是暂时性死区
例如:
js
{
// 这里已经进入了 block 的词法环境
// 但 value 还没有初始化
console.log(value) // ReferenceError
let value = 1
}所以工程上更准确的说法应该是:let / const 不是没有提升,而是提升后不会像 var 那样直接给你一个可读的 undefined。
3.3 为什么函数声明和函数表达式表现不一样
这是变量提升里非常高频、也很容易混的一个点。
js
foo() // ok
function foo() {
console.log('foo')
}
bar() // TypeError: bar is not a function
var bar = function () {
console.log('bar')
}它们差异的根源不是“长得像函数”,而是:
function foo() {}是函数声明,创建阶段就已经把函数本体绑定好了var bar = function () {}本质上是var bar加一次赋值- 创建阶段里
bar只有undefined,函数表达式本体要等执行到赋值语句才会出现
所以:看到函数能不能提前调用,先分清它到底是函数声明,还是函数表达式。
4. 调用栈到底在做什么
调用栈可以看成 当前有哪些执行上下文正在等待执行或返回。
后进入的函数,会先执行完再退出,这就是典型的“后进先出”。
js
function a() {
b()
}
function b() {
c()
}
function c() {
console.log('run c')
}
a()这段代码执行时,大致就是:
- 全局上下文入栈
a入栈b入栈c入栈c执行完成出栈b出栈a出栈
5. 为什么 JavaScript 常被说成“单线程”
可以直接把它理解成:同一个 JavaScript 执行线程里,同一时刻主要只有一段代码在主调用栈上执行。
这并不意味着整个浏览器世界只有一个线程,而是说:
- JavaScript 主线程执行是串行的
- 浏览器或运行时可以在外部帮你处理定时器、网络、事件监听等任务
- 等条件成熟后,再把回调放回可执行队列中等待主线程处理
6. 事件循环到底在解决什么问题
事件循环可以看成 当同步代码执行完后,JavaScript 如何继续调度那些暂时不能立即执行的任务。
如果没有这套机制,异步编程就很难成立。
7. 宏任务和微任务怎么理解
7.1 宏任务
可以看成 一轮较完整的任务单元。
常见来源包括:
- 整体脚本执行
setTimeoutsetInterval- I/O 回调
7.2 微任务
可以看成 优先级更高、会在当前宏任务结束后尽快清空的一批任务。
常见来源包括:
Promise.thenPromise.catchqueueMicrotaskMutationObserver
8. 一轮事件循环的大致顺序
可以按这条主线理解:
- 执行一个宏任务
- 当前宏任务里的同步代码先跑完
- 清空当前轮次产生的微任务队列
- 浏览器在合适时机做渲染
- 再进入下一轮宏任务
mermaid
flowchart TD
A[开始一轮宏任务] --> B[执行同步代码]
B --> C[当前调用栈清空]
C --> D[依次清空微任务]
D --> E[浏览器有机会做渲染]
E --> F[进入下一轮宏任务]9. 一个典型例子
js
console.log('start')
setTimeout(() => {
console.log('timeout')
}, 0)
Promise.resolve().then(() => {
console.log('promise')
})
console.log('end')输出顺序通常是:
js
start
end
promise
timeout原因可以拆成:
start和end是当前宏任务里的同步代码,先执行Promise.then进入微任务队列setTimeout回调进入后续宏任务队列- 当前宏任务结束后,先清空微任务
- 再等下一轮宏任务时执行
timeout
10. 为什么异步代码看起来“跳来跳去”
根本原因通常不是代码乱,而是:
- 同步代码受调用栈控制
- 异步回调由任务队列和事件循环调度
- 微任务和宏任务优先级不同
11. 工程上最容易踩的坑
11.1 把 setTimeout(fn, 0) 理解成“立刻执行”
它不是立刻执行,而是:最早在当前宏任务和微任务都处理完之后,再等待进入后续宏任务。
11.2 以为 await 会让整个程序同步阻塞
await 只是让当前异步函数后续逻辑以更自然的方式等待 Promise 结果,它不等于把事件循环停住。
11.3 解释执行顺序时只看代码上下顺序
真正要同时看:
- 当前是同步代码还是异步回调
- 异步回调属于宏任务还是微任务
- 当前调用栈是否已经清空
12. 一张总图
mermaid
flowchart TD
A[代码开始执行] --> B[创建执行上下文]
B --> C[进入调用栈]
C --> D[执行同步代码]
D --> E[遇到异步任务交给运行时]
E --> F[同步代码执行完]
F --> G[清空微任务]
G --> H[进入下一轮宏任务]13. 小结
把这条主线压缩起来,可以记住:
- 执行上下文负责准备代码的运行环境
- 调用栈负责管理函数调用顺序
- 事件循环负责在同步代码结束后调度异步任务
- 微任务通常比后续宏任务更早执行
下一步如果继续往下看,最自然的是进入 Promise、async/await 与异步编程,因为那部分就是建立在这里的执行机制之上的。