Skip to content

JavaScript 中的执行上下文、调用栈与事件循环

很多 JavaScript 问题,最后都会落到一句话:这段代码到底是按什么顺序执行的。

如果不理解执行上下文、调用栈和事件循环,很多现象都只能靠记结论。

1. JavaScript 为什么要讲执行机制

因为 JavaScript 不只是“把代码从上往下读一遍”那么简单。

真实执行过程中,至少会涉及:

  1. 变量和函数先如何进入可访问状态
  2. 当前到底是谁在执行
  3. 函数调用时上下文如何入栈和出栈
  4. 异步任务为什么不会立刻执行

2. 执行上下文是什么

执行上下文可以看成 一段代码在真正开始执行时,JavaScript 引擎为它准备好的运行环境

这套环境里通常至少要解决几件事:

  1. 当前作用域里有哪些变量
  2. 当前函数的 this 应该是什么
  3. 当前代码接下来如何被执行

常见执行上下文主要包括:

  1. 全局执行上下文
  2. 函数执行上下文
  3. eval 执行上下文

工程里最常用到的,主要是前两个。

2.1 执行上下文不是“一创建就立刻逐行跑代码”

更接近真实过程的理解方式通常是分成两个阶段:

  1. 创建阶段
  2. 执行阶段

创建阶段更关注:

  1. 当前作用域里有哪些声明
  2. 函数声明应该先绑定到哪里
  3. varletconst 应该如何进入环境记录
  4. 当前函数的 this 如何确定

执行阶段才会真正开始:

  1. 从上到下执行语句
  2. 进行赋值
  3. 调用函数
  4. 计算表达式

也就是说:很多看起来像“执行时突然发生的现象”,其实根源在执行前的创建阶段。

2.2 可以把执行上下文想成一张运行时表格

如果把它讲得更直观一点,执行上下文可以粗略理解成一张运行时登记表:

关注点它在处理什么
变量环境当前有哪些标识符已经进入可访问体系
词法环境当前作用域链应该如何向外查找
this 绑定当前函数执行时上下文对象是什么
代码执行指针当前代码从哪里开始往下执行

这张表不是让你背规范术语,而是帮助你建立一个稳定认识:JavaScript 在执行代码前,必须把“当前环境里能看到什么”整理清楚。

3. 为什么会有“提升”

很多人把提升理解成“变量真的被提到上面去了”。

实际写项目时,更多是这样:代码执行前,执行上下文会先建立当前作用域里的声明信息。

这就是为什么:

  1. 函数声明在定义前就能调用
  2. var 声明的变量在赋值前访问到的是 undefined
  3. letconst 虽然也会先进入环境记录,但在初始化前不能直接访问

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 letconst 为什么会有暂时性死区

很多人会把暂时性死区理解成“letconst 没有提升”。

这不准确。

把这件事说完整一点:

  1. letconst 也会在创建阶段进入当前词法环境
  2. 但在真正执行到声明语句之前,它们都处于“未初始化”状态
  3. 这个从作用域开始到变量初始化之前的区域,就是暂时性死区

例如:

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')
}

它们差异的根源不是“长得像函数”,而是:

  1. function foo() {} 是函数声明,创建阶段就已经把函数本体绑定好了
  2. var bar = function () {} 本质上是 var bar 加一次赋值
  3. 创建阶段里 bar 只有 undefined,函数表达式本体要等执行到赋值语句才会出现

所以:看到函数能不能提前调用,先分清它到底是函数声明,还是函数表达式。

4. 调用栈到底在做什么

调用栈可以看成 当前有哪些执行上下文正在等待执行或返回

后进入的函数,会先执行完再退出,这就是典型的“后进先出”。

js
function a() {
  b()
}

function b() {
  c()
}

function c() {
  console.log('run c')
}

a()

这段代码执行时,大致就是:

  1. 全局上下文入栈
  2. a 入栈
  3. b 入栈
  4. c 入栈
  5. c 执行完成出栈
  6. b 出栈
  7. a 出栈

5. 为什么 JavaScript 常被说成“单线程”

可以直接把它理解成:同一个 JavaScript 执行线程里,同一时刻主要只有一段代码在主调用栈上执行。

这并不意味着整个浏览器世界只有一个线程,而是说:

  1. JavaScript 主线程执行是串行的
  2. 浏览器或运行时可以在外部帮你处理定时器、网络、事件监听等任务
  3. 等条件成熟后,再把回调放回可执行队列中等待主线程处理

6. 事件循环到底在解决什么问题

事件循环可以看成 当同步代码执行完后,JavaScript 如何继续调度那些暂时不能立即执行的任务

如果没有这套机制,异步编程就很难成立。

7. 宏任务和微任务怎么理解

7.1 宏任务

可以看成 一轮较完整的任务单元

常见来源包括:

  1. 整体脚本执行
  2. setTimeout
  3. setInterval
  4. I/O 回调

7.2 微任务

可以看成 优先级更高、会在当前宏任务结束后尽快清空的一批任务

常见来源包括:

  1. Promise.then
  2. Promise.catch
  3. queueMicrotask
  4. MutationObserver

8. 一轮事件循环的大致顺序

可以按这条主线理解:

  1. 执行一个宏任务
  2. 当前宏任务里的同步代码先跑完
  3. 清空当前轮次产生的微任务队列
  4. 浏览器在合适时机做渲染
  5. 再进入下一轮宏任务
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

原因可以拆成:

  1. startend 是当前宏任务里的同步代码,先执行
  2. Promise.then 进入微任务队列
  3. setTimeout 回调进入后续宏任务队列
  4. 当前宏任务结束后,先清空微任务
  5. 再等下一轮宏任务时执行 timeout

10. 为什么异步代码看起来“跳来跳去”

根本原因通常不是代码乱,而是:

  1. 同步代码受调用栈控制
  2. 异步回调由任务队列和事件循环调度
  3. 微任务和宏任务优先级不同

11. 工程上最容易踩的坑

11.1 把 setTimeout(fn, 0) 理解成“立刻执行”

它不是立刻执行,而是:最早在当前宏任务和微任务都处理完之后,再等待进入后续宏任务。

11.2 以为 await 会让整个程序同步阻塞

await 只是让当前异步函数后续逻辑以更自然的方式等待 Promise 结果,它不等于把事件循环停住。

11.3 解释执行顺序时只看代码上下顺序

真正要同时看:

  1. 当前是同步代码还是异步回调
  2. 异步回调属于宏任务还是微任务
  3. 当前调用栈是否已经清空

12. 一张总图

mermaid
flowchart TD
    A[代码开始执行] --> B[创建执行上下文]
    B --> C[进入调用栈]
    C --> D[执行同步代码]
    D --> E[遇到异步任务交给运行时]
    E --> F[同步代码执行完]
    F --> G[清空微任务]
    G --> H[进入下一轮宏任务]

13. 小结

把这条主线压缩起来,可以记住:

  1. 执行上下文负责准备代码的运行环境
  2. 调用栈负责管理函数调用顺序
  3. 事件循环负责在同步代码结束后调度异步任务
  4. 微任务通常比后续宏任务更早执行

下一步如果继续往下看,最自然的是进入 Promise、async/await 与异步编程,因为那部分就是建立在这里的执行机制之上的。

基于 VitePress 构建的个人技术笔记。