单线程下的高效协作: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

相关推荐
foxhuli22933 分钟前
禁止ifrmare标签上的文件,实现自动下载功能,并且隐藏工具栏
前端
青皮桔1 小时前
CSS实现百分比水柱图
前端·css
失落的多巴胺1 小时前
使用deepseek制作“喝什么奶茶”随机抽签小网页
javascript·css·css3·html5
DataGear1 小时前
如何在DataGear 5.4.1 中快速制作SQL服务端分页的数据表格看板
javascript·数据库·sql·信息可视化·数据分析·echarts·数据可视化
影子信息1 小时前
vue 前端动态导入文件 import.meta.glob
前端·javascript·vue.js
青阳流月1 小时前
1.vue权衡的艺术
前端·vue.js·开源
样子20181 小时前
Vue3 之dialog弹框简单制作
前端·javascript·vue.js·前端框架·ecmascript
kevin_水滴石穿1 小时前
Vue 中报错 TypeError: crypto$2.getRandomValues is not a function
前端·javascript·vue.js
翻滚吧键盘1 小时前
vue文本插值
javascript·vue.js·ecmascript
孤水寒月2 小时前
给自己网站增加一个免费的AI助手,纯HTML
前端·人工智能·html