深入理解 JavaScript 事件循环
本文核心:什么是事件循环?它如何让 JavaScript 做到"永不阻塞"?
JavaScript 是单线程的,但网页需要同时处理点击、网络请求、定时器、动画渲染等无数任务。事件循环(Event Loop)就是这套调度机制的核心------它决定了代码的执行顺序,也保证了页面不会因为等待某个操作完成而卡死。
本文将从引擎与宿主的关系讲起,逐步深入到 Agent、调用栈、任务队列、微任务等核心概念,最终完整呈现事件循环的执行模型。读完本文,你将彻底理解:为什么
setTimeout(fn, 0)不会立刻执行,为什么Promise.then总是先于它执行,以及页面卡顿的根本原因是什么。
引擎和宿主
JavaScript 引擎
JavaScript 引擎的核心职责是解析、编译并执行 JavaScript 代码。它负责处理语法、管理内存(垃圾回收)和优化执行效率。但它本身不提供任何与外部环境交互的能力------不能操作 DOM、不能发起网络请求、不能读写文件,就像一个关在黑屋子里的计算器,只负责"算数"。
宿主环境
宿主环境决定了 JavaScript 代码在哪里运行,并为其提供与外部世界交互的"工具包":
在浏览器中,宿主向引擎注入 Web API(如 document、fetch、navigator.mediaDevices),使其能够操作 DOM、发起网络请求、调用硬件资源(摄像头、麦克风等)。
在 Node.js 中,宿主向引擎注入 Node API(如 fs、http、process),使其能够进行文件读写、搭建网络服务、管理进程。
代理执行模型(Agent)
注意:此处的 Agent 和"人工智能(AI)"没有任何关系。它是 ECMAScript 官方对"独立执行环境"的定义。
你可以把 Agent 通俗地理解为一条独立的"流水线" 。在浏览器中,这条流水线通常由操作系统线程来实现------但请注意,规范本身并不强制要求底层必须是操作系统线程。在资源受限的环境中,多个 Agent 可能会复用同一个线程。不过作为开发者,你可以放心地把 Agent 当作一个独立的执行单元来理解。
一个 Agent(流水线)必须包含三样东西:
| 组成部分 | 大白话解释 |
|---|---|
| 1. 堆(Heap) | 原材料仓库 。所有创建的对象、数组都乱七八糟地堆在这里,没有顺序,需要用的时候从里面捞。 特别说明 :每个 Agent 都有自己的独立仓库。即使两个 Agent 通过 SharedArrayBuffer 共享同一块底层内存,它们在各自的仓库里看到的也是自己那份"引用"指向同一块物理地址。 |
| 2. 队列(Queue) | 待办事项清单 。异步任务(比如定时器、点击事件)的回调都在这里排队等待,先进先出(FIFO) ------先来的先被处理。 ⚠️ 注意区分 :队列只是一个存放任务的"容器"(数据结构)。真正不停检查这个清单并派活给调用栈的,是下文的 事件循环(Event Loop)------它是"监工",不是"清单"本身。两者是不同概念。 |
| 3. 调用栈(Stack) | 工人手里的操作台 。当前正在执行的函数代码放在上面。后进先出(LIFO)------最后调用的函数(嵌套最深的)最先执行完。每调用一个函数,操作台上就压入一张"图纸(帧)";函数执行完,图纸就被扔掉。 |
一个 Agent(流水线)可以有多条"生产线"吗?
可以。
一个 Agent 里可以有多个域(Realm) ,每个域就是一个独立的"生产小组",拥有独立的全局对象(比如 window)。
举例 :一个 Tab 页面(主流水线)里嵌入了 10 个 <iframe>(子生产小组)。
那么这个 Agent 就有 1 个主页 + 10 个 iframe = 11 个 Realm。
它们共用一个操作台(调用栈)和待办清单(队列),因此它们之间可以直接传递数据(前提是同源,否则有安全隔离)。
Agent 的常见类型(在 Web 中的具体体现)
不同类型的 Agent 对应不同的运行场景。以下是浏览器中常见的五种 Agent 类型:
| Agent 类型 | 全局对象 | 大白话解释 | 特点与限制 |
|---|---|---|---|
| Window 代理 | Window |
主流水线。这是我们最熟悉的浏览器主界面(一个 Tab 页或窗口)。 | 它特殊在可以包含多个 Realm (主页面 + 多个 <iframe>)。只有同源的 iframe 才能被归到同一个代理里互相操作;跨域 iframe 虽然也在同一个线程,但被安全沙箱隔离,不能随意访问彼此的数据。 |
| 专用 Worker 代理 | DedicatedWorkerGlobalScope |
私人定制流水线。一个页面可以创建多个专用 Worker,每个都绑定到创建它的页面。 | 一对一关系:页面关了,Worker 也跟着结束。独立线程运行,无法操作 DOM。 |
| 共享 Worker 代理 | SharedWorkerGlobalScope |
公共共享流水线 。多个 Tab 页面(甚至不同浏览器窗口)可以连接到同一个共享 Worker 实例。 | 一对多关系:一个共享 Worker 可以被多个页面同时使用,实现跨标签页的数据共享。同样无法操作 DOM。 |
| Service Worker 代理 | ServiceWorkerGlobalScope |
物业保安流水线。用于拦截网络请求、实现离线缓存和推送通知。 | 生命周期独立于页面 :即使所有页面都关闭了,Service Worker 依然可以在后台运行(或休眠,待唤醒时重建)。它没有 window 对象,也无法操作 DOM。 |
| Worklet 代理 | WorkletGlobalScope |
特种作业流水线 。用于执行高性能的图形或音频计算,比如 PaintWorklet(CSS 自定义绘制)、AudioWorklet(音频处理)。 |
依附于渲染管线:它是渲染引擎的一部分,生命周期由渲染引擎管理,通常用于对性能要求极高的场景。 |
总结:一张图看懂 Agent 和执行单元的关系
less
浏览器 Tab 页
│
├── 主执行单元(Window Agent) ← 这就是我们常说的"主流水线"
│ ├── Realm A(主页面 window)
│ ├── Realm B(同源 iframe #1)
│ └── Realm C(同源 iframe #2)
│
├── 专用 Worker 执行单元(Dedicated Worker Agent) ← 独立流水线
│
├── 共享 Worker 执行单元(Shared Worker Agent) ← 可被多个 Tab 共享的流水线
│
└── Service Worker 执行单元(Service Worker Agent) ← 后台常驻(可能休眠)的流水线
一句话记住核心:
"Agent 就是一个独立干活的小车间(可以理解为执行线程)。一个车间里可以有多个工作台(Realm),比如主页面和它的 iframe。不同类型的 Worker 则是独立于主车间之外的子车间,各有各的用途和生命周期。"
栈与执行上下文
什么是执行上下文?
执行上下文 就是一张"便利贴"。每执行一个函数,引擎就贴一张,上面记着:参数、局部变量、this、返回地址。
这些便利贴叠成一个调用栈 ------后进先出(LIFO),最后贴上去的最先撕掉。
代码执行时,便利贴如何变化?
javascript
function foo(b) {
const a = 10;
return a + b + 11;
}
function bar(x) {
const y = 3;
return foo(x * y);
}
const baz = bar(7); // 结果: 42
执行过程(从下往上叠便利贴):
- 入口 :贴第一张,记录
foo、bar、baz→[入口] - 调用
bar(7):贴第二张,记录x=7、y=3→[入口, bar] bar调用foo(21):贴第三张,记录b=21、a=10→[入口, bar, foo]foo返回 42 :撕掉最上面的foo→[入口, bar]bar返回 42 :撕掉bar→[入口]- 入口结束 :撕掉入口 →
[]
生成器:便利贴可以"挂起"
普通函数执行完,便利贴当场撕毁。但生成器函数 可以把便利贴挂起保存,下次继续用。
javascript
function* gen() {
console.log(1);
yield; // 暂停点
console.log(2);
}
const g = gen();
g.next(); // 输出 1,挂起
g.next(); // 输出 2,彻底结束
过程 :创建时生成器内部存着一张空便利贴 → next() 把它贴到栈上执行 → 遇到 yield 撕下来存回去 → 再次 next() 重新贴上去继续跑。
尾调用优化(仅 Safari 支持)
尾调用:一个函数最后一步调用另一个函数,并且调用后什么都不做,直接返回结果。
javascript
function f() {
return g(); // ✅ 尾调用
}
普通情况 :调用 g() 时在栈上压入 g 的帧。
尾调用优化 :既然 f 调用 g 后啥也不干了,引擎直接扔掉 f 的帧 ,让 g 复用这个位置。递归时栈不爆。
javascript
function factorial(n, acc = 1) {
if (n <= 1) return acc;
return factorial(n - 1, n * acc); // 尾递归,栈友好
}
现状:只有 Safari 实现了。代价是堆栈跟踪会丢失被丢弃的帧信息。
闭包:函数背着的"小背包"
闭包:函数创建时,会"记住"当前环境中的所有变量,即使外层函数已执行完毕。
javascript
let f;
{
let x = 10;
f = () => x;
}
console.log(f()); // 10 ------ x 还活着
原理 :执行 { let x = 10; } 时,引擎在栈上创建了块级上下文。创建箭头函数 f 时,它偷偷背了一个包 ,里面装着当前环境的所有变量(包括 x)。块执行完本来要撕毁上下文,但引擎发现 x 被 f 的背包带走了,于是 x 继续存活。
闭包 = 函数 + 它出生时捕获的变量环境。
作业队列与事件循环
核心问题:单线程如何处理异步操作?
一个 Agent(线程)同一时间只能执行一段代码。如果所有代码都是同步的,很好办------一行接一行,总能执行完。
但问题来了:如果代码要等待某个操作完成(比如网络请求、定时器),难道要傻等着?比如:
javascript
// 假设这是同步等待
const data = fetch('https://api.example.com/data'); // 等待 500ms...
console.log(data); // 必须等上面完成后才能执行
如果真这么干,用户在等待期间什么都做不了------页面卡死,无法点击、无法滚动。作为 Web 脚本语言,JavaScript 的要求是"永不阻塞"。
解决方案:把"等待"外包出去
JavaScript 的解决思路非常简单:把等待的活交给宿主环境(浏览器/Node),自己只负责干"能立刻干完"的活。
| 角色 | 做什么 | 会不会等待? |
|---|---|---|
| JS 引擎 | 执行同步代码(函数调用、计算) | ❌ 从不等待,有事直接外包 |
| 宿主环境 | 执行异步操作(计时、网络请求、文件读写) | ✅ 默默在后台等着,完成后通知 JS |
流程如下:
- JS 遇到
setTimeout(() => {}, 1000)→ 把"计时 1 秒"外包给浏览器 → 自己继续执行后面的代码 - 浏览器在后台计时 1 秒 → 时间到 → 把回调函数放入一个"待办清单"(任务队列)
- 等 JS 把所有同步代码执行完(调用栈为空),事件循环从"待办清单"里取出回调,交给 JS 执行
这就是 JavaScript 异步编程的底层模型。
事件循环(Event Loop)
上一章我们提到 Agent 的"队列"是一个存放任务的容器。那么谁来"消费"这个队列?就是本章的主角------事件循环(Event Loop)。
事件循环就像一个永不停歇的"监工",它的唯一职责是:
盯着调用栈,一旦栈空了,就从任务队列里取出一个任务放到栈上执行。
text
┌─────────────────────────┐
│ 调用栈 │
│ (当前正在执行的代码) │
└───────────┬─────────────┘
│ 空了?
▼
┌─────────────────────────┐
│ 事件循环(监工) │
│ "栈空了,去队列取任务" │
└───────────┬─────────────┘
│
▼
┌─────────────────────────┐
│ 任务队列 │
│ (等待执行的回调们) │
│ [回调A] [回调B] [回调C] │
└─────────────────────────┘
为什么"永不阻塞"是可靠的?
关键保证来自两个设计:
保证 1:每个任务"运行到完成"(Run-to-Completion)
一旦一个任务开始执行,它会从头跑到尾,不会被任何其他代码中断。
javascript
setTimeout(() => {
console.log('A');
// 即使这里有一个耗时 5 秒的循环
for (let i = 0; i < 1000000000; i++) {}
console.log('B');
}, 0);
setTimeout(() => {
console.log('C');
}, 0);
// 输出:A → (等待 5 秒) → B → C
// 第二个 setTimeout 的回调必须等第一个完全执行完才能执行
这意味着:你在函数里操作的数据,在函数执行期间不会被其他代码中途篡改。这大大降低了编程的复杂度------你不需要像多线程语言那样加锁。
保证 2:宿主 API 本身是异步的
setTimeout、fetch、addEventListener 这些 API 都是由宿主环境提供的,它们的设计就是非阻塞的。
javascript
// 发起网络请求
fetch('/api/user')
.then(response => response.json())
.then(data => console.log(data));
// 这行代码会立即执行,不会等上面完成
console.log('请求已发出,但不等待结果');
例外(应避免) :alert()、confirm()、同步 XHR 会阻塞主线程,应尽量避免使用。
宏任务 vs 微任务:优先级不同
事件循环中的"待办事项"并非一视同仁。HTML 标准将任务分为两类:
| 类型 | 优先级 | 常见来源 | 执行时机 |
|---|---|---|---|
| 微任务(Microtask) | 🔺 高 | Promise.then、queueMicrotask、MutationObserver |
当前宏任务执行完后立即全部清空 |
| 宏任务(Task) | 🔻 普通 | setTimeout、setInterval、用户事件(click)、I/O |
每次循环只取一个执行 |
核心规则:
每一轮事件循环:执行一个宏任务 → 清空所有微任务 → (可能渲染)→ 取下一个宏任务
javascript
console.log('1');
setTimeout(() => {
console.log('2'); // 宏任务
}, 0);
Promise.resolve().then(() => {
console.log('3'); // 微任务
});
console.log('4');
// 输出顺序:1 → 4 → 3 → 2
// 微任务(3)永远先于下一个宏任务(2)执行
微任务最重要的特性 :如果在微任务执行过程中又添加了新的微任务,这些新微任务会在同一轮循环中继续执行,直到队列彻底为空。
javascript
Promise.resolve().then(() => {
console.log('A');
queueMicrotask(() => console.log('B')); // 新增微任务
});
Promise.resolve().then(() => console.log('C'));
// 输出:A → C → B
// B 虽然是在 A 执行过程中新增的,但依然在 C 之后、下一个宏任务之前执行
总结:一张图看懂事件循环
text
1. 【执行一个宏任务】
└── 执行该任务中的所有同步代码
└── 遇到异步 API → 外包给宿主,继续执行后续代码
↓
2. 【清空微任务队列】
└── 执行所有微任务(包括执行过程中新增的)
↓
3. 【(可能)渲染页面】
└── 浏览器决定是否更新屏幕
↓
4. 【回到步骤 1,取下一个宏任务】
关于"可能渲染":浏览器通常以约 60Hz(每 16.6ms)的频率刷新屏幕。如果距离上次渲染已超过一个刷新周期,且主线程空闲,浏览器就会执行渲染流水线(Style → Layout → Paint → Composite)。如果主线程被 JavaScript 占用(比如正在执行一个耗时循环),渲染就会被推迟,造成"掉帧"或"卡顿"。
记住三个核心要点:
- 单线程不阻塞:等待的活外包给宿主,自己只管执行调用栈里的代码
- 微任务优先:每轮事件循环,微任务队列必须彻底清空,才会去取下一个宏任务
- 运行到完成:一个任务一旦开始执行,不会被中途打断,保证了数据操作的安全性