掌握事件循环机制,能够编写高效、可预测的异步代码,是 JavaScript 开发者进阶的必经之路。深入理解事件循环都有助于开发者优化代码性能,提升用户体验,解决异步编程中遇到的各种复杂问题。
一、单线程与异步的必要性
单线程与多线程
线程是程序运行的基本单元。
单线程:在这种环境下,程序仅有一个执行路径。JavaScript 作为广泛应用于浏览器端与 Node.js 环境的编程语言,常采用单线程模式处理各类代码逻辑。
- 这意味着同一时刻,它仅能处理一项任务,所有任务只能按照先后顺序依次执行。
多线程:多线程允许程序同时拥有多个执行路径。在一些复杂的程序中,不同线程能够并行处理不同任务,以此大幅提升程序的整体处理效率。
- 例如在服务器应用里,一个线程可以专门负责监听客户端请求,而另一个线程进行数据处理,两者协同工作实现高效响应。
2. 同步与异步
在代码执行过程中,同步与异步有着截然不同的表现。
同步执行:在这种模式下,JavaScript 代码严格按照编写顺序逐行依次运行。
- 只有前一个语句执行完毕,后续语句才会开始执行,各操作之间存在着严格的先后顺序。
- 这种执行模式非常适用于逻辑紧密、对执行顺序要求极高的场景,像变量初始化、简单的算术运算等操作就常常采用同步执行。
异步执行:当 JavaScript 遇到诸如发起网络请求、设置定时器等异步任务时,并不会暂停执行去等待任务完成,而是直接继续执行后续代码。当异步任务结束后,会通过特定的机制,比如回调函数、Promise 对象等,通知程序进行相应的后续处理,从而打破了传统的线性执行流程。
3. JavaScript 单线程的意义与局限
优势:在操作 DOM 等共享资源时,单线程有效规避了多线程可能引发的资源竞争冲突。在多线程环境下,多个线程同时读写 DOM 可能导致数据不一致、页面显示异常等问题,而单线程确保了数据一致性和程序稳定性。
困境:一旦某个任务执行时间过长,例如进行大规模数据计算,或者执行长时间的 I/O 操作,整个程序就会陷入阻塞状态。在浏览器环境中,这将直接导致页面卡顿,用户进行点击按钮、滚动页面等交互操作时,无法及时得到响应,严重影响用户体验。
- 为解决这一问题,异步编程成为 JavaScript 发展的关键方向。
- 事件循环机制作为异步编程的核心,在单线程环境下,巧妙地调度同步和异步任务的执行。它能够让 JavaScript 在等待异步任务完成的同时,继续处理其他任务,从而保证程序持续流畅运行,使 JavaScript 能够适应复杂应用场景的需求。
二、事件循环的关键概念
调用栈(Call Stack)
- 调用栈,也称执行上下文栈,是一种用于管理函数调用的数据结构,如同一个存储函数执行上下文的 "栈" 容器 。它遵循后进先出(LIFO)的原则,这意味着最后进入栈的元素会最先被取出。
- 调用栈主要负责管理同步代码的执行顺序,是保障 JavaScript 代码按序、正确运行的重要基础。
当 JavaScript 引擎开始执行代码时,同步代码会依照编写的先后顺序,逐个进入调用栈。我们通过一个具体例子来深入理解:
javascript
function a() {
b();
}
function b() {
console.log("Executed");
}
a();
- 首先,当执行
a()
时,a
函数的执行上下文,包括函数的参数、局部变量以及执行环境等相关信息,会被压入调用栈的栈顶。此时,调用栈中仅有a
函数的执行上下文。 - 接着,由于
a
函数内部调用了b
函数,b
函数的执行上下文便紧跟着被压入栈顶。此时,调用栈从栈顶到栈底依次为b
函数执行上下文和a
函数执行上下文 。 - 随后,
b
函数开始执行,它输出 "Executed"。当b
函数执行完毕,其执行上下文已完成使命,便从调用栈栈顶弹出。此时,调用栈中仅剩下a
函数的执行上下文。 - 最后,
a
函数也执行完毕,a
的执行上下文同样从栈顶弹出。至此,调用栈为空,执行过程结束。
栈内变化
初始: []
a(): [a]
a→b: [b, a]
b结束: [a]
a结束: []
调用栈确保每个函数在执行时,其局部作用域中的变量和执行逻辑都能得到正确管理,进而使得同步代码能够有条不紊地顺序执行。
Web API 与异步任务
Web API 即网页应用程序接口(Web Application Programming Interface),是由浏览器提供的一系列功能接口,通过与浏览器底层多线程机制的协同,使得 JavaScript 在单线程环境下也能高效地处理各种耗时的异步任务。
像setTimeout
、fetch
、DOM 事件等接口,都是 Web API 的重要组成部分。以setTimeout
为例,当代码执行到setTimeout
函数时:
javascript
setTimeout(() => {
console.log('Timeout callback executed');
}, 1000);
console.log('After setTimeout');
JavaScript 引擎本身并不直接处理setTimeout
任务,而是将其转交给浏览器的 Web API 中的定时器模块。
- 该模块借助浏览器底层的多线程机制运作,在独立于 JavaScript 主线程的环境下进行计时操作。
- 此时,
console.log('After setTimeout')
作为同步代码,继续在调用栈中按顺序执行。待设定的 1000 毫秒时间结束,setTimeout
的回调函数便会被放入任务队列,等待后续被调用栈取用并执行。
网络请求的实现同样依赖 Web API。以fetch
为例,当代码发起fetch
请求时,JavaScript 引擎将请求任务交付给浏览器的网络模块。
- 该模块在后台多线程环境下,独立完成网络请求的发送、等待服务器响应以及数据接收等操作。
- 当请求成功获取到数据或因各种原因失败时,网络模块会将相应的回调函数放入任务队列。
- 通过这种方式,网络请求这类异步操作与 JavaScript 主线程实现了分离,避免了在等待网络响应过程中阻塞主线程,确保页面交互等其他任务能持续流畅进行。
任务队列(Task Queues)
- 任务队列,又称消息队列,它是异步任务回调函数的等待区域。
- 当调用栈中的同步代码全部执行完毕,即调用栈为空时,事件循环开始工作,检查任务队列有无待执行回调函数。若有,就依特定规则,将回调函数依次推入调用栈执行,保证了异步任务的回调函数能在合适的时机得以执行。
- 任务队列中的任务又进一步细分为宏任务和微任务。
宏任务(MacroTask)
- 宏任务涵盖一系列相对复杂、耗时,或者涉及与外部交互的任务。这些任务的回调函数统一被存放在宏任务队列中,按顺序等待执行。
常见的宏任务涵盖多个方面:
-
script
标签中的代码:这是浏览器环境中最开始执行的宏任务。- 当浏览器加载一个 HTML 页面时,会依次执行页面中的
script
标签内的代码。 - 这些代码的执行是一个宏任务,并且是页面加载过程中的第一个宏任务。
- 在执行
script
代码时,如果遇到了其他宏任务(如setTimeout
等),会将它们添加到宏任务队列中,等待当前script
宏任务执行完毕后再依次执行。
- 当浏览器加载一个 HTML 页面时,会依次执行页面中的
-
定时器任务 :
setTimeout
和setInterval
是典型代表。setTimeout
函数设定一个延迟时间,当时间一到,其回调函数就会被放入宏任务队列。- 例如
setTimeout(() => console.log('延迟执行'), 2000)
,2 秒后回调函数进入队列等待执行。
- 例如
setInterval
则以固定间隔重复将回调函数送入队列。- 例如
setInterval(() => console.log('定时触发'), 3000)
,每 3 秒就会将回调函数排入宏任务队列。
- 例如
-
I/O 操作任务 :网络请求(如使用
fetch
、XMLHttpRequest
发起的请求)以及文件读取、写入、数据库等操作都属于此类。- 以
fetch
为例,当执行fetch('https://example.com/api/data')
时,浏览器的网络模块开始处理请求,期间 JavaScript 主线程继续执行后续代码。 - 待请求完成,无论成功获取数据还是请求失败,相应的回调函数(如
.then(response => { /* 处理响应 */ })
或.catch(error => { /* 处理错误 */ })
)都会被添加到宏任务队列。
- 以
-
UI 渲染任务:浏览器为保证页面显示正常,会适时将页面渲染设为宏任务。
- 当页面元素的样式改变(如通过 JavaScript 修改
element.style.color ='red'
)、布局调整(如添加或删除元素导致页面重新布局)时,浏览器需重新计算布局并绘制页面,这一渲染过程就作为宏任务进入队列。 - 只有当渲染相关的宏任务执行完毕,页面才会呈现最新的样式和布局。
- 当页面元素的样式改变(如通过 JavaScript 修改
微任务(MicroTask)
- 微任务是执行优先级高于宏任务的异步任务类型,它主要处理对即时性要求极高的操作。
- 当同步代码执行完毕,调用栈清空后,JavaScript 引擎会优先处理微任务队列中的任务。
常见的微任务包括以下方面:
-
Promise 相关回调
Promise.then
、Promise.catch
、Promise.finally
方法的回调。- 当 Promise 状态改变(从
pending
转为fulfilled
或rejected
),对应then
或catch
回调就被加入微任务队列。 - 比如
Promise.resolve(42).then(value => console.log(value))
,then
回调会在调用栈清空后,优先于宏任务执行。Promise.finally
不管 Promise 最终成功或失败,其回调都会入队,用于做清理等统一处理。
-
MutationObserver 回调
MutationObserver
是浏览器提供的用于监听 DOM 变化的重要工具。它创建一个异步的观察者,当 DOM 树发生特定变化如节点增减时,就会触发预先设置的回调函数。- 一旦监测到指定变化,回调就排入微任务队列。适用于需对 DOM 变化即时响应,且不想被宏任务延迟的场景。
-
queueMicrotask 添加的任务
queueMicrotask
是 JavaScript 中一个用于将任务添加到微任务队列的函数。- 调用
queueMicrotask(() => console.log('This is a microtask'))
,传入的回调就会入队,等调用栈为空时执行,方便开发者手动控制微任务执行时机。 - 在一些场景下,开发者希望在某个同步操作完成后,立即执行一些清理工作或者更新 UI 的操作,但又不想让这些操作被宏任务打断,就可以使用它来实现。
三、浏览器中的事件循环详解
事件循环的完整流程
- 执行调用栈中同步代码 :JavaScript 引擎首先会按照顺序执行调用栈中的同步代码,从全局代码开始,按照代码的书写顺序依次执行同步代码。在这个过程中,函数的执行上下文会依次被压入和弹出调用栈。
- 依次执行所有微任务 :一旦调用栈中的同步代码全部执行完毕,调用栈变空,事件循环会按顺序逐个取出微任务队列中的任务,并放入调用栈执行,这一过程持续进行,直至微任务队列变为空。
- 执行一个宏任务:在微任务队列被清空后,事件循环会从宏任务队列中取出一个宏任务放入调用栈执行。每个宏任务执行完毕后,事件循环不会立即执行下一个宏任务,而是进入下一步。
- 检查是否需要渲染更新 :浏览器会在此时检查是否需要进行页面渲染更新。如果存在
requestAnimationFrame
等与渲染相关的任务,会在这个阶段执行。这保证了页面渲染能够与事件循环协调进行,避免了因频繁渲染或渲染时机不当导致的性能问题。 - 重复步骤 1 - 4:完成上述步骤后,事件循环会不断重复这个过程,持续处理同步代码、微任务、宏任务以及渲染更新,从而实现了程序的持续运行和交互响应。
js
// 第一次循环:同步代码执行阶段
console.log('同步任务 1');
// 将回调函数添加到宏任务队列
setTimeout(() => {
console.log('宏任务 1');
Promise.resolve().then(() => {
console.log('宏任务 1 产生的微任务');
});
setTimeout(() => {
console.log('宏任务 1 产生的宏任务');
}, 0);
}, 0);
// 将回调函数添加到微任务队列
Promise.resolve().then(() => {
console.log('微任务 1');
Promise.resolve().then(() => {
console.log('微任务 1 产生的微任务');
});
});
console.log('同步任务 2');

微任务优先级的体现
微任务优先级高于宏任务的特性在实际代码中有着明显的体现。例如:
javascript
setTimeout(() => console.log("MacroTask"), 0);
Promise.resolve().then(() => console.log("MicroTask"));
尽管 setTimeout
设置的延迟时间为 0,但由于 Promise.resolve().then
产生的微任务优先级更高,所以输出顺序为 MicroTask → MacroTask
。