JavaScript Promise 如何与事件循环协同工作

JavaScript 的异步行为常常让开发者感到困惑。当你编写 setTimeout(() => console.log('timeout'), 0)
并期望立即执行时,却发现 Promise 先完成了解析。理解 JavaScript Promise 如何与事件循环交互,可以揭示这种现象的原因,并帮助你编写更可预测的异步代码。
核心要点
- 事件循环在进入下一个宏任务之前会处理所有微任务
- Promise 和 async/await 使用微任务队列,优先级高于 setTimeout
- 理解双队列系统有助于预测 JavaScript 的执行顺序
- 递归微任务可能会饿死事件循环并阻塞宏任务
JavaScript 事件循环基础
JavaScript 运行在单线程上,一次只处理一个操作。事件循环通过协调调用栈和两个不同的队列来实现异步操作:微任务队列和宏任务队列。
调用栈会立即执行同步代码。当栈清空时,事件循环按照特定顺序检查待处理的任务:
- 首先执行所有微任务
- 执行一个宏任务
- 循环重复
这种优先级系统解释了为什么 promise 的行为与 setTimeout 不同。
JavaScript 宏任务与微任务:关键区别
理解宏任务与微任务的区别对于预测代码执行顺序至关重要。
宏任务包括:
setTimeout
setInterval
- I/O 操作
- UI 渲染
微任务包括:
- Promise 回调(
.then
、.catch
、.finally
) queueMicrotask()
- MutationObserver 回调
事件循环在进入下一个宏任务之前会处理所有微任务。这创建了一个优先级系统,使得 promise 总是在定时器之前执行。
Promise 与微任务队列的实际运作
让我们通过代码来观察 promise 如何与事件循环交互:
console.log('1');
setTimeout(() => console.log('2'), 0);
Promise.resolve()
.then(() => console.log('3'))
.then(() => console.log('4'));
console.log('5');
输出: 1, 5, 3, 4, 2
执行流程如下:
- 同步的
console.log('1')
立即执行 setTimeout
将回调调度到宏任务队列- Promise 回调作为微任务入队
- 同步的
console.log('5')
执行 - 事件循环处理所有微任务(3, 4)
- 事件循环处理一个宏任务(2)
微任务队列会在任何宏任务运行之前完全清空,即使是零延迟的定时器也是如此。
Discover how at OpenReplay.com.
Async/Await 与事件循环的集成
Async/await 的行为遵循相同的微任务规则。await
关键字会暂停函数执行,并将后续代码作为微任务调度:
async function example() {
console.log('1');
await Promise.resolve();
console.log('2'); // 这会成为一个微任务
}
example();
console.log('3');
// 输出: 1, 3, 2
在 await
之后,函数体的剩余部分会加入微任务队列。这解释了为什么 console.log('3')
在 console.log('2')
之前执行,尽管它在代码中出现得更晚。
常见陷阱与实用模式
微任务队列饥饿
递归创建微任务可能会阻塞事件循环:
function dangerousLoop() {
Promise.resolve().then(dangerousLoop);
}
// 不要这样做 - 会阻塞所有宏任务
混合使用定时器与 Promise
当组合不同的异步模式时,请记住执行优先级:
setTimeout(() => console.log('timeout'), 0);
fetch('/api/data')
.then(response => response.json())
.then(data => console.log('data'));
Promise.resolve().then(() => console.log('immediate'));
// 顺序: immediate → data(就绪时)→ timeout
调试执行顺序
使用此模式来追踪执行流程:
console.log('Sync start');
queueMicrotask(() => console.log('Microtask 1'));
setTimeout(() => console.log('Macrotask 1'), 0);
Promise.resolve()
.then(() => console.log('Microtask 2'))
.then(() => console.log('Microtask 3'));
setTimeout(() => console.log('Macrotask 2'), 0);
console.log('Sync end');
// 输出: Sync start, Sync end, Microtask 1, Microtask 2, Microtask 3, Macrotask 1, Macrotask 2
总结
事件循环的双队列系统决定了 JavaScript 的异步执行顺序。Promise 和 async/await 使用微任务队列,优先级高于 setTimeout 和其他宏任务。这一知识将神秘的异步行为转化为可预测的模式,使你能够编写更可靠的异步 JavaScript 代码。
记住:同步代码首先运行,然后清空所有微任务,接着执行一个宏任务。这个循环不断重复,使 JavaScript 的单线程能够高效处理复杂的异步操作。
常见问题
Promise 回调是微任务,而 setTimeout 创建的是宏任务。事件循环总是在进入下一个宏任务之前处理所有微任务,无论超时时长是多少。
是的,持续创建 promise 或微任务而不允许宏任务运行会饿死事件循环。这会阻止 UI 更新和其他宏任务的执行。
Async/await 是 promise 的语法糖。await 关键字会暂停执行并将后续代码作为微任务调度,遵循与 promise 回调相同的优先级规则。
不同的模式遵循各自的队列规则。Promise 和 async/await 使用微任务,而 setTimeout 和 setInterval 使用宏任务。当调用栈为空时,微任务总是优先执行。
Complete picture for complete understanding
Capture every clue your frontend is leaving so you can instantly get to the root cause of any issue with OpenReplay — the open-source session replay tool for developers. Self-host it in minutes, and have complete control over your customer data.
Check our GitHub repo and join the thousands of developers in our community.