单线程下的高效协作:JavaScript 事件循环机制详解

掌握事件循环机制,能够编写高效、可预测的异步代码,是 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 在单线程环境下也能高效地处理各种耗时的异步任务。

setTimeoutfetch、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宏任务执行完毕后再依次执行。
  • 定时器任务setTimeoutsetInterval是典型代表。

    • setTimeout函数设定一个延迟时间,当时间一到,其回调函数就会被放入宏任务队列。
      • 例如setTimeout(() => console.log('延迟执行'), 2000),2 秒后回调函数进入队列等待执行。
    • setInterval则以固定间隔重复将回调函数送入队列。
      • 例如setInterval(() => console.log('定时触发'), 3000),每 3 秒就会将回调函数排入宏任务队列。
  • I/O 操作任务 :网络请求(如使用fetchXMLHttpRequest发起的请求)以及文件读取、写入、数据库等操作都属于此类。

    • fetch为例,当执行fetch('https://example.com/api/data')时,浏览器的网络模块开始处理请求,期间 JavaScript 主线程继续执行后续代码。
    • 待请求完成,无论成功获取数据还是请求失败,相应的回调函数(如.then(response => { /* 处理响应 */ }).catch(error => { /* 处理错误 */ }))都会被添加到宏任务队列。
  • UI 渲染任务:浏览器为保证页面显示正常,会适时将页面渲染设为宏任务。

    • 当页面元素的样式改变(如通过 JavaScript 修改element.style.color ='red')、布局调整(如添加或删除元素导致页面重新布局)时,浏览器需重新计算布局并绘制页面,这一渲染过程就作为宏任务进入队列。
    • 只有当渲染相关的宏任务执行完毕,页面才会呈现最新的样式和布局。

微任务(MicroTask)

  • 微任务是执行优先级高于宏任务的异步任务类型,它主要处理对即时性要求极高的操作。
  • 当同步代码执行完毕,调用栈清空后,JavaScript 引擎会优先处理微任务队列中的任务。

常见的微任务包括以下方面:

  • Promise 相关回调

    • Promise.thenPromise.catchPromise.finally 方法的回调。
    • 当 Promise 状态改变(从pending转为fulfilledrejected),对应thencatch回调就被加入微任务队列。
    • 比如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 的操作,但又不想让这些操作被宏任务打断,就可以使用它来实现。

三、浏览器中的事件循环详解

事件循环的完整流程

  1. 执行调用栈中同步代码 :JavaScript 引擎首先会按照顺序执行调用栈中的同步代码,从全局代码开始,按照代码的书写顺序依次执行同步代码。在这个过程中,函数的执行上下文会依次被压入和弹出调用栈。
  2. 依次执行所有微任务 :一旦调用栈中的同步代码全部执行完毕,调用栈变空,事件循环会按顺序逐个取出微任务队列中的任务,并放入调用栈执行,这一过程持续进行,直至微任务队列变为空。
  3. 执行一个宏任务:在微任务队列被清空后,事件循环会从宏任务队列中取出一个宏任务放入调用栈执行。每个宏任务执行完毕后,事件循环不会立即执行下一个宏任务,而是进入下一步。
  4. 检查是否需要渲染更新 :浏览器会在此时检查是否需要进行页面渲染更新。如果存在 requestAnimationFrame 等与渲染相关的任务,会在这个阶段执行。这保证了页面渲染能够与事件循环协调进行,避免了因频繁渲染或渲染时机不当导致的性能问题。
  5. 重复步骤 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

相关推荐
LaughingZhu几秒前
PH热榜 | 2025-04-09
前端·数据库·人工智能·mysql·开源
枫super13 分钟前
Day-03 前端 Web-Vue & Axios 基础
前端·javascript·vue.js
程序猿chen1 小时前
Vue.js组件安全工程化演进:从防御体系构建到安全性能融合
前端·vue.js·安全·面试·前端框架·跳槽·安全架构
你也来冲浪吗1 小时前
MD编辑器用法讲解
前端
小小小小宇1 小时前
十万字总结所有React hooks(含简单原理)
前端
MariaH1 小时前
MySQL数据库DQL
前端
Enjoy10241 小时前
v8垃圾回收机制
前端
zhangbao90s1 小时前
Tauri 与 Electron 对比:性能、包大小及实际权衡
javascript·node.js
Georgewu1 小时前
【HarmonyOS 5】敏感信息本地存储详解
前端·harmonyos
_Le_1 小时前
css 小师系列:一种新的影响样式优先级的方式😍
前端·css