引言
在现代Web开发中,理解JavaScript的事件循环机制对于构建高效、响应式的应用程序至关重要。无论是处理用户交互、网络请求还是定时任务,JavaScript以其独特的单线程异步编程模型提供了强大的功能。然而,这一机制背后的核心概念------宏任务(macrotask)与微任务(microtask),以及它们如何影响代码执行顺序,往往容易被忽视。本文将深入探讨这些关键概念,并通过具体的代码示例展示它们的实际应用,帮助开发者更好地掌握JavaScript异步编程的本质。
JavaScript的单线程模型
JavaScript是一种单线程语言,这意味着它在任何给定时间点只能执行一个任务。这种设计选择主要是为了简化语言的并发模型,并避免多线程编程中常见的复杂问题,如死锁和资源竞争。尽管是单线程的,JavaScript通过事件驱动架构实现了非阻塞操作,这使得它非常适合用于用户界面交互等场景。
在浏览器环境中,JavaScript引擎(例如V8)负责执行代码,同时浏览器提供了一个运行时环境(宿主环境:浏览器环境或者Node环境)来处理诸如网络请求、计时器以及DOM操作等任务。由于这些操作可能需要一些时间才能完成,如果它们同步执行,则会阻塞主线程,导致页面无响应。因此,JavaScript采用了异步编程模型来解决这个问题。
异步编程的基础概念
异步编程允许程序在等待某个耗时操作完成的同时继续执行其他任务,而不是暂停等待该操作结束。JavaScript提供了几种机制来实现异步编程:
- 回调函数 :这是最基本的形式,即当某个异步操作完成后调用指定的函数。例如,
setTimeout()
或XMLHttpRequest
都使用了回调函数。 - Promise:为了解决"回调地狱"的问题而引入的一种更高级的抽象。Promise代表一个最终可能完成或失败的操作,并提供了更清晰的方式来链式处理异步操作的结果。
- async/await:ES2017引入的新语法糖,基于Promise之上,使得异步代码看起来更像同步代码,提高了代码的可读性和维护性。
事件循环机制
核心组成部分
事件循环主要由以下关键部分组成:
- 调用栈:这是一个LIFO(后进先出)的数据结构,用于追踪当前正在执行的函数。每当一个函数被调用时,该函数会被添加到调用栈的顶部;当函数执行完毕后,则从栈顶移除。通过这种方式,JavaScript引擎能够跟踪函数的嵌套和调用顺序。
- 任务队列:为了处理异步操作,JavaScript使用了两种类型的任务队列------宏任务(macrotask)队列和微任务(microtask)队列。每种队列都有特定的用途和触发时机,以确保异步操作按预期顺序执行。
事件循环的工作流程
- 执行全局脚本:作为初始宏任务,JavaScript引擎开始执行全局脚本。
- 宏任务执行:完成当前宏任务后,检查并执行所有已注册的微任务,直到微任务队列为空。
- 微任务执行:一旦微任务队列为空,如果需要进行UI更新,则执行相应的渲染操作。
- 准备下一轮循环:之后,事件循环回到宏任务队列,取下一个宏任务重复上述步骤。
- 简而言之:同步任务清空执行栈 -> 微任务队列(一次清空) -> 页面渲染 -> 宏任务队列(每次一个) -> 进入idle状态 之后一直循环
宏任务与微任务
宏任务(Macrotask)
宏任务是指那些需要被放入事件循环的任务队列并在当前执行上下文完成后执行的操作。它们包括但不限于:
浏览器环境中的宏任务
- I/O操作:虽然JavaScript本身不直接处理I/O操作,但通过Web API可以触发异步I/O操作(如文件读写、网络请求等),这些操作的回调属于宏任务。(浏览器)
- setTimeout 和 setInterval:用于在指定时间后执行代码块。适用于需要延迟执行某些代码的情况。(浏览器/Node.js)
- UI渲染:尽管不是严格意义上的"宏任务",但在浏览器中,在微任务队列被清空之后,理论上浏览器有机会进行页面的重新渲染。然而,实际上是否进行渲染取决于多种因素,包括是否有任何更新需要渲染、浏览器内部的优化策略等。并不是每次微任务队列清空后都会立即触发页面渲染。
- 用户交互事件:例如点击、滚动、输入等产生的事件处理程序。(仅浏览器)
- postMessage :通过
window.postMessage
发送的消息会在接收端作为宏任务处理。(浏览器) - MessageChannel:创建一个新的消息通道,允许跨上下文通信,其回调函数作为宏任务处理。(浏览器/Node.js)
- close 事件 :当一个
<iframe>
或者窗口关闭时触发的事件处理程序。(仅浏览器) - load 事件:文档或某个资源加载完成后的回调。(仅浏览器)
Node.js环境中的宏任务
- setImmediate :这个API用于在当前poll阶段完成后立即执行回调函数,它提供了一种比
setTimeout(..., 0)
更精确地安排回调的方式。(仅Node.js) - fs 模块的异步方法 :如
fs.readFile
、fs.writeFile
等异步文件系统操作的回调。(仅Node.js) - 网络请求:如HTTP请求的回调,使用Node.js内置模块进行网络通信时。(仅Node.js)
- DNS 模块的异步方法 :如
dns.lookup
等异步DNS查询的回调。(仅Node.js) - 计时器(setTimeout 和 setInterval) :与浏览器中的相同,用于延迟执行代码块。(Node.js)
请注意,尽管一些宏任务(如setTimeout
和setInterval
)在浏览器和Node.js中都可用,但它们的行为可能会因环境而略有不同,特别是在定时精度方面。此外,随着技术的发展,可能会有新的宏任务类型被引入到这两个环境中。
每次事件循环迭代中,会从宏任务队列中取出一个任务执行,执行完毕后再处理微任务队列中的所有任务。
微任务(Microtask)
浏览器环境中的微任务
- Promise 回调 :当一个
Promise
对象被决议(无论是fulfilled还是rejected),其.then()
或.catch()
方法注册的回调函数将作为微任务排队并执行。(浏览器/Node.js) - MutationObserver 回调 :这是一个用于监听DOM变化的接口。当你使用
MutationObserver
来监视DOM树的变化时,一旦观察到指定的变动,相关的回调函数就会作为微任务执行。(仅浏览器) - queueMicrotask() 方法:这是ECMAScript提供的一个标准方法,允许你手动向微任务队列添加一个微任务。这对于确保某些代码在当前调用栈完成之后但在下一个宏任务开始之前执行非常有用。(浏览器/Node.js)
Node.js环境中的微任务
- Promise 回调 :与浏览器环境相同,
Promise
的.then()
或.catch()
方法注册的回调函数在Node.js中同样作为微任务处理。(浏览器/Node.js) - process.nextTick:这是Node.js特有的机制,它允许你将回调函数插入到微任务队列的最前面,在当前操作完成后立即执行。尽管它不是标准的微任务,但它的行为类似于微任务。(仅Node.js)
- queueMicrotask() 方法 :与浏览器环境相同,Node.js也支持
queueMicrotask()
方法,允许手动添加微任务。(浏览器/Node.js)
需要注意的是,虽然有些微任务机制(如Promise
回调和queueMicrotask()
)在浏览器和Node.js中都可用,但它们的行为和优先级可能会因环境而略有不同。例如,在Node.js中,process.nextTick
具有最高的调度优先级,甚至高于其他微任务。
微任务保证了某些高优先级的异步操作能够尽快得到处理,而不需要等待下一轮事件循环。
主线程与宏任务
在JavaScript中,所有的同步代码(包括直接写在.js
文件中的代码)都被视为一个宏任务,并且是在主线程上执行的。这意味着当JavaScript引擎开始执行一段脚本时,它首先会处理这段脚本作为一个宏任务的一部分。在这个过程中,任何同步操作,如变量赋值、函数调用和console.log()
输出等,都是直接在主线程上顺序执行的。(简单来说虽然变量赋值、函数调用和console.log()输出等都是宏任务,但是他们都是立即执行的,如果中间有微任务那么也是他们先执行微任务后执行。因为他们是在主线程上执行的同步代码。)
Javascript
console.log('script start');
上述代码行是一个典型的同步语句,它作为整个脚本宏任务的一部分被立即执行在主线程上。这里,"宏任务"的概念更多是指这个脚本作为一个整体与其他异步回调(例如通过setTimeout
设置的定时器回调)区分开来的一种方式。
工作流程示例
考虑以下代码段:
Javascript
console.log('script start'); // 同步操作,立即执行
setTimeout(() => {
console.log('timeout callback'); // 宏任务,将在当前宏任务(整个脚本)完成后执行
}, 0);
Promise.resolve().then(() => {
console.log('promise then'); // 微任务,在当前宏任务结束后,但在事件循环继续到下一个宏任务之前执行
});
let a = 1; // 变量赋值,同步操作,立即执行
console.log(a); // 同步操作,立即执行
console.log('script end'); // 同步操作,立即执行
输出顺序将是:
arduino
script start
1
script end
promise then
timeout callback
解释如下:
- 首先打印出
script start
、1和script end
,因为这些都是同步代码。 - 然后,在当前宏任务(即整个脚本)结束后,JavaScript引擎会处理微任务队列中的任务,因此
Promise.resolve().then(...)
中的回调将被执行,打印出promise then
。 - 最后,下一个宏任务(在这里是由
setTimeout
设置的回调)被执行,打印出timeout callback
。
常考题
分析题
请你仔细分析代码,推算出输出的结果。切记不要看答案请先仔细分析
Javascript
async function async1() {
console.log('E');
await async2();
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'));
这段代码展示了JavaScript中异步编程的基本概念,包括宏任务、微任务以及async/await
的执行顺序。让我们逐步分析其执行流程:
执行流程
-
初始调用:
- 首先执行
async1()
函数,这是一段同步代码调用,因此立即开始执行。 - 在
async1
内部,首先打印出'E'
。
- 首先执行
-
调用
async2()
:async2()
函数被调用,并打印出'G'
。- 因为
async2
是一个异步函数(尽管在这个例子中它没有返回一个真正的异步操作),但它的执行是同步的直到遇到第一个await
或返回。
-
处理
await async2();
:- 虽然
async2
本身是同步执行完毕的,但是由于使用了await
关键字,JavaScript引擎会将await
之后的代码视为一个微任务,并将其添加到微任务队列中等待当前调用栈清空后再执行。 - 此时,控制权暂时返回给调用者,继续执行后续代码。
- 虽然
-
Promise构造函数:
- 紧接着,在主程序流中创建了一个新的
Promise
实例并立即执行其执行器函数,打印出'I'
。 - 该
Promise
迅速解决(resolve),并将对应的.then()
回调加入微任务队列中。
- 紧接着,在主程序流中创建了一个新的
-
设置定时器:
- 设置了一个
setTimeout
,用于在至少0毫秒延迟后执行回调函数,这会在下一轮事件循环作为宏任务来执行,打印'H'
。
- 设置了一个
-
微任务队列执行:
- 当前执行栈为空后,JavaScript引擎开始处理微任务队列中的所有任务。
- 首先是
async1
函数中await
后的代码,打印'F'
。 - 接着是之前加入微任务队列中的
.then()
回调,打印'J'
。
-
宏任务执行:
- 最后,当所有的微任务都处理完毕后,才会轮到宏任务队列中的任务被执行。
- 此时,执行
setTimeout
的回调函数,打印'H'
。
输出顺序
基于上述分析,最终的输出顺序应为:E G I F J H
。
E
来自于async1
函数的直接调用。G
是async2
的输出。I
是new Promise
构造函数执行的结果。F
是async1
中await
后面的代码,被安排为微任务。J
是.then()
方法注册的回调,也是一个微任务。- 最后,
H
是由setTimeout
安排的宏任务。
JavaScript
// E G I F J H
async function async1() {
console.log('E'); // 1
await async2(); // 执行权交给async2
// async2 是异步函数 await 后面的代码会安排在微任务队列中
// 等当前的调用栈清空后才会执行。
// 不是真正的异步变同步 promise then 的语法糖
// 如果你难以理解那就理解成使用async/await时
// 把await后面的代码包在.then()里面了所以他进入了微任务队列
console.log('F'); // 4
}
async function async2() {
console.log('G'); // 2
}
setTimeout(() => console.log('H'), 0);// 宏任务 6
async1(); // 同步代码执行
// executer 执行器函数
new Promise((res) => {
console.log('I'); // 3 首次的同步执行栈
res();
}).then(() => console.log('J')); // 假如微任务队列 5
MutationObserver
MutationObserver
是一个用于监听 DOM 变化的接口。它允许你监视 DOM 树的变动,比如元素的添加、删除、属性的变化或文本内容的修改等。当指定的 DOM 变化发生时,MutationObserver
会触发回调函数。这个回调函数是作为微任务执行的,这意味着它的优先级较高,并且会在当前执行栈为空之后但在下一个宏任务开始之前被执行。
使用步骤
- 创建 MutationObserver 实例 :首先需要创建一个
MutationObserver
的实例,并定义其回调函数。该回调函数会在观察到任何指定的变化时被调用。 - 配置观察选项 :你需要提供一个选项对象给
observe()
方法来告诉MutationObserver
应该监听哪些类型的DOM变化。例如,是否监听子节点的变化、属性的变化等。 - 开始观察目标节点 :通过调用
observe()
方法并传入要观察的目标节点和选项对象来启动观察。 - 停止观察 :当你不再需要监听DOM变化时,可以通过调用
disconnect()
方法来停止观察,这将停止所有已注册的观察。
示例代码
Html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>MutationObserver Example</title>
</head>
<body>
<!-- 按钮用于触发DOM变化 -->
<button id="toggle">Toggle Paragraph</button>
<!-- 目标容器 -->
<div id="container">
<p>This is a paragraph.</p>
</div>
<script>
// 选择需要观察变动的节点
var targetNode = document.getElementById('container');
// 配置观察选项:仅监听子节点的添加和移除
var config = { childList: true };
// 创建一个回调函数,在每次观察到变动时被调用
var callback = function(mutationsList, observer) {
for(var mutation of mutationsList) {
if (mutation.type === 'childList') {
console.log('A child node has been added or removed.');
}
}
};
// 创建一个 MutationObserver 实例并传入回调函数
var observer = new MutationObserver(callback);
// 开始观察目标节点的指定变化
observer.observe(targetNode, config);
// 添加事件监听器来触发 DOM 变化
document.getElementById('toggle').addEventListener('click', function() {
var container = document.getElementById('container');
var p = container.querySelector('p');
if (p) {
// 如果存在段落,则移除它
container.removeChild(p);
} else {
// 如果不存在段落,则添加一个新的段落
var newP = document.createElement('p');
newP.textContent = 'This is a dynamically added paragraph.';
container.appendChild(newP);
}
});
</script>
</body>
</html>
解释
-
HTML 结构:
- 包含一个按钮 (
id="toggle"
),用户点击该按钮可以触发DOM的变化。 - 一个
div
容器 (id="container")
,初始情况下包含一个段落<p>
元素。
- 包含一个按钮 (
-
JavaScript 逻辑:
- 使用
document.getElementById
获取要观察的目标节点 (targetNode
)。 - 配置
MutationObserver
的观察选项 (config
),这里只监听子节点的添加或删除 (childList: true
)。 - 定义
callback
函数,当检测到任何指定类型的DOM变化时,该函数会被调用,并打印一条消息到控制台。 - 创建
MutationObserver
实例并开始观察目标节点的变化。 - 给按钮添加一个点击事件监听器,当按钮被点击时,检查
container
内是否含有段落<p>
元素。如果有,则移除;如果没有,则添加一个新的段落。
- 使用
通过这个例子,你可以看到每当通过点击按钮添加或移除段落元素时,MutationObserver
回调函数就会被触发,并在控制台上打印出相应的消息。这演示了如何利用 MutationObserver
来监听DOM的变化,并根据这些变化执行特定的操作。
queueMicrotask
queueMicrotask()
方法是现代浏览器和Node.js环境中提供的一种机制,用于将一个微任务(microtask)排队,该微任务将在当前调用栈清空之后但在任何新事件循环迭代开始之前执行。这个方法提供了一种标准化的方式来安排代码在DOM更新之前但尽可能快地执行,而不需要依赖于像Promise
这样的间接手段。
使用场景
- 避免布局抖动 :当你需要在DOM修改后立即进行读操作(如获取元素的尺寸或位置),使用
queueMicrotask()
可以确保这些读操作发生在所有写操作完成之后,但是还没有触发重新渲染之前。 - 处理微任务队列中的任务 :如果你有任务需要以高优先级执行,并且希望它们在当前执行栈清空之后尽快执行,但又不希望它们打断当前的执行流程,可以使用
queueMicrotask()
。 - 替代
Promise.resolve().then(...)
:在某些情况下,你可能想要安排一个微任务而不创建不必要的Promise
实例。queueMicrotask()
提供了一个更直接的方法来做到这一点。
基本语法
Javascript
queueMicrotask(function () {
// 微任务执行的代码
});
或者,如果你使用箭头函数:
Javascript
queueMicrotask(() => {
// 微任务执行的代码
});
示例
这里有一个简单的例子,展示了如何使用queueMicrotask()
来确保一段代码在当前调用栈清空之后执行:
Javascript
console.log('Start');
// 立即执行的任务
queueMicrotask(() => {
console.log('This is a microtask');
});
console.log('End');
输出顺序将是:
sql
Start
End
This is a microtask
在这个例子中,queueMicrotask
中的回调函数会在Start
和End
被打印出来之后执行,但是在下一次事件循环开始之前。
示例:避免布局抖动
假设我们有一个网页应用,其中用户可以点击按钮来动态添加或删除段落,并且我们希望在每次DOM更新后立即检查页面上所有段落的总高度,以决定是否需要显示滚动条。为了避免不必要的重排(reflow),我们可以在DOM修改完成后,但在浏览器有机会重新渲染之前,使用queueMicrotask()
来安排这些读取操作。
HTML 和 JavaScript 代码:
Html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>queueMicrotask Example</title>
<style>
#container {
width: 200px;
border: 1px solid black;
margin-bottom: 10px;
max-height: 500px; /* 设置一个最大高度 */
overflow-y: hidden; /* 默认隐藏滚动条 */
}
p {
height: 50px;
background-color: lightblue;
margin: 5px 0;
}
</style>
</head>
<body>
<button id="add">Add Paragraph</button>
<button id="remove">Remove Paragraph</button>
<div id="container">
<p>This is a paragraph.</p>
</div>
<p id="status"></p>
<script>
const container = document.getElementById('container');
const status = document.getElementById('status');
function updateStatus() {
// 使用 queueMicrotask 来确保在DOM更新后立即计算高度
queueMicrotask(() => {
// 获取容器的实际最大高度(包括padding和border)
const maxHeight = parseInt(getComputedStyle(container).maxHeight);
if (container.scrollHeight > maxHeight) {
container.style.overflowY = 'auto'; // 启用滚动条
status.textContent = 'Scrollbar needed!';
} else {
container.style.overflowY = 'hidden'; // 隐藏滚动条
status.textContent = 'No scrollbar.';
}
});
}
document.getElementById('add').addEventListener('click', () => {
const newP = document.createElement('p');
newP.textContent = 'This is another paragraph.';
container.appendChild(newP);
updateStatus();
});
document.getElementById('remove').addEventListener('click', () => {
const paragraphs = container.querySelectorAll('p');
if (paragraphs.length > 0) {
container.removeChild(paragraphs[paragraphs.length - 1]);
}
updateStatus();
});
</script>
</body>
</html>
关键点解释
-
获取容器的最大高度:
- 使用
getComputedStyle(container).maxHeight
获取容器的最大高度,并将其转换为整数进行比较。
- 使用
-
比较
scrollHeight
和maxHeight
:- 在
updateStatus
函数中,我们通过比较container.scrollHeight
和maxHeight
来判断是否需要启用滚动条。 - 如果
scrollHeight
超过了maxHeight
,则将overflow-y
设置为auto
,这会启用滚动条。 - 如果没有超过,则保持
overflow-y
为hidden
,从而隐藏滚动条。
- 在
与Promise
的区别
虽然你可以通过Promise.resolve().then(callback)
来达到类似的效果,但queueMicrotask()
提供了几个潜在的优势:
- 更明确的意图:它清楚地表明你是在排队一个微任务。
- 性能:避免了创建不必要的
Promise
对象,尽管在实际应用中这种差异可能是微不足道的。
process.nextTick
process.nextTick()
是 Node.js 中提供的一种机制,用于在当前操作完成后、下一次事件循环开始前立即执行回调函数。它与浏览器环境中的微任务(如 Promise
的 .then()
或 queueMicrotask()
)类似,但仅限于 Node.js 环境中使用。process.nextTick()
回调会在当前操作的末尾和任何 I/O 操作之前执行,这意味着它的优先级高于其他微任务。
使用场景
- 优先执行 :当你需要确保某个回调函数在当前操作结束后尽快执行,并且优先于其他微任务时,可以使用
process.nextTick()
。 - 避免I/O阻塞 :在某些情况下,你可能希望在处理I/O操作之前快速执行一些逻辑来调整状态或进行必要的检查,这时也可以使用
process.nextTick()
。
基本语法
Javascript
process.nextTick(callback[, ...args]);
callback
:要在下一个事件循环迭代之前执行的函数。[...args]
:可选参数,传递给回调函数的参数。
示例
这里有几个简单的例子,展示了如何使用 process.nextTick()
:
示例 1:基本用法
Javascript
console.log('Start');
process.nextTick(() => {
console.log('This is from process.nextTick');
});
console.log('End');
输出顺序将是:
sql
Start
End
This is from process.nextTick
在这个例子中,process.nextTick
中的回调函数将在当前操作(即打印 'Start' 和 'End')完成后立即执行,但在任何新的事件循环开始之前。
示例 2:与 Promise 对比
Javascript
console.log('Start');
Promise.resolve().then(() => {
console.log('This is from Promise.then');
});
process.nextTick(() => {
console.log('This is from process.nextTick');
});
console.log('End');
输出顺序将是:
vbnet
Start
End
This is from process.nextTick
This is from Promise.then
在这个例子中,即使 Promise.resolve().then(...)
和 process.nextTick(...)
都被用来安排异步操作,process.nextTick
的回调仍然会先于 Promise.then
的回调执行。
注意事项
- 过度使用的风险 :由于
process.nextTick()
的高优先级,如果过度使用可能会导致"饥饿"现象,即其它更低优先级的任务(包括I/O操作)得不到执行的机会。因此,应该谨慎使用。 - 与微任务的区别 :虽然
process.nextTick()
在Node.js环境中提供了类似于微任务的功能,但它并不属于JavaScript标准的一部分,而是特定于Node.js的API。如果你的代码既运行在浏览器也运行在Node.js环境中,那么你可能需要考虑兼容性问题。 - 不适用于浏览器环境 :
process.nextTick()
是Node.js特有的API,在浏览器环境中不可用。对于浏览器环境,可以使用queueMicrotask()
或者基于Promise
的方法来达到类似效果。
结语
通过对宏任务和微任务的详细解析,我们不仅了解了JavaScript事件循环的工作原理,还掌握了如何有效地利用这些机制来优化我们的代码。无论是简化复杂的异步操作流程,还是提高应用的整体性能,理解事件循环机制都是每一位前端开发者不可或缺的基础知识。希望本文能够为您提供有价值的见解,并激发您进一步探索JavaScript异步编程世界的兴趣。记住,掌握这些核心概念不仅能提升您的技术能力,还能为用户提供更加流畅、高效的使用体验。让我们一起继续深入学习,不断进步,共同推动Web技术的发展。