当前位置: 首页 > ops >正文

【前端基础】事件循环 详解

文章目录

    • 一、JavaScript 的单线程特性
    • 二、事件循环的核心组成
    • 三、事件循环的运作流程
    • 四、宏任务 (Macrotask) vs 微任务 (Microtask)
    • 五、代码示例解析
    • 六、浏览器环境中的渲染时机
    • 七、Node.js 环境中的事件循环
    • 八、为什么理解事件循环很重要?

事件循环是 JavaScript 实现异步非阻塞 I/O 的核心机制,也是理解 JavaScript 代码执行顺序,特别是涉及 setTimeout, Promise, async/await 等异步操作时至关重要的概念。
在这里插入图片描述

一、JavaScript 的单线程特性

首先,我们需要明确 JavaScript 是一门单线程语言。这意味着在任意特定时刻,JavaScript 引擎只能执行一个任务。所有任务都需要排队,一个接一个地处理。

优点:

  • 避免了多线程环境下复杂的并发控制问题(如竞态条件、死锁等)。
  • 实现简单。

缺点:

  • 如果一个任务执行时间过长(例如,复杂的计算或长时间的网络请求),后续的所有任务都必须等待,导致程序阻塞,用户界面卡顿甚至无响应。这就是所谓的“阻塞 (blocking)”。

为了解决单线程带来的阻塞问题,同时又能处理耗时的操作(如 I/O 操作、定时器、用户交互等),JavaScript 引入了异步编程模型和事件循环机制。

二、事件循环的核心组成

事件循环模型主要由以下几个关键部分组成,尤其是在浏览器环境中:

  1. 调用栈 (Call Stack / Execution Stack)

    • 一个后进先出 (LIFO) 的数据结构,用于存储所有正在执行的函数调用的上下文。
    • 当一个函数被调用时,它的帧 (frame) 会被推入调用栈。
    • 当函数执行完毕返回时,它的帧会从调用栈中弹出。
    • 所有同步代码都在调用栈中执行。
  2. 堆 (Heap)

    • 一块内存区域,用于存储对象、数组等复杂数据类型。与事件循环的直接关系不大,但与 JavaScript 的内存管理相关。
  3. 宿主环境提供的 API (Web APIs / Node.js APIs)

    • 这些 API 由 JavaScript 的宿主环境(浏览器或 Node.js)提供,它们不是 JavaScript 引擎的一部分。
    • 例如:
      • 浏览器环境DOM API (如事件监听), setTimeout(), setInterval(), XMLHttpRequest, Workspace(), requestAnimationFrame() 等。
      • Node.js 环境fs (文件系统), http (网络), child_process, timers 模块等。
    • 这些 API 允许我们发起异步操作。当调用这些 API 时,操作会在宿主环境的后台线程中处理,而不会阻塞 JavaScript 主线程。
  4. 任务队列 (Task Queue / Callback Queue)
    事件循环中有两种主要的任务队列:

    • 宏任务队列 (Macrotask Queue / Task Queue)
      • 用于存放宏任务 (Macrotask) 的回调函数。
      • 常见的宏任务来源:
        • setTimeout(), setInterval() 的回调
        • setImmediate() (Node.js 环境)
        • I/O 操作的回调 (如文件读写、网络请求完成)
        • UI 交互事件的回调 (如点击、键盘输入、滚动等)
        • 脚本 (<script>) 本身的执行 (可以看作是一个初始的宏任务)
        • UI 渲染 (浏览器环境下,通常在处理完微任务后,在两次宏任务之间或特定时机进行)
    • 微任务队列 (Microtask Queue)
      • 用于存放微任务 (Microtask) 的回调函数。
      • 微任务通常具有更高的优先级,会在当前宏任务执行完毕后、下一个宏任务开始前立即执行。
      • 常见的微任务来源:
        • Promise.then(), Promise.catch(), Promise.finally() 的回调
        • async/awaitawait 关键字之后的代码 (实际上是 Promise 的封装)
        • MutationObserver 的回调
        • queueMicrotask() API
        • process.nextTick() (Node.js 环境,优先级甚至高于其他微任务)

三、事件循环的运作流程

