深入理解 JavaScript 事件循环

深入理解 JavaScript 事件循环

本文核心:什么是事件循环?它如何让 JavaScript 做到"永不阻塞"?

JavaScript 是单线程的,但网页需要同时处理点击、网络请求、定时器、动画渲染等无数任务。事件循环(Event Loop)就是这套调度机制的核心------它决定了代码的执行顺序,也保证了页面不会因为等待某个操作完成而卡死。

本文将从引擎与宿主的关系讲起,逐步深入到 Agent、调用栈、任务队列、微任务等核心概念,最终完整呈现事件循环的执行模型。读完本文,你将彻底理解:为什么 setTimeout(fn, 0) 不会立刻执行,为什么 Promise.then 总是先于它执行,以及页面卡顿的根本原因是什么。


引擎和宿主

JavaScript 引擎

JavaScript 引擎的核心职责是解析、编译并执行 JavaScript 代码。它负责处理语法、管理内存(垃圾回收)和优化执行效率。但它本身不提供任何与外部环境交互的能力------不能操作 DOM、不能发起网络请求、不能读写文件,就像一个关在黑屋子里的计算器,只负责"算数"。

宿主环境

宿主环境决定了 JavaScript 代码在哪里运行,并为其提供与外部世界交互的"工具包":

在浏览器中,宿主向引擎注入 Web API(如 documentfetchnavigator.mediaDevices),使其能够操作 DOM、发起网络请求、调用硬件资源(摄像头、麦克风等)。

在 Node.js 中,宿主向引擎注入 Node API(如 fshttpprocess),使其能够进行文件读写、搭建网络服务、管理进程。


代理执行模型(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

执行过程(从下往上叠便利贴):

  1. 入口 :贴第一张,记录 foobarbaz[入口]
  2. 调用 bar(7) :贴第二张,记录 x=7y=3[入口, bar]
  3. bar 调用 foo(21) :贴第三张,记录 b=21a=10[入口, bar, foo]
  4. foo 返回 42 :撕掉最上面的 foo[入口, bar]
  5. bar 返回 42 :撕掉 bar[入口]
  6. 入口结束 :撕掉入口 → []

生成器:便利贴可以"挂起"

普通函数执行完,便利贴当场撕毁。但生成器函数 可以把便利贴挂起保存,下次继续用。

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)。块执行完本来要撕毁上下文,但引擎发现 xf 的背包带走了,于是 x 继续存活。

闭包 = 函数 + 它出生时捕获的变量环境。

作业队列与事件循环

核心问题:单线程如何处理异步操作?

一个 Agent(线程)同一时间只能执行一段代码。如果所有代码都是同步的,很好办------一行接一行,总能执行完。

但问题来了:如果代码要等待某个操作完成(比如网络请求、定时器),难道要傻等着?比如:

javascript 复制代码
// 假设这是同步等待
const data = fetch('https://api.example.com/data'); // 等待 500ms...
console.log(data); // 必须等上面完成后才能执行

如果真这么干,用户在等待期间什么都做不了------页面卡死,无法点击、无法滚动。作为 Web 脚本语言,JavaScript 的要求是"永不阻塞"。


解决方案:把"等待"外包出去

JavaScript 的解决思路非常简单:把等待的活交给宿主环境(浏览器/Node),自己只负责干"能立刻干完"的活。

角色 做什么 会不会等待?
JS 引擎 执行同步代码(函数调用、计算) ❌ 从不等待,有事直接外包
宿主环境 执行异步操作(计时、网络请求、文件读写) ✅ 默默在后台等着,完成后通知 JS

流程如下:

  1. JS 遇到 setTimeout(() => {}, 1000) → 把"计时 1 秒"外包给浏览器 → 自己继续执行后面的代码
  2. 浏览器在后台计时 1 秒 → 时间到 → 把回调函数放入一个"待办清单"(任务队列)
  3. 等 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 本身是异步的

setTimeoutfetchaddEventListener 这些 API 都是由宿主环境提供的,它们的设计就是非阻塞的

javascript 复制代码
// 发起网络请求
fetch('/api/user')
  .then(response => response.json())
  .then(data => console.log(data));

// 这行代码会立即执行,不会等上面完成
console.log('请求已发出,但不等待结果');

例外(应避免)alert()confirm()、同步 XHR 会阻塞主线程,应尽量避免使用。


宏任务 vs 微任务:优先级不同

事件循环中的"待办事项"并非一视同仁。HTML 标准将任务分为两类:

类型 优先级 常见来源 执行时机
微任务(Microtask) 🔺 Promise.thenqueueMicrotaskMutationObserver 当前宏任务执行完后立即全部清空
宏任务(Task) 🔻 普通 setTimeoutsetInterval、用户事件(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 占用(比如正在执行一个耗时循环),渲染就会被推迟,造成"掉帧"或"卡顿"。

记住三个核心要点:

  1. 单线程不阻塞:等待的活外包给宿主,自己只管执行调用栈里的代码
  2. 微任务优先:每轮事件循环,微任务队列必须彻底清空,才会去取下一个宏任务
  3. 运行到完成:一个任务一旦开始执行,不会被中途打断,保证了数据操作的安全性
相关推荐
MariaH1 小时前
git rebase的使用
前端
阡陌Jony1 小时前
关于前端性能优化的一些问题:
前端
用户600071819102 小时前
【翻译】简化 TSRX
前端
IT乐手3 小时前
佛德角逼平西班牙,国足还有啥借口?
前端
JustHappy4 小时前
我汇总了身边朋友的经历才发现,其实第一份实习是最难找的......
前端·后端·面试
星栈4 小时前
Dioxus 的响应式系统:`Signal`、`Memo`、`Effect` 和异步状态到底该怎么分工
前端·前端框架
yingyima4 小时前
Java 正则表达式:比你想象的更强大
前端
yuanyxh7 小时前
macOS 应用 - 纯对话生成
前端·macos·ai编程
大家的林语冰7 小时前
ES5 凉凉,Babel 8 正式发布,默认不再编译为 ES5 和 CJS......
前端·javascript·前端工程化