JavaScript 事件循环详解:宏任务与微任务的执行顺序
JavaScript 作为单线程语言,通过事件循环(Event Loop)机制协调同步与异步操作。本文将拆解宏任务与微任务的执行差异,剖析从代码加载到 DOM 渲染全过程的任务调度逻辑,帮助开发者掌握异步编程的核心原理。
前言:单线程与异步的基础
JavaScript 引擎在浏览器和 Node.js 中均运行于单线程环境。这意味着在同一时刻,解释器只能执行一段代码。若所有操作均为同步阻塞式(如复杂的计算或长网络请求),页面将陷入无响应状态,甚至导致白屏。为了解决这一矛盾,JavaScript 采用了异步非阻塞模型:将耗时操作放入独立的队列,主线程优先处理同步代码,待空闲时再按序取出异步任务执行。
任务分类:宏任务与微任务
异步任务并非无序堆积,而是根据执行优先级被划分为两类:
第1步:识别宏任务(Macrotask)
宏任务通常由浏览器或宿主环境发起,其核心特征是在当前脚本执行完毕后的下一轮事件循环(即 DOM 渲染后)执行。常见的宏任务包括:
setTimeout/setInterval:定时器任务。即使延迟设为0,也必须等待当前调用栈清空且时间满足条件后才进入队列。XMLHttpRequest:网络请求完成后的回调。UI rendering:浏览器的 UI 渲染更新本身也可视为一种宏任务。
第2步:识别微任务(Microtask)
微任务具有更高的执行优先级,它们将在当前宏任务执行完毕后、下一次 DOM 渲染之前立即执行。常见的微任务包括:
Promise.then/.catch:Promise 状态改变后的回调。queueMicrotask():显式创建的微任务。MutationObserver:DOM 变更监听回调。
console.log('start');
// 添加微任务
Promise.resolve().then(() => {
console.log('promise then');
});
queueMicrotask(() => {
console.log('queueMicrotask');
});
console.log('end');执行结果验证了微任务的优先性:start 和 end 先输出,随后按顺序输出 promise then 和 queueMicrotask。
核心机制:事件循环(Event Loop)流程
理解事件循环需关注三个关键组件的协作:调用栈(Call Stack)、任务队列(Task Queue,分宏/微)以及 HTML 渲染管线。
完整的执行顺序如下:
- 执行同步代码:遇到函数压入调用栈,遇到异步函数将其分配至对应的宏任务或微任务队列。
- 清理微任务队列:当前调用栈清空后,引擎会检查微任务队列。如果存在微任务,全部依次执行(注意:在执行新的微任务期间产生的新微任务,也会在本轮被追加并执行)。直到微任务队列为空。
- 界面渲染:如果有重排(Reflow)或重绘(Repaint)的需求,浏览器在此阶段更新屏幕。
- 执行宏任务:从宏任务队列头部取出一个任务,推入调用栈执行。
- 循环往复:重复步骤 2-4,直至所有队列清空。
执行规则总结
- 微任务先于宏任务:只要微任务队列不为空,就不会切换到下一个宏任务。
- 渲染时机:微任务在渲染前执行,可影响即将呈现的 DOM;宏任务在渲染后执行。
- FIFO 原则:同类任务遵循先进先出,但微任务可能因本轮执行产生新的微任务而延长执行时间。
复杂场景演练
第1步:微任务嵌套宏任务
当在微任务中创建宏任务时,新宏任务会被加入宏任务队列,等待下一轮循环。
new Promise((resolve) => {
console.log('promise 1');
// 在 Promise 内部添加宏任务
setTimeout(() => { console.log('setTimeout 1'); }, 500);
resolve();
}).then(() => {
console.log('promise then');
// 在 then 中添加另一个宏任务
setTimeout(() => { console.log('setTimeout 2'); }, 0);
});
new Promise((resolve) => {
console.log('promise 2');
resolve();
});结果分析:
- 首先执行同步部分的两个
Promise构造器,输出promise 1、promise 2。 - 微任务队列中有两个
then回调(第二个 Promise 虽未链式调用 then,但在某些环境中可能有默认行为,此处主要看第一个已定义的 then)。 - 执行
promise then,在此过程中创建了setTimeout 2(0ms)。 - 微任务清空,进入渲染阶段。
- 执行宏任务:
setTimeout 2和setTimeout 1均在队列中。由于setTimeout 2延迟极短且触发早,通常优先被执行(具体取决于环境调度,但逻辑上属于同一轮次的后续宏任务)。
第2步:宏任务嵌套微任务
这是最容易混淆的场景。关键在于:只有当前宏任务彻底结束且微任务队列为空时,才会去取下一个宏任务。
setTimeout(() => {
console.log('setTimeout 1');
new Promise((resolve) => {
console.log('promise 1');
resolve();
}).then(() => { console.log('promise then 1'); });
}, 500);
setTimeout(() => {
console.log('setTimeout 2');
new Promise((resolve) => {
console.log('promise 2');
resolve();
});
}, 0);结果分析:
- 初始化:两个
setTimeout注册进入宏任务队列。setTimeout 2延迟 0ms,排在前面。 - 第一轮循环:微任务队列空,取
setTimeout 2执行。输出setTimeout 2,创建包含promise 2的微任务。 - 第二轮循环:
setTimeout 2执行完毕,**强制检查微任务队列**。此时存在promise 2相关的微任务,必须将其全部执行完。输出promise 2。 - 第三轮循环:微任务为空,取下一个宏任务
setTimeout 1。输出setTimeout 1,接着创建promise 1微任务。 - 第四轮循环:再次检查微任务,执行
promise 1及其then。输出promise 1、promise then 1。
结论:只要宏任务中产生了微任务,当前的宏任务结束后,必须立刻“清空”微任务队列,才会去执行后续的宏任务。
其他场景简述
- 宏任务嵌套宏任务:新宏任务直接进入宏任务队列尾部,按照延迟时间和插入顺序排队。
- 微任务嵌套微任务:新微任务会被推入微任务队列末尾,并在当前这一轮“清微任务”的过程中继续执行,不会中断去渲染或切换宏任务。
总结
JavaScript 的事件循环是管理并发行为的基石。开发者应牢记以下原则以规避异步 Bug:
- 同步代码优先于一切异步代码。
- 微任务 > 宏任务,且在当前宏任务结束后,必须先清空微任务队列才能进行下一轮循环。
- 涉及 DOM 读取或修改的逻辑,若需依赖最新的 DOM 状态,应考虑放入微任务或确保在正确的宏任务周期执行。
- 对于复杂的异步依赖,清晰梳理代码的执行路径,必要时使用
async/await简化逻辑,但其底层依然遵循上述事件循环规则。
以上就是 JavaScript 事件循环、宏任务与微任务执行的详细内容,更多关于 JS 异步编程的资料请关注本站其它相关文章!
本站声明
本文内容仅供参考,不代表本站观点。转载或引用请注明出处。如发现内容问题,请通过联系我们页面反馈。