事件循环是一个持续不断的过程,它的基本工作流程如下:

  1. 执行同步代码

    • 首先,JavaScript 引擎会执行全局的同步代码(通常是 <script> 标签中的代码,这可以看作是第一个宏任务)。
    • 所有同步函数调用都会被压入调用栈并依次执行。
  2. 遇到异步 API 调用

    • 当遇到异步 API 调用(如 setTimeout, WorkspacePromise 的创建),JavaScript 引擎会将这些操作交给相应的宿主环境 API 处理。
    • 宿主环境 API 会在后台处理这些异步任务。
    • 当异步任务完成(例如定时器到期、数据获取成功/失败、Promise 状态改变),宿主环境会将相应的回调函数放入对应的任务队列中(宏任务队列或微任务队列)。
  3. 事件循环的监控

    • 事件循环会持续不断地检查调用栈是否为空
  4. 处理微任务

    • 一旦调用栈为空(即当前宏任务中的所有同步代码执行完毕),事件循环会立即检查微任务队列
    • 如果微任务队列不为空,事件循环会按顺序执行队列中所有的微任务,直到微任务队列变空为止。
    • 重要:如果在执行微任务的过程中,又产生了新的微任务,这些新的微任务也会被添加到微任务队列的末尾,并在当前轮次的微任务处理中被执行完毕。微任务队列必须在下一个宏任务开始前被完全清空。
  5. 处理宏任务

    • 当调用栈为空且微任务队列也为空之后,事件循环会检查宏任务队列。
    • 如果宏任务队列不为空,事件循环会取出一个最早进入队列的宏任务,将其回调函数压入调用栈执行。
    • 这个宏任务的执行过程与步骤 1 类似,它可能包含同步代码和新的异步 API 调用。
  6. 重复循环

    • 当这个新的宏任务执行完毕(调用栈再次变空),事件循环会再次执行步骤 4(检查并清空微任务队列),然后执行步骤 5(处理下一个宏任务),如此往复,形成一个持续的循环。

简单概括一轮事件循环 (Tick):
一个宏任务 -> 执行该宏任务中的所有同步代码 -> 清空所有微任务 -> (可能会进行UI渲染) -> 下一个宏任务…

四、宏任务 (Macrotask) vs 微任务 (Microtask)

理解宏任务和微任务的区别是掌握事件循环的关键:

  • 执行时机
    • 微任务在当前宏任务执行结束后、下一个宏任务开始前立即执行。
    • 宏任务则需要等待前面的宏任务以及所有微任务都执行完毕后,才会按队列顺序执行。
  • 优先级:微任务的优先级高于宏任务。
  • 队列特性
    • 通常一次事件循环只会执行一个宏任务(从宏任务队列中取一个)。
    • 但会执行所有当前可用的微任务,直到微任务队列为空。

一个形象的比喻:

  • 宏任务队列:像是银行的排队叫号系统,一次处理一个客户(宏任务)。
  • 微任务队列:像是银行的 VIP 通道或紧急业务窗口。当一个普通客户(宏任务)办理完业务后,银行会立即处理所有 VIP 通道和紧急业务窗口的客户(微任务),直到这些客户都处理完毕,才会叫下一个普通号。

五、代码示例解析

让我们通过一个经典的例子来理解事件循环:

console.log('Script start'); // 1. 同步代码setTimeout(function() { // 注册宏任务 M1console.log('setTimeout callback'); // M1.1
}, 0);Promise.resolve().then(function() { // 注册微任务 m1console.log('Promise.then callback 1'); // m1.1
}).then(function() { // 注册微任务 m2 (由 m1 返回的 Promise 产生)console.log('Promise.then callback 2'); // m2.1
});console.log('Script end'); // 2. 同步代码

执行顺序分析:

  1. 全局同步代码执行 (第一个宏任务的一部分)

    • console.log('Script start'); 输出: Script start
    • 遇到 setTimeout,其回调函数被注册为一个宏任务 (M1),放入宏任务队列。
    • Promise.resolve() 创建一个已解决的 Promise,其第一个 .then() 的回调函数被注册为一个微任务 (m1),放入微任务队列。
    • console.log('Script end'); 输出: Script end
    • 调用栈变空。
  2. 处理微任务队列

    • 事件循环检查微任务队列,发现不为空。
    • 执行微任务 m1: console.log('Promise.then callback 1'); 输出: Promise.then callback 1
    • m1 的 .then() 返回一个新的 Promise,其回调函数(打印 “Promise.then callback 2”)被注册为新的微任务 (m2),放入微任务队列末尾。
    • 微任务队列现在是 [m2]。事件循环继续处理微任务。
    • 执行微任务 m2: console.log('Promise.then callback 2'); 输出: Promise.then callback 2
    • 微任务队列变空。
  3. 处理宏任务队列 (下一轮事件循环)

    • 事件循环检查宏任务队列,发现 M1。
    • 取出 M1,将其回调函数压入调用栈执行。
    • 执行 M1 的回调: console.log('setTimeout callback'); 输出: setTimeout callback
    • 调用栈变空。
    • 再次检查微任务队列(此时为空)。

最终输出顺序:

Script start
Script end
Promise.then callback 1
Promise.then callback 2
setTimeout callback

六、浏览器环境中的渲染时机

