前言
对于每一个JavaScript开发者来说,理解事件循环(Event Loop)是迈向高级水平的关键一步。JavaScript是单线程的,这意味着它一次只能做一件事。但我们日常接触的网页却是高度交互的,可以同时处理用户输入、网络请求和动画等多种任务。这背后的功臣,就是我们今天要探讨的------事件循环机制。
一、为什么需要事件循环?
想象一下,如果JavaScript没有异步处理能力,当一个需要耗时5秒的网络请求发出后,整个页面将会被冻结5秒,无法响应任何用户的点击或滚动操作。这无疑会带来极差的用户体验。
为了解决这个问题,JavaScript引入了异步的概念,而事件循环正是实现异步编程的核心机制。它允许主线程在等待耗时操作(如I/O、定时器)完成时,继续执行后续代码,从而避免了阻塞。
二、核心概念
在深入了解事件循环的工作流程之前,我们需要先掌握几个基本概念:
1. 调用栈(Call Stack)
调用栈是一个 后进先出(LIFO) 的数据结构,用于存储和管理函数调用。当一个脚本开始执行时,全局执行上下文被压入栈中。每当一个函数被调用,它的执行上下文就会被创建并压入栈顶;当函数执行完毕返回时,其执行上下文就会从栈顶被弹出。
javascript
function third() {
console.log('Hello from third!');
}
function second() {
third();
}
function first() {
second();
}
first();
上述代码的调用栈变化过程是:first
进栈 -> second
进栈 -> third
进栈 -> third
出栈 -> second
出栈 -> first
出栈。
2. Web APIs
Web APIs 是浏览器提供给 JavaScript 引擎的一系列功能接口,它们不属于 JavaScript 核心语言,而是由浏览器环境(或 Node.js 环境)提供。
Web APIS的作用
Web APIs 的核心作用是处理那些无法立即完成的、耗时的任务,从而让 JavaScript 的主线程能够"脱身"去处理其他事情。可以把它们想象成是浏览器的"后台工作人员"。
当你调用一个异步函数时,比如 setTimeout
来设置一个定时器,或者用 fetch
来请求一个网络资源,JavaScript 引擎并不会傻傻地等待。它会做以下两件事:
-
- 将这个任务(例如"5秒后执行某个函数")转交给对应的 Web API。
-
- 然后继续执行调用栈中剩余的同步代码,完全不耽误。
浏览器接手这个任务后,会在自己的独立线程中进行处理 。当任务完成时(比如定时器时间到了,或者网络数据返回了),Web API 并不会直接把结果插回 JavaScript 主线程,而是会将指定的回调函数放入相应的任务队列(宏任务或微任务队列)中,排队等待被事件循环机制取回执行。
常见的 Web APIs 包括:
- DOM 操作 :
document.getElementById
,addEventListener
等。 - 定时器 :
setTimeout
,setInterval
。 - 网络请求 :
fetch
,XMLHttpRequest
(AJAX)。
3. 任务队列(Task Queue)
任务队列是一个先进先出(FIFO)的结构,用于存放待执行的回调函数。当一个异步任务(如setTimeout
的定时结束、AJAX请求成功返回)完成时,其对应的回调函数会被放入任务队列中,等待被执行。
任务队列又分为两种:
- 宏任务队列(Macrotask Queue) : 也叫任务队列(Task Queue) 。用于存放像
setTimeout
,setInterval
,setImmediate
(Node.js), I/O操作, UI渲染等任务的回调。 - 微任务队列(Microtask Queue) : 用于存放像
Promise.then/catch/finally
,async/await
,process.nextTick
(Node.js),MutationObserver
等任务的回调。微任务的优先级高于宏任务。
三、事件循环的工作流程
事件循环的机制可以用一个简单的循环来描述,它不知疲倦地执行以下步骤:
-
- 执行同步代码:首先,执行调用栈中的所有同步代码,直到调用栈为空。
-
- 检查微任务队列:在调用栈清空后,立即检查微任务队列。
-
- 执行所有微任务:如果微任务队列不为空,则一口气执行完队列中所有的微任务。如果在执行微任务的过程中,又产生了新的微任务,那么这些新的微任务也会被添加到队列的末尾,并在当前轮次中被执行完毕。
-
- 取出一个宏任务:当微任务队列为空后,事件循环会检查宏任务队列。如果队列不为空,则取出一个宏任务,并将其回调函数压入调用栈中执行。
-
- 重复:一个宏任务执行完毕后,调用栈再次清空。事件循环回到第2步,再次检查微任务队列,如此往复循环。
- 关键点 :一次事件循环只会执行一个宏任务,但会执行所有微任务。
代码示例
让我们通过一个经典的例子来巩固理解:
javascript
console.log('script start'); // 1. 同步代码
setTimeout(function() {
console.log('setTimeout'); // 5. 宏任务
}, 0);
Promise.resolve().then(function() {
console.log('promise1'); // 3. 微任务
}).then(function() {
console.log('promise2'); // 4. 微任务
});
console.log('script end'); // 2. 同步代码
-
执行分析:
-
console.log('script start')
是同步代码,立即执行,打印 "script start"。
-
- 遇到
setTimeout
,将其回调函数交给 Web API 处理。0毫秒后,Web API 将该回调函数放入宏任务队列。
- 遇到
-
- 遇到
Promise.resolve().then()
,.then()
中的回调是微任务,被放入微任务队列。
- 遇到
-
console.log('script end')
是同步代码,立即执行,打印 "script end"。
-
- 至此,所有同步代码执行完毕,调用栈为空。
-
- 事件循环检查微任务队列,发现里面有
promise1
的回调。执行它,打印 "promise1"。执行过程中,返回一个新的Promise,其.then()
(即promise2
的回调) 被放入微任务队列的末尾。
- 事件循环检查微任务队列,发现里面有
-
- 当前轮次的微任务还没执行完,继续检查微任务队列,发现了
promise2
的回调。执行它,打印 "promise2"。
- 当前轮次的微任务还没执行完,继续检查微任务队列,发现了
-
- 现在微任务队列为空了。
-
- 事件循环去宏任务队列取出一个任务,即
setTimeout
的回调。压入调用栈执行,打印 "setTimeout"。
- 事件循环去宏任务队列取出一个任务,即
-
- 所有队列都为空,脚本执行结束。
-
-
最终输出顺序:
arduinoscript start script end promise1 promise2 setTimeout
结语
事件循环是JavaScript异步编程的基石。理解它,你就能明白为什么 setTimeout(fn, 0)
不会立即执行,也能清晰地预测 Promise
和 setTimeout
混合代码的执行顺序。掌握了事件循环,你将能更自信地编写出高效、稳定且可预测的JavaScript代码。
希望这篇文章有帮助到你,如果文章有错误,请你在评论区指出,大家一起进步,谢谢🙏。