深入理解JS(八):事件循环,单线程的“一心多用”

前言

对于每一个JavaScript开发者来说,理解事件循环(Event Loop)是迈向高级水平的关键一步。JavaScript是单线程的,这意味着它一次只能做一件事。但我们日常接触的网页却是高度交互的,可以同时处理用户输入、网络请求和动画等多种任务。这背后的功臣,就是我们今天要探讨的------事件循环机制。

一、为什么需要事件循环?

想象一下,如果JavaScript没有异步处理能力,当一个需要耗时5秒的网络请求发出后,整个页面将会被冻结5秒,无法响应任何用户的点击或滚动操作。这无疑会带来极差的用户体验。

为了解决这个问题,JavaScript引入了异步的概念,而事件循环正是实现异步编程的核心机制。它允许主线程在等待耗时操作(如I/O、定时器)完成时,继续执行后续代码,从而避免了阻塞。

二、核心概念

在深入了解事件循环的工作流程之前,我们需要先掌握几个基本概念:

1. 调用栈(Call Stack)

调用栈是一个 后进先出(LIFO) 的数据结构,用于存储和管理函数调用。当一个脚本开始执行时,全局执行上下文被压入栈中。每当一个函数被调用,它的执行上下文就会被创建并压入栈顶;当函数执行完毕返回时,其执行上下文就会从栈顶被弹出。

javascript 复制代码
function third() {
  console.log('Hello from third!');
}

function second() {
  third();
}

function first() {
  second();
}

first();

上述代码的调用栈变化过程是:first进栈 -> second进栈 -> third进栈 -> third出栈 -> second出栈 -> first出栈。

2. Web APIs

Web APIs 是浏览器提供给 JavaScript 引擎的一系列功能接口,它们不属于 JavaScript 核心语言,而是由浏览器环境(或 Node.js 环境)提供。

Web APIS的作用

Web APIs 的核心作用是处理那些无法立即完成的、耗时的任务,从而让 JavaScript 的主线程能够"脱身"去处理其他事情。可以把它们想象成是浏览器的"后台工作人员"。

当你调用一个异步函数时,比如 setTimeout 来设置一个定时器,或者用 fetch 来请求一个网络资源,JavaScript 引擎并不会傻傻地等待。它会做以下两件事:

    1. 将这个任务(例如"5秒后执行某个函数")转交给对应的 Web API。
    1. 然后继续执行调用栈中剩余的同步代码,完全不耽误。

浏览器接手这个任务后,会在自己的独立线程中进行处理 。当任务完成时(比如定时器时间到了,或者网络数据返回了),Web API 并不会直接把结果插回 JavaScript 主线程,而是会将指定的回调函数放入相应的任务队列(宏任务或微任务队列)中,排队等待被事件循环机制取回执行。

常见的 Web APIs 包括:

  • DOM 操作 : document.getElementById, addEventListener 等。
  • 定时器 : setTimeout, setInterval
  • 网络请求 : fetch, XMLHttpRequest (AJAX)。

3. 任务队列(Task Queue)

任务队列是一个先进先出(FIFO)的结构,用于存放待执行的回调函数。当一个异步任务(如setTimeout的定时结束、AJAX请求成功返回)完成时,其对应的回调函数会被放入任务队列中,等待被执行。

任务队列又分为两种:

  • 宏任务队列(Macrotask Queue) : 也叫任务队列(Task Queue) 。用于存放像 setTimeout, setInterval, setImmediate(Node.js), I/O操作, UI渲染等任务的回调。
  • 微任务队列(Microtask Queue) : 用于存放像 Promise.then/catch/finally, async/await, process.nextTick (Node.js), MutationObserver 等任务的回调。微任务的优先级高于宏任务。

三、事件循环的工作流程

