JavaScript 是单线程语言,这意味着它一次只能执行一个任务。然而,通过事件循环机制,JavaScript 能够有效地处理并发操作,如定时器、promises 和 I/O 操作,而无需依赖多线程。
事件循环(event loop) 是 JavaScript 中负责管理代码执行顺序的机制,它通过协调同步任务、异步微任务和宏任务的执行,确保程序能够高效且非阻塞地运行。
同步代码
同步代码是指按照书写的顺序依次执行的代码段,这意味着每一段代码必须等待前一段代码执行完毕后才能开始执行。JavaScript 是单线程语言,这意味着它一次只能执行一个任务。因此,默认情况下,所有的代码都是以同步的方式执行的。
特点:
- 代码按照书写顺序依次执行。
- 每个任务必须等待前一个任务完成后才能开始。
- 如果某个任务耗时较长(如复杂的计算或I/O操作),会阻塞后续代码的执行,导致用户界面无响应或其他不良用户体验。
示例:
console.log('第一条消息');
let data = fetchData(); // 假设这是一个需要花费时间的操作
console.log('第二条消息', data);
在这个例子中,fetchData()
函数如果执行时间较长,则“第二条消息”将不得不等待直到 fetchData()
完成后才会显示。
异步代码
异步代码允许某些操作在后台运行而不阻塞主线程上的其他代码执行。当这些异步操作完成时,它们会通知 JavaScript 执行相应的回调函数。这种机制非常适合处理那些不需要立即返回结果的操作,比如网络请求、文件读写等。
实现方式:
- 回调函数:最基础的形式,通过将一个函数作为参数传递给另一个函数,并在异步操作完成后调用该函数。
- Promise:一种更强大和灵活的方式来处理异步操作,允许链式调用和更好的错误处理。
- async/await:基于 Promise 的语法糖,使得异步代码看起来更像同步代码,易于阅读和维护。
示例:
使用 Promise:
console.log('第一条消息');
fetchData().then(data => {
console.log('第二条消息', data);
});
console.log('第三条消息');
在这个例子中,“第三条消息”不会等待 fetchData()
完成即可打印出来,而“第二条消息”会在数据获取成功后显示。
使用 async/await:
async function displayData() {
console.log('第一条消息');
let data = await fetchData();
console.log('第二条消息', data);
console.log('第三条消息');
}
displayData();
这里,虽然使用了 await
关键字暂停了 displayData
函数的执行直到 fetchData()
完成,但因为它是异步的,所以不会影响到全局执行流程中的其他部分。
宏任务与微任务
JavaScript 中的任务主要分为两大类:宏任务和微任务。
- 宏任务 包括
setTimeout
和setInterval
等,它们负责安排代码在未来某个时间点执行。值得注意的是,整个<script>
标签本身也被视为一个宏任务。除此之外,在 Node.js 环境下还有process.nextTick
和setImmediate
方法,其中process.nextTick
总是在当前操作完成后立即执行,优先于所有其他微任务;而setImmediate
则被设计为在当前 poll 阶段结束后立即执行,相当于一个特殊的宏任务。 - 微任务 则由 promise 队列组成,遵循先进先出(FIFO)的原则。
async
/await
实际上是基于 promises 的语法糖,因此它们也属于微任务类别。这些任务会在当前任务完成后立即执行,通常用于处理需要优先于下一个宏任务执行的逻辑。此外,MutationObserver
也是微任务的一部分,它允许开发者监听 DOM 变化,并在变化发生后立即做出反应。 - 任务队列 不论是微任务还是宏任务都有属于它的任务队列,值得一提的是,宏任务队列一次取一个宏任务出来执行,而微任务队列一次全部清空。
事件循环的工作流程
事件循环的运作方式可以概括为以下步骤:
- 同步任务执行:首先执行调用栈中的所有同步任务。任何函数调用都会被推入调用栈,并按照顺序执行,直到栈为空。
- 微任务队列清空:一旦调用栈为空,事件循环会检查并执行所有的微任务。由于微任务可能在执行过程中产生新的微任务(例如在一个微任务内部返回一个新的 Promise),这个过程会一直持续到微任务队列完全清空为止。
- 页面渲染:如果有必要,浏览器此时会进行页面更新。
- 宏任务执行:接下来,事件循环从宏任务队列中取出一个任务来执行。完成之后,循环回到第二步,再次清空微任务队列。值得注意的是,在 Node.js 环境中,除了标准的宏任务外,还存在特殊的宏任务如
setImmediate
。 - 进入 idle:当没有更多的宏任务待执行时,系统将进入空闲状态,等待新的任务到来。当用户执行诸如点击、滑动滚轮等等事件时,事件循环又会从第一步开始,由此不断循环。
关键点解析
- 执行栈必须先清空,以确保所有同步任务都已完成,然后才会处理微任务或宏任务。
- 当创建一个新的 promise 时,其执行体(即传给构造函数的函数)是同步执行的。但是,
.then()
或.catch()
方法注册的回调会被放入微任务队列中,在当前任务结束后执行。 - 在实际应用中,可能会遇到在宏任务或微任务内部创建新的宏任务或微任务的情况。例如,在一个 promise 的
.then()
回调中设置另一个setTimeout
,或者在宏任务内部创建一个新的微任务。理解这种嵌套关系对于管理复杂异步流程至关重要。
b站会考的事件循环面试题
可以自己先思考一下执行顺序
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Async/Await Example</title>
</head>
<body>
<script>
async function async1() {
console.log('E');
await async2(); // 执行权交给async2
// async2 是异步函数 await 后面的代码会安排在微任务队列中
// 等当前的调用栈清空后才会执行。
// 不是真正的异步变同步 promise then 的语法糖
console.log('F');
}
async function async2() {
console.log('G');
}
setTimeout(() => console.log('H'), 0); // 宏任务
async1(); // 同步代码执行
new Promise((res) => {
console.log('I'); // 首次的同步执行栈
res();
}).then(() => console.log('J')); // 假如微任务队列
</script>
</body>
</html>
揭晓答案:E G I F J H
执行顺序
同步任务执行(首次执行栈):
- 调用
async1()
开始执行,打印 'E'。 - 调用
await async2()
,这将首先执行async2
函数,打印 'G'。因为async2
是一个异步函数,使用await
关键字时,它实际上返回一个 promise 并暂停async1
的执行直到该 promise 解析。但是,由于async2
内部没有其他异步操作,这个 promise 立即被解析。 - 在
async1
中,await
后面的代码(即console.log('F')
)被安排为一个微任务。 - 接下来,执行匿名立即执行的新 Promise 构造函数,打印 'I'。
- 新 Promise 的
.then()
方法注册了一个微任务,将在当前的任务完成后执行,打印 'J'。
微任务队列清空:
- 清空所有微任务,包括由
async1
和新 Promise 注册的微任务。因此,先打印 'F',然后是 'J'。
宏任务执行:
- 最后,处理宏任务队列中的唯一任务,即由
setTimeout
设置的任务,打印 'H'。
其他小tips
- 浏览器中的事件循环你需要把页面的渲染也考虑进去
- 由于node封装了v8引擎,在node环境里同样有事件循环机制,用node做后端事件循环就包括了
**I/O 操作**
以及**process.nextTick 和 setImmediate**
- MutationObserver:这是一个用于监听 DOM 变化的接口,其回调函数也会作为微任务执行。这允许开发者在 DOM 发生变化后立即做出响应,同时保持较高的性能,因为它不会阻塞主线程直到下一个重绘
最后,如果你想熟练判断代码的执行顺序,彻底搞懂执行栈与事件循环机制的交互,你可以尝试用这个网站练习一下: JS Visualizer 9000