JavaScript 的事件循环(Event Loop)是它实现异步非阻塞操作的核心机制。即使 JavaScript 是单线程的(一次只能做一件事),事件循环也能让它处理定时器、网络请求、用户点击等并发任务。
1. 三大核心部件(内存模型)
调用栈(Call Stack):同步任务的执行环境。它是一个 LIFO(后进先出)结构,记录当前代码执行到哪一步(函数调用进栈,函数返回出栈)。
Web APIs(或 Node.js APIs):浏览器或 Node 提供的外部环境(如 setTimeout、ajax、DOM 事件、Promise 的底层 IO)。当这些异步任务被触发时,它们会被扔到这里等待,不会阻塞栈。
任务队列(Task Queues):异步任务完成后的回调函数存放处,等待被拉回栈中执行。它分为 宏任务队列 和 微任务队列。
2. 核心机制:一次完整的"事件循环"Tick
事件循环会永远不停地检查:"调用栈是否为空?" 如果为空,它就会从任务队列中取出任务放入栈中执行。
一个完整的循环(Tick)执行顺序极其严格:
- 先清空当前调用栈(执行所有同步代码)。
- 清空微任务队列(Microtasks)------ 全部执行,执行过程中新加的微任务也要当场清掉,直到队列为空。
- UI渲染
- 执行一次宏任务(Macrotask)------ 仅取出队列头部的一个任务执行。
- 回到步骤 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。
- setTimeout 和 setInterval 的回调函数会被放入宏任务队列。
- async 关键字的作用只有一个:确保该函数隐式返回一个 Promise
- await 表达式会暂停异步函数的执行,直到 Promise 被解决,并返回结果。await 之后的代码相当于在 Promise 的 .then() 中执行,会被放入微任务队列。此时函数暂时挂起,控制权交还给主线程
- 微任务队列遵循"先进先出(FIFO)"原则
- 同源任务,严格按入队顺序执行,比如两个 setTimeout、或两个 click 事件回调,它们严格按照先入先出(FIFO)的顺序执行。
- 复杂规则:跨源任务,浏览器有"隐含优先级":
- 用户交互队列(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) > 下一次渲染。