JavaScript 的事件循环

JavaScript 的事件循环(Event Loop)是它实现异步非阻塞操作的核心机制。即使 JavaScript 是单线程的(一次只能做一件事),事件循环也能让它处理定时器、网络请求、用户点击等并发任务。

1. 三大核心部件(内存模型)

调用栈(Call Stack):同步任务的执行环境。它是一个 LIFO(后进先出)结构,记录当前代码执行到哪一步(函数调用进栈,函数返回出栈)。

Web APIs(或 Node.js APIs):浏览器或 Node 提供的外部环境(如 setTimeout、ajax、DOM 事件、Promise 的底层 IO)。当这些异步任务被触发时,它们会被扔到这里等待,不会阻塞栈。

任务队列(Task Queues):异步任务完成后的回调函数存放处,等待被拉回栈中执行。它分为 宏任务队列 和 微任务队列。

2. 核心机制:一次完整的"事件循环"Tick

事件循环会永远不停地检查:"调用栈是否为空?" 如果为空,它就会从任务队列中取出任务放入栈中执行。

一个完整的循环(Tick)执行顺序极其严格:

  1. 先清空当前调用栈(执行所有同步代码)。
  2. 清空微任务队列(Microtasks)------ 全部执行,执行过程中新加的微任务也要当场清掉,直到队列为空。
  3. UI渲染
  4. 执行一次宏任务(Macrotask)------ 仅取出队列头部的一个任务执行。
  5. 回到步骤 2(下一次循环)。

3. 宏任务(Macrotask)和 微任务(Microtask)

宏任务(Macrotask)和 微任务(Microtask)的优先级不同:

微任务(更高优先级):Promise.then()、catch()、finally()、MutationObserver(浏览器)、queueMicrotask()、process.nextTick(Node.js)。

宏任务(较低优先级):setTimeout、setInterval、setImmediate(Node.js)、I/O 操作、UI 渲染、MessageChannel。

  1. setTimeout 和 setInterval 的回调函数会被放入宏任务队列。
  2. async 关键字的作用只有一个:确保该函数隐式返回一个 Promise
  3. await 表达式会暂停异步函数的执行,直到 Promise 被解决,并返回结果。await 之后的代码相当于在 Promise 的 .then() 中执行,会被放入微任务队列。此时函数暂时挂起,控制权交还给主线程
  4. 微任务队列遵循"先进先出(FIFO)"原则
  5. 同源任务,严格按入队顺序执行,比如两个 setTimeout、或两个 click 事件回调,它们严格按照先入先出(FIFO)的顺序执行。
  6. 复杂规则:跨源任务,浏览器有"隐含优先级":
  • 用户交互队列(click、mousedown、键盘事件)
  • 定时器队列(setTimeout、setInterval)
  • 网络请求队列(fetch、xhr 回调)
  • I/O 队列(IndexedDB、文件读写)

用户交互(UI Events) > 网络回调(Network) > 定时器(Timers) > 空闲回调(Idle)

4.代码

bash 复制代码
console.log('1'); // 同步

setTimeout(() => {
  console.log('2'); // 宏任务
}, 0);

Promise.resolve().then(() => {
  console.log('3'); // 微任务
});

console.log('4'); // 同步

执行流程:

输出 1(同步栈)。

遇到 setTimeout,回调放入宏任务队列。

遇到 Promise.then,回调放入微任务队列。

输出 4(同步栈清空)。

微任务优先:执行微任务队列,输出 3。

宏任务执行:执行第一个宏任务,输出 2。

最终结果:1 -> 4 -> 3 -> 2。

宏任务中嵌套微任务

bash 复制代码
setTimeout(() => { // 宏任务A
  console.log('A');
  Promise.resolve().then(() => console.log('B')); // 微任务
}, 0);

setTimeout(() => { // 宏任务C
  console.log('C');
}, 0);

结果:A -> B -> C。

原因:执行宏任务 A 时,调用栈重新占据主线程。A 执行完后,事件循环会再次检查微任务队列(发现有 B),清空 B 后,才会去取下一个宏任务 C。

5. 浏览器 , Node.js 的细微差别

  • 浏览器:一次循环只取 一个 宏任务执行,然后立即清空所有微任务。
  • Node.js(旧版):微任务会在事件循环的各个阶段之间执行。
  • Node.js(新版,与浏览器对齐):基本遵循浏览器规则,但多了 nextTick(优先级高于 Promise)。

7.避坑

  • 避免长循环阻塞UI:尽量不要在微任务中递归执行耗时代码(因为微任务会连续清空,这会卡死主线程,导致页面无响应)。如果必须大量计算,建议使用宏任务(setTimeout)来分片执行,或者使用 requestIdleCallback。
  • setTimeout(fn, 0) 不是 0ms:即便设置为 0,浏览器内部也有最小延迟(HTML 标准规定至少
    4ms),且必须等待当前同步代码和微任务全部执行完才会触发。

当前 Script(同步) > process.nextTick > 微任务(Promise) > 宏任务(setTimeout) > 下一次渲染。

相关推荐
乌暮3 小时前
从 `println` 到生产级日志:Spring Boot 日志体系完全指南
java·开发语言·jvm·spring boot·后端·学习·面试
星夜离尘563 小时前
MATLAB 定点量化误差实验 半个 LSB 的界限为什么挡不住削顶失真
开发语言·matlab
Wang's Blog3 小时前
Java 项目实战: 交友项目 - 百度人脸识别:介绍
java·开发语言·交友
障碍的枫子3 小时前
我的第一个Agent
开发语言·python·langchain
Pniubi3 小时前
区分agent中长期记忆和偏好记忆
开发语言·ai·智能体
我就是不信3 小时前
Swift 方法派发机制详解
开发语言·ios·swift
CCCCCCCCharlie4 小时前
深入解析I/O底层:文件描述符与缓冲区真相
linux·开发语言·c++·算法
VimorphTech4 小时前
React 任务轮询,为什么不能只写一个 setInterval?
javascript·typescript
Zenova EdgeOS4 小时前
Rust TUI 实战:用 ratatui 构建工业边缘终端界面
开发语言·后端·rust·工业网关
lexiangqicheng4 小时前
Docker的沙箱机制是怎样的?
前端·javascript