Back

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

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

JavaScript 的异步行为常常让开发者感到困惑。当你编写 setTimeout(() => console.log('timeout'), 0) 并期望立即执行时,却发现 Promise 先完成了解析。理解 JavaScript Promise 如何与事件循环交互,可以揭示这种现象的原因,并帮助你编写更可预测的异步代码。

核心要点

  • 事件循环在进入下一个宏任务之前会处理所有微任务
  • Promise 和 async/await 使用微任务队列,优先级高于 setTimeout
  • 理解双队列系统有助于预测 JavaScript 的执行顺序
  • 递归微任务可能会饿死事件循环并阻塞宏任务

JavaScript 事件循环基础

JavaScript 运行在单线程上,一次只处理一个操作。事件循环通过协调调用栈和两个不同的队列来实现异步操作:微任务队列和宏任务队列。

调用栈会立即执行同步代码。当栈清空时,事件循环按照特定顺序检查待处理的任务:

  1. 首先执行所有微任务
  2. 执行一个宏任务
  3. 循环重复

这种优先级系统解释了为什么 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

执行流程如下:

  1. 同步的 console.log('1') 立即执行
  2. setTimeout 将回调调度到宏任务队列
  3. Promise 回调作为微任务入队
  4. 同步的 console.log('5') 执行
  5. 事件循环处理所有微任务(3, 4)
  6. 事件循环处理一个宏任务(2)

微任务队列会在任何宏任务运行之前完全清空,即使是零延迟的定时器也是如此。

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.

OpenReplay