JavaScript 事件循环是该语言中最重要但又被误解的概念之一。尽管 JavaScript 是单线程的,但它的事件循环通过复杂的队列系统实现了非阻塞并发。本文构建了 JavaScript 如何处理异步执行的完整心智模型,从调用堆栈到微任务队列以及其间的所有内容。
调用堆栈和执行模型
JavaScript 使用具有 LIFO(后进先出)结构的调用堆栈。当一个函数被调用时,一个新的栈帧被压入栈中。当函数返回时,框架就会弹出。关键原则是运行到完成:每个函数在任何其他代码可以中断它之前运行完成。
function multiply(a, b) { return a * b; }
function square(n) { return multiply(n, n); }
function logSquare(x) { console.log(square(x)); }
logSquare(4); // call stack: logSquare → square → multiply → ...
这种单线程模型意味着长时间运行的操作会阻塞主线程。事件循环通过将等待操作委托给运行时环境(浏览器或 Node.js)并通过专门的队列异步处理它们的回调来解决这个问题。
宏任务队列
宏任务,也称为任务,源自 Node.js 中的 setTimeout、setInterval、I/O 事件、UI 事件和 setImmediate。事件循环在每次迭代中从队列中选取一个宏任务,执行它直至完成,然后在移动到下一个宏任务之前处理微任务队列。
console.log("Start");
setTimeout(() => console.log("Timeout"), 0);
console.log("End");
// Output: Start, End, Timeout
即使延迟为 0,setTimeout 回调也会作为宏任务排队,并且仅在当前同步代码和所有微任务完成后才执行。这种行为经常被希望 setTimeout(fn, 0) 立即执行的开发人员误解。
微任务队列
微任务比宏任务具有更高的优先级。它们源自 Node.js 中的 Promise .then()、.catch()、.finally()、queueMicrotask()、MutationObserver 和 process.nextTick()。关键规则是在处理下一个宏任务之前,整个微任务队列都会被清空。
console.log("1");
Promise.resolve().then(() => console.log("2"));
setTimeout(() => console.log("3"), 0);
console.log("4");
// Output: 1, 4, 2, 3
这种顺序解释了为什么 Promise 在 setTimeout 回调之前解析,即使两者都准备好了。一个危险的后果是微任务饥饿:递归调度微任务可能会阻止宏任务执行,从而冻结应用程序。
| 队列类型 | 来源 | 优先 | 每个周期排空 |
|---|---|---|---|
| 宏任务 | setTimeout、I/O、UI 事件 | 降低 | 一项任务 |
| 微任务 | Promise、queueMicrotask | 更高 | 整个队列 |
| 渲染 | 请求动画帧 | 之间 | 涂漆前 |
异步/等待内部结构
async 和 await 关键字是 Promise 和生成器函数的语法糖。异步函数同步执行到第一个 await 表达式,此时它将控制权交还给事件循环,并在等待的 Promise 解析为微任务时恢复。
async function example() {
console.log("A");
await Promise.resolve();
console.log("B");
}
example();
console.log("C");
// Output: A, C, B
await 关键字不会阻塞线程。它脱糖为 Promise .then() 回调,该回调作为微任务排队。这就是为什么 await 之后的代码在所有同步代码完成后执行。
requestAnimationFrame 和渲染
渲染管道(样式重新计算、布局和绘制)发生在事件循环周期中的特定点。 requestAnimationFrame 回调在浏览器执行绘制之前运行,使它们成为视觉更新的正确 API。
function animate(timestamp) {
element.style.transform = `translateX(${Math.sin(timestamp / 1000) * 100}px)`;
requestAnimationFrame(animate);
}
requestAnimationFrame(animate);
对动画使用 setTimeout 或 setInterval 会导致卡顿,因为它们的回调与浏览器的绘制周期不一致。另一方面,requestIdleCallback API 是为不应该影响帧预算的非关键工作而设计的。
性能影响
长任务(JavaScript 执行超过 50 毫秒)会阻塞主线程并导致明显的卡顿。 Long Tasks API 支持编程监控:
const observer = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
console.log("Long task detected:", entry.duration, "ms");
}
});
observer.observe({ entryTypes: ["longtask"] });
长任务直接影响核心网络生命,特别是与下一次绘制的交互(INP)。缓解策略包括使用 setTimeout 或 scheduler.yield() 分解长任务、将繁重的计算卸载给 Web Workers,以及使用 requestIdleCallback 进行可推迟的工作。
结论
掌握事件循环对于编写高性能 JavaScript 至关重要。了解调用堆栈、宏任务队列、微任务队列和渲染管道之间的关系有助于您避免常见错误、优化动画性能并诊断生产问题。具有优先队列排出的运行到完成执行的思维模型是所有异步 JavaScript 开发的基础。