事件循环的机制可以用一个简单的循环来描述,它不知疲倦地执行以下步骤:

    1. 执行同步代码:首先,执行调用栈中的所有同步代码,直到调用栈为空。
    1. 检查微任务队列:在调用栈清空后,立即检查微任务队列。
    1. 执行所有微任务:如果微任务队列不为空,则一口气执行完队列中所有的微任务。如果在执行微任务的过程中,又产生了新的微任务,那么这些新的微任务也会被添加到队列的末尾,并在当前轮次中被执行完毕。
    1. 取出一个宏任务:当微任务队列为空后,事件循环会检查宏任务队列。如果队列不为空,则取出一个宏任务,并将其回调函数压入调用栈中执行。
    1. 重复:一个宏任务执行完毕后,调用栈再次清空。事件循环回到第2步,再次检查微任务队列,如此往复循环。
  • 关键点一次事件循环只会执行一个宏任务,但会执行所有微任务。

代码示例

让我们通过一个经典的例子来巩固理解:

javascript 复制代码
console.log('script start'); // 1. 同步代码

setTimeout(function() {
  console.log('setTimeout'); // 5. 宏任务
}, 0);

Promise.resolve().then(function() {
  console.log('promise1'); // 3. 微任务
}).then(function() {
  console.log('promise2'); // 4. 微任务
});

console.log('script end'); // 2. 同步代码
  • 执行分析:

      1. console.log('script start') 是同步代码,立即执行,打印 "script start"。
      1. 遇到 setTimeout,将其回调函数交给 Web API 处理。0毫秒后,Web API 将该回调函数放入宏任务队列。
      1. 遇到 Promise.resolve().then().then() 中的回调是微任务,被放入微任务队列。
      1. console.log('script end') 是同步代码,立即执行,打印 "script end"。
      1. 至此,所有同步代码执行完毕,调用栈为空。
      1. 事件循环检查微任务队列,发现里面有 promise1 的回调。执行它,打印 "promise1"。执行过程中,返回一个新的Promise,其 .then() (即 promise2 的回调) 被放入微任务队列的末尾。
      1. 当前轮次的微任务还没执行完,继续检查微任务队列,发现了 promise2 的回调。执行它,打印 "promise2"。
      1. 现在微任务队列为空了。
      1. 事件循环去宏任务队列取出一个任务,即 setTimeout 的回调。压入调用栈执行,打印 "setTimeout"。
      1. 所有队列都为空,脚本执行结束。
  • 最终输出顺序:

    arduino 复制代码
    script start
    script end
    promise1
    promise2
    setTimeout

结语

事件循环是JavaScript异步编程的基石。理解它,你就能明白为什么 setTimeout(fn, 0) 不会立即执行,也能清晰地预测 PromisesetTimeout 混合代码的执行顺序。掌握了事件循环,你将能更自信地编写出高效、稳定且可预测的JavaScript代码。

希望这篇文章有帮助到你,如果文章有错误,请你在评论区指出,大家一起进步,谢谢🙏。

相关推荐
C4程序员8 分钟前
北京JAVA基础面试30天打卡03
java·开发语言·面试
chxii20 分钟前
2.9 插槽
前端·javascript·vue.js
姑苏洛言1 小时前
扫码点餐小程序产品需求分析与功能梳理
前端·javascript·后端
Freedom风间1 小时前
前端必学-完美组件封装原则
前端·javascript·设计模式
Java技术小馆1 小时前
PromptPilot打造高效AI提示词
java·后端·面试
江城开朗的豌豆1 小时前
React表单控制秘籍:受控组件这样玩就对了!
前端·javascript·react.js
一枚前端小能手1 小时前
📋 代码片段管理大师 - 5个让你的代码复用率翻倍的管理技巧
前端·javascript
国家不保护废物2 小时前
Web Worker 多线程魔法:告别卡顿,轻松实现图片压缩!😎
前端·javascript·面试
BOB_BOB_BOB_2 小时前
【ee类保研面试】其他类---计算机网络
计算机网络·面试·职场和发展·保研
接着奏乐接着舞。2 小时前
如何在Vue中使用拓扑图功能
前端·javascript·vue.js