在浏览器中,页面的渲染更新通常也作为事件循环的一部分。浏览器一般会尝试在每秒60帧(约16.7ms一次)的频率更新页面。

  • 渲染操作通常被认为是宏任务(或者在宏任务之间进行)。
  • 重要的是,所有微任务会在下一次渲染之前执行完毕。这意味着,如果微任务执行时间过长,或者不断有新的微任务被添加到队列中,可能会阻塞或延迟页面的渲染,导致用户感觉卡顿。
  • requestAnimationFrame() 是一个特殊的 API,它的回调会在浏览器下一次重绘之前执行,非常适合用来执行动画更新。它本身可以被看作是与渲染紧密相关的另一种类型的任务。

七、Node.js 环境中的事件循环

Node.js 的事件循环机制与浏览器类似,但也有其自身的特点和更复杂的阶段划分。Node.js 事件循环的主要阶段包括:

  1. timers (定时器):执行 setTimeout()setInterval() 的回调。
  2. pending callbacks (待定回调):执行延迟到下一个循环迭代的 I/O 回调。
  3. idle, prepare (仅内部使用)
  4. poll (轮询):检索新的 I/O 事件;执行与 I/O 相关的回调(除了关闭回调、定时器回调和 setImmediate())。Node.js 在这里可能会阻塞,等待新的连接、数据等。
  5. check (检查):执行 setImmediate() 的回调。
  6. close callbacks (关闭回调):例如 socket.on('close', ...)

在 Node.js 中,process.nextTick() 的回调并不完全属于上述阶段,它们会在当前操作完成后、事件循环继续到下一个阶段之前立即执行,优先级非常高,甚至高于其他微任务。Promise 的回调也是微任务,在每个阶段完成后、进入下一个阶段前,以及 process.nextTick() 队列清空后执行。

总的来说,Node.js 的事件循环更为复杂,但“宏任务-微任务”的基本处理逻辑(即每个宏任务阶段完成后清空微任务队列)是相似的。

八、为什么理解事件循环很重要?

  • 编写高性能代码:避免长时间运行的同步代码阻塞主线程,合理利用异步操作提升应用响应性。
  • 调试异步问题:理解代码的实际执行顺序,帮助定位和解决异步相关的 bug。
  • 掌握异步模式:更好地使用 Promise, async/await 等现代异步编程工具。
  • 避免常见陷阱:例如,过度依赖 setTimeout(fn, 0) 的执行时机,或者不理解微任务可能导致的渲染延迟。

事件循环是 JavaScript 异步编程的基石,深刻理解它能够让你更自如地驾驭 JavaScript 的异步特性,编写出更健壮、更高效的应用程序。

http://www.xdnf.cn/news/8888.html

相关文章:

  • 小样本机器学习再发力!2025再登Nature正刊
  • 【Prompt】Prompt介绍与示例
  • Spring AI 智能体代理模式(Agent Agentic Patterns)
  • OceanBase数据库从入门到精通(运维监控篇)
  • 【四种JavaScript 实现页面底部回到顶部功能的实现方式】
  • DeepSeek 赋能教育游戏化:AI 重构学习体验的技术密码
  • Qt C++ GUI编程进阶:多窗口交互与事件机制深度解析
  • 《软件工程》第 6 章 - 软件设计概论
  • 数据结构第3章 线性表 (竟成)
  • 职坐标IT培训:硬件嵌入式与AI芯片开发实战
  • ESP8266+STM32 AT驱动程序,心知天气API 记录时间: 2025年5月26日13:24:11
  • 人工智能是桥梁,不是目标
  • C++之STL入门
  • MySQL数据库零基础入门教程:从安装配置到数据查询全掌握【MySQL系列】
  • 易语言模拟真人鼠标轨迹算法
  • AI时代新词-Transformer架构:开启AI新时代的关键技术
  • 大语言模型 19 - MCP FastAPI-MCP 实现自己的MCP服务 快速接入API
  • [ARM][架构] 01.ARMv7 特权等级与核心寄存器
  • MMA: Multi-Modal Adapter for Vision-Language Models论文解读
  • 华为云Flexus+DeepSeek征文 | 体验简单高效的模型推理开通之旅
  • 华为OD机试真题——传递悄悄话(二叉树最长路径问题)(2025A卷:200分)Java/python/JavaScript/C/C++/GO最佳实现
  • 微软技术赋能:解锁开发、交互与数据潜力,共探未来创新路
  • SDL2常用函数:SDL_BlitSurfaceSDL_UpdateWindowSurface 数据结构及使用介绍
  • 深度解析 vm.max_map_count:用途、原理与调优建议
  • 篇章三 数据结构——前置知识(三)
  • 我们是如何为 ES|QL 重建自动补全功能的
  • 常见的css布局单位
  • 深度解析C语言数据类型:从char到double的存储秘密
  • Flutter图片Image、本地图片、程程图片、圆片剪切、圆形图片
  • 小米玄戒O1架构深度解析(一):十核异构设计与缓存层次详解