一文详解JS中的执行顺序------事件循环(宏任务、微任务)
为什么 JavaScript 是单线程的?
JavaScript 诞生的初衷是为了处理网页上的简单交互,比如表单验证。试想一下,如果 JavaScript 是多线程的:
- 线程 A 想修改某个 DOM 节点的内容
- 线程 B 想删除同一个 DOM 节点
这就会导致复杂的同步问题(锁机制),对于轻量级的网页脚本来说太重了。因此,JavaScript 从诞生起就是单线程的,这意味着它在同一时间只能做一件事。
但"单线程"并不意味着它慢。JavaScript 巧妙地利用了异步非阻塞 机制,配合 Event Loop (事件循环),让它能够高效地处理大量并发任务(如网络请求、定时器、DOM 事件)。
核心概念解析
为了理解 Event Loop,我们需要先搞清楚几个"角色":
同步任务 (Synchronous)
那些立即执行 、不等待 其他操作完成、并且按顺序在主线程上依次执行的任务就是同步任务。
你可以直接把这段代码复制到浏览器的控制台(F12 -> Console)运行:
javascript
console.log('1. 任务开始');
// 【同步任务】:一个极其耗时的循环
// 假设我们要计算 10 亿次加法
let sum = 0;
const limit = 1000000000; // 10 亿
console.log('2. 开始执行耗时同步任务 (请观察页面是否卡住)...');
for (let i = 0; i < limit; i++) {
sum += i;
// 注意:在这个循环结束前,JS 引擎绝对不会去处理任何其他事情
// 你的鼠标点击、页面滚动、定时器回调、网络请求完成等,全部被阻塞!
}
console.log('3. 耗时任务结束,结果:', sum);
当今浏览器的性能虽说不至于卡死,但是在进行计算的这一秒内你尝试滚动页面,发现页面似乎无响应了,这就是JS的主进程被阻塞,无法执行其他任务(页面滚动)。
异步任务 (Asynchronous)
异步任务就是"现在不执行,将来某个时刻再执行"的任务。 它们不会阻塞主线程,而是将回调函数注册好,交给浏览器(或 Node.js)的 API 去处理,等处理完了,再把回调函数放入队列,等待 Event Loop 在合适的时机执行。
宏任务 (MacroTask)
- 代表一个个离散的、独立的任务。
- 浏览器为了能够使 JS 内部 task 与 DOM 任务能够有序的执行,会在一个 task 执行结束后,在下一个 task 执行开始前,对页面进行重新渲染。
- 常见宏任务 :
- 整体代码 script (可以理解为第一个宏任务)
setTimeout/setInterval- UI 渲染 / I/O
微任务 (MicroTask)
- 优先级高于宏任务(除了当前的 script)。
- 在当前宏任务执行结束后,下一次渲染之前,会立即清空所有的微任务。
- 常见微任务 :
Promise.then/catch/finallyasync/await(本质是 Promise)MutationObserver(监听 DOM 变化)queueMicrotask
---
Event Loop 执行流程
这就是 JavaScript 永不停歇的"心脏"跳动机制:
- 执行栈 (Call Stack) 选择最先进入队列的宏任务(通常是整体 script 代码),执行其同步代码。
- 执行过程中,遇到微任务 ,将其放入微任务队列。
- 执行过程中,遇到宏任务 (如 setTimeout),将其回调放入宏任务队列。
- 当前宏任务执行完毕(Call Stack 清空)。
- 关键步骤 :检查微任务队列 。如果有微任务,依次执行所有微任务 ,直到队列清空。
- 注意:如果在执行微任务的过程中又产生了新的微任务,会继续添加到队列末尾并在本次循环中一并执行!这可能导致"死循环"阻塞页面渲染。
- 渲染页面(如果有必要)。
- 检查宏任务队列,取出下一个宏任务,回到步骤 1。
口诀:
同步先行 -> 清空微任务 -> 渲染 -> 下一个宏任务
代码案例
让我们通过一段复杂的代码来彻底捋清楚执行顺序。
javascript
// 1. 同步代码
console.log('同步代码 1');
// 2. 宏任务 (setTimeout)
setTimeout(() => {
console.log('setTimeout 1');
Promise.resolve().then(() => {
console.log('setTimeout 1 内部微任务');
});
}, 0);
// 3. Promise 构造函数 (同步)
const promise1 = new Promise((resolve) => {
console.log('Promise 构造函数');
resolve();
console.log('Promise 构造函数内 resolve 后');
});
// 4. 微任务 (Promise.then)
promise1.then(() => {
console.log('Promise.then 1');
setTimeout(() => {
console.log('Promise.then 1 内部 setTimeout');
}, 0);
});
// 5. Async/Await (同步+微任务)
async function asyncFn() {
console.log('async 函数同步部分');
// await 相当于 Promise.resolve().then(...)
// await 这一行及之后的代码会被放入微任务队列
await Promise.resolve();
console.log('await 后微任务');
}
asyncFn();
// 6. 同步代码
console.log('同步代码 2');
// 7. 微任务 (queueMicrotask)
queueMicrotask(() => {
console.log('queueMicrotask 微任务');
});
// 8. 微任务 (MutationObserver)
const observer = new MutationObserver(() => {
console.log('MutationObserver 微任务');
});
const div = document.createElement('div');
observer.observe(div, { attributes: true });
div.setAttribute('data-test', '1'); // 触发
执行步骤详解
第一轮:执行 Script 宏任务(同步代码)
-
执行
console.log('同步代码 1')- 控制台输出 :
同步代码 1
- 控制台输出 :
-
执行
setTimeout(...)- 宏任务队列 :
[SetTimeout1]
- 宏任务队列 :
-
执行
new Promise(...)- 控制台输出 :
Promise 构造函数 - 控制台输出 :
Promise 构造函数内 resolve 后
- 控制台输出 :
-
执行
promise1.then(...)- 微任务队列 :
[Then1]
- 微任务队列 :
-
执行
asyncFn()- 控制台输出 :
async 函数同步部分 - 微任务队列 :
[Then1, Await]
- 控制台输出 :
-
执行
console.log('同步代码 2')- 控制台输出 :
同步代码 2
- 控制台输出 :
-
执行
queueMicrotask(...)- 微任务队列 :
[Then1, Await, Queue]
- 微任务队列 :
-
执行
MutationObserver- 微任务队列 :
[Then1, Await, Queue, Observer]
- 微任务队列 :
第二轮:清空微任务队列
-
取出
Then1执行- 控制台输出 :
Promise.then 1 - 宏任务队列 :
[SetTimeout1, SetTimeout2]
- 控制台输出 :
-
取出
Await执行- 控制台输出 :
await 后微任务
- 控制台输出 :
-
取出
Queue执行- 控制台输出 :
queueMicrotask 微任务
- 控制台输出 :
-
取出
Observer执行- 控制台输出 :
MutationObserver 微任务
- 控制台输出 :
第三轮:执行下一个宏任务
-
取出
SetTimeout1执行- 控制台输出 :
setTimeout 1 - 微任务队列 :
[InnerThen](宏任务中产生的微任务)
- 控制台输出 :
-
清空微任务队列 (执行
InnerThen)- 控制台输出 :
setTimeout 1 内部微任务
- 控制台输出 :
第四轮:执行再下一个宏任务
- 取出
SetTimeout2执行- 控制台输出 :
Promise.then 1 内部 setTimeout
- 控制台输出 :
最终输出结果
text
同步代码 1
Promise 构造函数
Promise 构造函数内 resolve 后
async 函数同步部分
同步代码 2
Promise.then 1
await 后微任务
queueMicrotask 微任务
MutationObserver 微任务
setTimeout 1
setTimeout 1 内部微任务
Promise.then 1 内部 setTimeout
(注:微任务之间的顺序主要取决于入队顺序,await 和 Promise.then 的具体先后可能因浏览器版本/ECMAScript 规范版本略有差异,但在现代浏览器中通常如上所示。MutationObserver 和 queueMicrotask 通常也在微任务队尾)
易错点与避坑指南
Promise 构造函数是同步的
很多人误以为 new Promise 里的代码是异步的。错!只有 .then()、.catch() 里的回调才是异步微任务。
Await 的本质
await xxx 相当于 Promise.resolve(xxx).then(() => { ...后续代码... })。它把异步代码写得像同步一样,但本质上它是让出了线程,把后续代码扔进了微任务队列。
微任务死循环 (Microtask Loop)
这是一个非常危险的操作! 宏任务 执行完一个,会给 UI 渲染的机会。 微任务则是"死磕到底"------只要队列不空,就不停地执行。
如果你在微任务里不断添加新的微任务:
javascript
function loop() {
Promise.resolve().then(loop); // 无限递归微任务
}
loop();
结果:页面完全卡死 。因为主线程一直忙着清空微任务,根本没机会去执行 UI 渲染,也没机会去执行下一个宏任务(如点击事件、定时器)。这比 while(true) 更隐蔽,但同样致命。
总结
JavaScript 的 Event Loop 就像一个不知疲倦的调度员:
- 先处理手里现有的急事(同步代码)。
- 处理完急事,马上看看有没有"小纸条"(微任务),有就一口气全处理完。
- 如果"小纸条"处理完了,喘口气,看看能不能画画(UI渲染)。
- 最后再去信箱里拿下一封信(宏任务),开始新的轮回。
理解了这个机制,你就能明白为什么 setTimeout(fn, 0) 不一定准时,为什么大量计算要放在 Web Worker 里,以及为什么你的页面有时候会莫名其妙地卡顿了。