问你一个问题:代码的执行顺序 ... 真的是从上到下吗?
你或许会毫不犹豫地回答:"当然!",但是,如果你看完这篇文章,了解了事件循环机制之后,或许,你会有新的答案也说不定。
一、事件循环是什么?
首先,你或许已经知晓,JavaScript 它是一个单线程执行的脚本,这意味着它同一时刻只能执行一个任务。
现实中,却有着许多耗时的操作(比如:网络请求、定时器、文件读写等),如果等待这些操作完成,页面将完全卡死,无法响应用户交互或渲染更新。
于是,我们就有了事件循环(Event Loop) 机制,事件循环可以协调同步任务、异步任务的执行,让单线程的 JavaScript 也能高效处理并发操作。
事件循环的作用:
- 避免阻塞:它可以将耗时任务移出主线程,确保页面交互流畅
- 任务调度:它可以按照优先级(同步 > 微任务 > 宏任务)的顺序执行代码
- 响应异步:它能处理 I/O、定时器等操作结果,实现非阻塞运行
二、事件循环的核心概念介绍
1. 进程与线程
- 进程:程序执行的独立实例(如一个浏览器标签页),它拥有独立内存空间。
- 线程 :进程内的执行单元。就像JS , 它就是 单线程的,这意味着它只有一个调用栈,一次只能执行一段代码。
2. 同步任务 vs 异步任务
- 同步任务 :
立即在主线程(调用栈)上执行的任务
,执行顺序就是你书写的代码顺序。它能保证尽快完成页面渲染和交互响应。 - 异步任务 :
不会立即执行结果的任务
,它一般会被交给浏览器的其他线程(如定时器线程、网络请求线程)处理。完成后,其回调函数会被放入相应的任务队列,等待事件循环调度到主线程执行。
3. 宏任务 vs 微任务
宏任务和微任务,它们本质上其实都属于异步任务。
- 宏任务 :相对独立的工作单元,常见类型有
setTimeout
,setInterval
, , I/O 操作, UI 渲染等。 - 微任务 :更紧急、需在当前宏任务结束后、渲染前尽快执行的任务,常见类型有
Promise.then
,MutationObserver
,queueMicrotask
,process.nextTick
等。
三、事件循环执行顺序
事件循环的运行机制一般遵循固定的步骤,如图所示:
- 执行初始宏任务 :通常就是执行当前
<script>
标签内的所有同步代码 ,一个<script>
就是一个宏任务。 - 清空微任务队列 :当前宏任务的所有同步代码执行完毕后(调用栈为空),JS 引擎会立即、连续地执行微任务队列中的所有可执行微任务,直到队列清空。
- 渲染更新(可选):浏览器可能会在这个时机进行 UI 渲染。
- 取下一个宏任务 :从宏任务队列中取出最先进入的宏任务执行。
- 循环步骤2-4:执行宏任务的同步代码 -> 清空微任务队列 -> (可能渲染)-> 取下一个宏任务...
结合代码分析
案例 1:基础顺序
javascript
console.log('任务开头'); // 同步任务1
setTimeout(() => { console.log('执行setTimeout'); }, 0); // 宏任务1 (回调)
Promise.resolve().then(() => { console.log('执行Promise'); }); // 微任务1
console.log('任务结尾'); // 同步任务2
输出结果:
javascript
任务开头
任务结尾
执行Promise
执行setTimeout
执行步骤:
-
执行初始宏任务 (
<script>
):- 执行同步任务1: 输出
任务开头
- 遇到
setTimeout
: 其回调函数() => { console.log('执行setTimeout') }
被放入宏任务队列。 - 遇到
Promise.resolve().then(...)
: 其回调函数() => { console.log('执行Promise') }
被放入微任务队列。 - 执行同步任务2: 输出
任务结尾
- 初始宏任务执行完毕,调用栈清空。
- 执行同步任务1: 输出
-
清空微任务队列:
- 检查微任务队列,发现有 1 个微任务(微任务1)。
- 执行微任务1的回调: 输出
执行Promise
- 微任务队列清空。
-
(可能发生的 UI 渲染)
-
取下一个宏任务:
- 从宏任务队列中取出宏任务1(
setTimeout
的回调)。 - 执行该回调: 输出
执行setTimeout
- 该宏任务执行完毕,调用栈清空。
- 从宏任务队列中取出宏任务1(
-
检查微任务队列: 此时微任务队列为空。
-
(可能发生的 UI 渲染)
-
取下一个宏任务: 宏任务队列为空,事件循环等待新任务。
结论:
一般情况下,执行顺序为 同步任务 (整个script宏任务) -> 微任务 -> 下一个宏任务
。
案例 2:微任务插队 and 宏任务内的微任务
javascript
console.log("开头"); // 同步1
process.nextTick(() => { console.log("微任务:Process Next Tick"); }); // 微任务1 (Node)
Promise.resolve().then(() => { console.log("微任务:Promise"); }); // 微任务2
setTimeout(() => { // 宏任务1 (回调)
console.log("宏任务:setTimeout");
Promise.resolve().then(() => { console.log("微任务:setTimeout --> Promise"); }); // 宏任务1内部的微任务
}, 0);
console.log("结尾"); // 同步2
输出结果:
javascript
开头
结尾
微任务:Process Next Tick
微任务:Promise
宏任务:setTimeout
微任务:setTimeout --> Promise
执行步骤:
-
执行初始宏任务 (
<script>
):- 输出
开头
(同步1) process.nextTick
回调放入微任务队列 (微任务1)Promise.then
回调放入微任务队列 (微任务2)setTimeout
回调放入宏任务队列 (宏任务1)- 输出
结尾
(同步2) - 初始宏任务执行完毕。调用栈清空。
- 输出
-
清空微任务队列:
- 执行微任务1: 输出
微任务:Process Next Tick
(Node 中nextTick
优先级通常高于Promise
) - 执行微任务2: 输出
微任务:Promise
- 微任务队列清空。
- 执行微任务1: 输出
-
(可能发生的 UI 渲染)
-
取下一个宏任务 (宏任务1):
- 执行宏任务1的回调:
- 输出
宏任务:setTimeout
- 遇到
Promise.resolve().then(...)
: 其回调() => { console.log("微任务:setTimeout --> Promise") }
被放入微任务队列。
- 输出
- 宏任务1执行完毕。调用栈清空。
- 执行宏任务1的回调:
-
清空微任务队列 (由宏任务1产生的):
- 执行刚加入的微任务: 输出
微任务:setTimeout --> Promise
- 微任务队列清空。
- 执行刚加入的微任务: 输出
-
(可能发生的 UI 渲染)
-
取下一个宏任务: 宏任务队列为空。
结论:
- 同步任务最先执行完 (
开头
,结尾
)。 - 初始宏任务结束后,所有微任务 (
Process Next Tick
,Promise
) 被立即、连续执行完。 - 然后才执行宏任务 (
setTimeout
)。 - 宏任务 (
setTimeout
) 执行过程中产生的微任务 (setTimeout --> Promise
),必须在该宏任务自身执行完毕后、下一个宏任务开始前,立即执行。
案例 3:微任务与宏任务混合
javascript
console.log('同步开始'); // 同步1
const promise1 = Promise.resolve('第一个 promise');
const promise2 = Promise.resolve('第二个 promise');
const promise3 = new Promise(resolve => { resolve('第三个 promise'); });
setTimeout(() => { // 宏任务1
promise2.then(value => { // 宏任务1内部的微任务1
console.log("下一轮再见");
const promise4 = Promise.resolve('第四个 promise');
promise4.then(value => { console.log(value); }); // 宏任务1内部的微任务1产生的微任务
})
}, 0);
setTimeout(() => { // 宏任务2
console.log("下两轮再见");
}, 0);
promise1.then(value => console.log(value)); // 微任务1
promise2.then(value => console.log(value)); // 微任务2
promise3.then(value => console.log(value)); // 微任务3
console.log('同步结束'); // 同步2
输出结果:
arduino
同步开始
同步结束
第一个 promise
第二个 promise
第三个 promise
下一轮再见
第四个 promise
下两轮再见
执行步骤:
- 执行初始宏任务 (
<script>
):- 输出
同步开始
- 创建 promise1, promise2, promise3
- 将宏任务1
() => { promise2.then() }
放入宏任务队列 - 将宏任务2
() => { console.log("下两轮再见") }
放入宏任务队列 - 将微任务1
() => console.log(promise1的值)
、微任务2() => console.log(promise2的值)
、微任务3() => console.log(promise3的值)
放入微任务队列 - 输出
同步结束
- 初始宏任务执行完毕。调用栈清空。
- 输出
- 清空微任务队列:
- 按入队顺序执行微任务1, 2, 3:
- 微任务1: 输出
第一个 promise
- 微任务2: 输出
第二个 promise
- 微任务3: 输出
第三个 promise
- 微任务1: 输出
- 微任务队列清空。
- 按入队顺序执行微任务1, 2, 3:
- (可能发生 UI 渲染)
- 取下一个宏任务 (最先进入宏任务队列的 - 宏任务1):
- 执行宏任务1的回调:
- 执行
promise2.then(...)
: 因为 promise2 早已fulfilled
,所以其回调value => { ... }
被立即放入微任务队列 (成为宏任务1内部的微任务1)。
- 执行
- 宏任务1执行完毕。调用栈清空。
- 执行宏任务1的回调:
- 清空微任务队列 (由宏任务1产生的):
- 执行刚加入的微任务 (宏任务1内部的微任务1):
- 输出
下一轮再见
- 创建 promise4 (状态
fulfilled
) - 执行
promise4.then(...)
: 其回调value => { console.log(value) }
被立即放入微任务队列 (成为宏任务1内部的微任务1产生的微任务)。
- 输出
- 该微任务执行完毕,但微任务队列此时非空(刚放入了 promise4 的回调)!
- 继续清空微任务队列 :
- 执行 promise4 的回调: 输出
第四个 promise
- 执行 promise4 的回调: 输出
- 微任务队列真正清空。
- 执行刚加入的微任务 (宏任务1内部的微任务1):
- (可能发生 UI 渲染)
- 取下一个宏任务 (宏任务2):
- 执行宏任务2的回调: 输出
下两轮再见
- 该宏任务执行完毕,调用栈清空。
- 执行宏任务2的回调: 输出
- 检查微任务队列: 为空。
- (可能发生 UI 渲染)
- 取下一个宏任务: 宏任务队列为空。
结论:
- 同步任务 (
同步开始
,同步结束
) 最先执行。 - 初始宏任务结束后,其产生的所有微任务 (
第一个 promise
,第二个 promise
,第三个 promise
) 被立即连续执行。 - 执行第一个宏任务 (宏任务1)。宏任务内部可以产生新的微任务 (
下一轮再见
对应的回调及其内部的promise4.then
回调)。 - 关键点:宏任务1执行完毕后,必须清空此时微任务队列中的所有任务 (包括宏任务1内部产生的
下一轮再见
和它进一步产生的第四个 promise
),才会去执行下一个宏任务 (宏任务2 -下两轮再见
)。 - 微任务队列的清空是彻底的、递归的:只要执行一个微任务的过程中又向微任务队列添加了新任务,引擎会一直执行下去直到队列为空。这保证了微任务的高优先级和执行的连续性。
四、总结
- 同步优先的情况:永远先执行完当前宏任务中的所有同步代码。
- 微任务插队的情况 :每当一个宏任务执行完毕(调用栈清空),引擎会立即、连续、彻底地 执行完当前微任务队列中的所有任务。这是实现
Promise
链式调用、async/await
看似同步风格的核心保障。 - 宏任务接力的情况 :只有在一个宏任务及其产生的所有微任务都执行完毕后,才会从宏任务队列中取出一个 任务开始执行下一轮循环。
setTimeout
、setInterval
、I/O 等操作的回调都在此列。