JavaScript 是一门单线程语言,这意味着它一次只能执行一个任务。为了处理异步操作如网络请求、定时器等,JavaScript 使用了事件轮询机制。接下来我们将深入探讨这一核心概念,并通过详细的文字描述帮助你构建一个清晰的事件轮询工作原理图。
🧠 一、JavaScript 是单线程语言,但如何实现异步?
JavaScript 最初设计为单线程语言,意味着它在同一时间只能执行一个任务。这种设计避免了多线程中常见的复杂问题(如死锁、竞态条件),但也带来了一个挑战:如果某个任务很耗时,整个页面就会“卡住”。
为了解决这个问题,JavaScript 引入了 事件循环机制(Event Loop),通过将任务分为不同类型(宏任务和微任务),在主线程空闲时按优先级依次执行这些任务,从而实现了高效的异步编程。
📌 二、事件循环的五大核心组成部分
1. 调用栈(Call Stack)
调用栈是 JavaScript 执行函数的地方。当函数被调用时,它会被压入调用栈;函数执行完毕后,会从调用栈弹出。
function foo() {
console.log('foo');
}
function bar() {
foo();
}
bar(); // 函数调用顺序形成调用栈
调用栈变化:
bar()入栈 →foo()入栈 →foo()出栈 →bar()出栈
2. 浏览器 API(Web APIs)
对于像 setTimeout、fetch、addEventListener 这类异步操作,它们不是由 JavaScript 主线程直接执行的,而是交由浏览器的其他线程处理(如定时器线程、网络线程等)。
当这些操作完成后,它们会将回调放入相应的任务队列中等待执行。
3. 回调队列(Task Queue)
宏任务队列(Macrotask Queue)
- 存放宏任务,如:
setTimeoutsetIntervalDOM 事件I/O- 整个
<script>标签的内容
微任务队列(Microtask Queue)
- 存放微任务,如:
Promise.then / .catch / .finallyMutationObserverqueueMicrotask- Node.js 中的
process.nextTick
📌 重点原则:
在一次完整的事件循环中,先执行所有微任务,再执行下一个宏任务。
4. 事件循环(Event Loop)
事件循环是一个持续运行的机制,它的主要职责是:
- 检查调用栈是否为空;
- 如果调用栈为空,则查看微任务队列是否有待执行的任务;
- 如果微任务队列也为空,则查看宏任务队列是否有任务;
- 将选中的任务推入调用栈并执行。
🎯 三、宏任务 vs 微任务:优先级差异详解
| 类型 | 示例 | 特点 |
|---|---|---|
| 宏任务 | setTimeout, setInterval, DOM 事件, I/O | 每次事件循环只执行一个宏任务 |
| 微任务 | Promise.then/catch/finally, MutationObserver, queueMicrotask, process.nextTick (Node) | 一旦有微任务就立即执行,直到微任务队列清空 |
🔁 四、事件循环完整执行流程
为了更清晰地展示 JavaScript 事件轮询的工作原理,下面是一个详细的流程描述:
- 初始状态:程序开始运行,调用栈为空。
- 同步代码执行:所有同步代码被推入调用栈并依次执行。
- 异步操作触发:
- 当遇到如
setTimeout或者网络请求等异步操作时,它们不会立即进入调用栈,而是通过浏览器 API 处理。 - 这些操作完成后,其回调函数会被放入宏任务队列中。
- 当遇到如
- Promise 和其他微任务产生:当 Promise 被解析(fulfilled 或 rejected),它的
.then()或.catch()方法中的回调会被添加到微任务队列。 - 检查微任务队列:在每次宏任务结束后,JavaScript 引擎会清空当前的微任务队列,确保所有微任务都得到处理。
- 宏任务执行:一旦微任务队列为空,事件轮询会选择下一个宏任务执行,重复上述过程。
- 页面渲染:通常,在宏任务执行完毕后且没有更多宏任务待处理时,浏览器会进行页面重绘或重新布局。
文字流程示例:
- Step 1: 程序启动,首先执行全局脚本,这作为一个宏任务存在于宏任务队列中。
- Step 2: 在执行过程中,如果遇到像
fetch这样的异步请求,它会被传递给 Web APIs 处理,而不会阻塞后续代码的执行。 - Step 3: 如果在此期间有任何 Promise 被解决,相关的回调将被加入到微任务队列。
- Step 4: 全局脚本执行完毕后,JavaScript 引擎会检查并执行所有的微任务。
- Step 5: 完成微任务后,如果存在任何宏任务(例如由
setTimeout设置的回调),则选择一个宏任务执行。 - Step 6: 如果此时没有更多的宏任务等待执行,浏览器可能会选择更新用户界面。
🧪 五、经典代码示例 + 详细解析
示例1:基本宏任务与微任务执行顺序
console.log("script start");
setTimeout(() => {
console.log("setTimeout");
}, 0);
Promise.resolve().then(() => {
console.log("promise");
});
console.log("script end");
执行流程分析:
-
同步代码:
- 输出
"script start" - 注册
setTimeout到 Web API - 注册
Promise.then到微任务队列 - 输出
"script end"
- 输出
-
同步任务结束,调用栈为空。
-
执行微任务队列:
- 输出
"promise"
- 输出
-
微任务队列清空,进入宏任务队列:
- 输出
"setTimeout"
- 输出
✅ 输出结果:
script start
script end
promise
setTimeout
示例2:多个 Promise 和 DOM 操作混合使用
<script>
const target = document.createElement("div");
document.body.appendChild(target);
const observer = new MutationObserver(() => {
console.log("微任务:MutationObserver");
});
observer.observe(target, { attributes: true, childList: true });
target.setAttribute("data-set", "1111");
target.appendChild(document.createElement("span"));
target.setAttribute("style", "background-color:red;");
</script>
解析:
- 所有对 DOM 的修改都会被记录;
- 当前宏任务结束后,触发
MutationObserver回调,作为微任务执行; - 所以输出是在所有 DOM 修改完成之后,但在下一个宏任务之前。
✅ 输出结果:
微任务:MutationObserver
示例3:Node.js 中的 process.nextTick
console.log("Start");
Promise.resolve().then(() => {
console.log("Promise resolved");
});
process.nextTick(() => {
console.log("Process next tick");
});
setTimeout(() => {
console.log("hhhh");
Promise.resolve().then(() => {
console.log("inner Promise");
});
}, 0);
console.log("End");
执行流程分析:
- 同步输出:
Start→End - Node.js 中
process.nextTick()的优先级高于Promise.then(),所以先执行:Process next tick
- 再执行微任务队列中的
Promise.then()→Promise resolved - 下一轮事件循环,执行宏任务
setTimeout→hhhh - 在该宏任务中又产生了一个新的微任务 →
inner Promise
✅ 输出结果:
Start
End
Process next tick
Promise resolved
hhhh
inner Promise
示例4:多个 Promise 与 setTimeout 混合使用
console.log("同步Start");
const promise1 = Promise.resolve("First Promise");
const promise2 = Promise.resolve("Second Promise");
const promise3 = new Promise(resolve => resolve("Third Promise"));
setTimeout(() => {
console.log("setTimeout");
const promise4 = Promise.resolve("Fourth Promise");
promise4.then(value => console.log(value));
}, 0);
setTimeout(() => {
console.log("setTimeout2");
});
[promise1, promise2, promise3].forEach(p => p.then(value => console.log(value)));
console.log("同步End");
执行流程分析:
- 同步输出:
同步Start→同步End - 三个 Promise 已经解决(resolved),其
.then回调加入微任务队列:First PromiseSecond PromiseThird Promise
- 微任务队列依次执行:
- 输出以上三个 Promise 的值
- 下一轮事件循环开始,执行第一个
setTimeout:- 输出
setTimeout - 里面的
promise4.then(...)加入微任务队列 → 输出Fourth Promise
- 输出
- 第二个
setTimeout执行 → 输出setTimeout2
✅ 输出结果:
同步Start
同步End
First Promise
Second Promise
Third Promise
setTimeout
Fourth Promise
setTimeout2
示例5:queueMicrotask 的使用
<script>
console.log("同步");
queueMicrotask(() => {
console.log("微任务: queueMicrotask");
});
console.log("同步结束");
</script>
解析:
queueMicrotask()用于将一个函数添加到微任务队列,执行时机是在当前宏任务结束后,下一个宏任务开始前。- 与
Promise.then()类似,但更直观地表达了“我是一个微任务”的意图。
✅ 输出结果:
同步
同步结束
微任务: queueMicrotask
🖼️ 六、事件循环流程图
[ Call Stack ]
↓
[ Macro Task Start ]
↓
[ Execute Sync Code ]
↓
[ Micro Tasks Execution ]
↓
[ Optional Render Update ] ← 浏览器决定是否更新 UI
↓
[ Next Macro Task ]
↓
[ Repeat ]
📌 渲染时机说明:
- 渲染通常发生在每个宏任务结束后;
- 如果此时还有未执行的微任务,浏览器会推迟渲染,直到所有微任务执行完毕;
- 因此,微任务之间不会出现视图更新,确保逻辑一致性。
❓七、常见问题解析
Q1:为什么 setTimeout(fn, 0) 不等于立即执行?
A:因为 setTimeout 是宏任务,必须等到当前宏任务(即当前 script 脚本)和所有微任务执行完才会被执行。
Q2:Promise.then() 和 process.nextTick() 哪个先执行?
A:在 Node.js 中,process.nextTick() 优先于 Promise.then() 执行。
Q3:什么是“微任务爆炸”?
A:当大量微任务连续被创建(例如递归调用 Promise.then()),可能导致主线程长时间无法执行宏任务,造成页面“冻结”。
🎯 八、结语:掌握事件循环的重要性
事件循环是 JavaScript 异步编程的基石,理解其原理可以帮助我们:
- 编写高性能、响应迅速的应用;
- 避免因异步逻辑混乱导致的 bug;
- 更好地优化用户体验(如提前更新 DOM 或延迟加载数据);
- 应对前端开发中的高级场景(如动画、懒加载、服务端渲染 SSR)。
希望本文能帮助你系统地掌握 JavaScript 的事件循环机制,并在实际项目中灵活运用!如果你觉得这篇文章对你有帮助,欢迎收藏、点赞、转发!