首页/文章/javascript

揭秘JS事件循环:带你深入理解async与await、Promise与RAF

2025-07-30
426515 分钟
...

🚀 探索更多React Hooks的可能性?访问 www.reactuse.com 查看完整文档,通过 npm install @reactuses/core 快速安装,让你的React开发效率翻倍!

引言

在前端开发领域,JavaScript的事件循环(Event Loop)机制是理解异步编程、优化性能的关键。它不仅是面试中的高频考点,更是编写高效、无阻塞代码的基石。然而,许多开发者对其理解仍停留在表面,尤其是当async/awaitPromise以及requestAnimationFrame等现代异步API交织在一起时,其执行顺序往往令人困惑。

今天,我们将通过一道经典的字节跳动面试题,深入剖析JavaScript事件循环的奥秘。这道题目巧妙地融合了同步代码、setTimeoutrequestAnimationFrameasync/awaitPromise,旨在考察你对事件循环机制的全面理解。准备好了吗?让我们一起揭开这层神秘的面纱,让JavaScript的异步执行不再是谜!

代码初探

让我们先来看看这道面试题的源代码:

async function async1() {
  console.log('async1 start');
  await async2();
  console.log('async1 end');
}
async function async2() {
  console.log('async2');
}
console.log('script start');
setTimeout(() => {
  console.log('setTimeout');
}, 0);
requestAnimationFrame(() => {
  console.log('requestAnimationFrame');
});
async1();
new Promise(resolve => {
  console.log('promise1');
  resolve();
}).then(() => {
  console.log('promise2');
});
console.log('script end');

在不运行代码的情况下,你是否能准确说出最终的输出顺序呢?这正是本文将要揭示的秘密。

JavaScript事件循环机制深度解析

要彻底理解上述代码的执行顺序,我们首先需要对JavaScript的事件循环机制有一个清晰的认识。JavaScript是一门单线程语言,这意味着它在同一时间只能执行一个任务。那么,它是如何处理耗时的异步操作(如网络请求、定时器、用户交互)而不会阻塞主线程的呢?答案就是事件循环。

事件循环的核心概念包括:

1. 同步任务与异步任务

  • 同步任务 (Synchronous Tasks):在主线程上排队执行的任务,只有前一个任务执行完毕,才能执行后一个任务。它们会直接进入JavaScript引擎的执行栈(Call Stack)并立即执行。
  • 异步任务 (Asynchronous Tasks):不进入主线程,而是进入“任务队列”(Task Queue)的任务。只有当主线程中的同步任务执行完毕,事件循环才会从任务队列中取出异步任务放入执行栈执行。

2. 调用栈 (Call Stack)

调用栈是一个后进先出(LIFO)的数据结构,用于存储函数调用。当一个函数被调用时,它会被推入栈中;当函数执行完毕后,它会从栈中弹出。JavaScript引擎在执行代码时,会不断地检查调用栈是否为空。如果为空,则表示所有同步任务都已执行完毕。

3. Web APIs (Web APIs)

浏览器(或Node.js环境)提供了一系列Web API,用于处理异步操作,例如setTimeoutsetIntervalXMLHttpRequest、DOM事件等。当JavaScript引擎遇到这些异步API时,它会将这些任务交给对应的Web API模块处理,而不会阻塞主线程。当Web API处理完成后,会将对应的回调函数放入任务队列。

4. 任务队列 (Task Queues)

任务队列是存放异步任务回调函数的地方。根据任务类型的不同,任务队列又分为两种:

  • 宏任务队列 (Macrotask Queue):也称为任务队列(Task Queue)或回调队列(Callback Queue)。主要包含:

    • setTimeout
    • setInterval
    • I/O 操作 (如文件读写、网络请求)
    • UI 渲染 (浏览器环境)
    • MessageChannel
    • setImmediate (Node.js)
  • 微任务队列 (Microtask Queue):优先级高于宏任务队列。主要包含:

    • Promise.then().catch().finally()
    • async/await (本质上是Promise的语法糖)
    • MutationObserver
    • process.nextTick (Node.js)

5. 事件循环 (Event Loop) 机制

事件循环是JavaScript实现异步的“调度器”。它的工作流程可以概括为以下几个步骤:

  1. 执行同步代码:JavaScript引擎首先执行调用栈中的所有同步任务,直到调用栈清空。
  2. 执行微任务:当调用栈清空后,事件循环会检查微任务队列。如果微任务队列不为空,它会一次性地执行所有微任务,直到微任务队列清空。在执行微任务的过程中,如果又产生了新的微任务,这些新的微任务也会被添加到当前微任务队列的末尾,并在当前循环中被执行。
  3. 执行宏任务:当微任务队列清空后,事件循环会从宏任务队列中取出一个宏任务(注意:每次循环只取出一个宏任务)放入执行栈执行。
  4. 重复循环:上述步骤2和3会不断重复,形成一个循环,直到所有任务执行完毕。

简而言之,事件循环的优先级是:同步任务 > 微任务 > 宏任务。 每次宏任务执行完毕后,都会清空微任务队列,然后再执行下一个宏任务。这是理解复杂异步代码执行顺序的关键。

深入剖析字节跳动面试题

现在,让我们结合事件循环的知识,一步步分析这道面试题中的代码执行顺序。请记住,浏览器环境下的事件循环会包含渲染阶段,而requestAnimationFrame(RAF)的执行时机与渲染紧密相关。

async function async1() {
  console.log("async1 start");
  await async2();
  console.log("async1 end");
}
async function async2() {
  console.log("async2");
}
console.log("script start");
setTimeout(() => {
  console.log("setTimeout");
}, 0);
requestAnimationFrame(() => {
  console.log("requestAnimationFrame");
});
async1();
new Promise(resolve => {
  console.log("promise1");
  resolve();
}).then(() => {
  console.log("promise2");
});
console.log("script end");

执行步骤详解:

  1. console.log("script start");

    • 这是同步代码,立即执行。
    • 输出: script start
  2. setTimeout(() => { console.log("setTimeout"); }, 0);

    • setTimeout是一个宏任务。它被添加到Web API中,等待0毫秒后(实际上是尽可能快地)将其回调函数放入宏任务队列。
    • 当前状态: 宏任务队列:[setTimeout回调]
  3. requestAnimationFrame(() => { console.log("requestAnimationFrame"); });

    • requestAnimationFrame (RAF) 是一个特殊的异步任务,它不属于宏任务或微任务队列。它的回调会在浏览器下一次重绘之前执行。
    • 当前状态: RAF队列:[requestAnimationFrame回调]
  4. async1();

    • 调用async1函数,进入函数内部。
    • console.log("async1 start");:同步代码,立即执行。
    • 输出: async1 start
    • await async2();
      • 调用async2函数,进入函数内部。
      • console.log("async2");:同步代码,立即执行。
      • 输出: async2
      • async2函数执行完毕并返回一个resolved的Promise。await会暂停async1的执行,并将async1await后面的代码(即console.log("async1 end");)作为微任务添加到微任务队列。
    • 当前状态: 微任务队列:[async1剩余代码]
  5. new Promise(resolve => { console.log("promise1"); resolve(); }).then(() => { console.log("promise2"); });

    • Promise的构造函数是同步执行的。
    • console.log("promise1");:同步代码,立即执行。
    • 输出: promise1
    • resolve()被调用,Promise状态变为resolved。.then()的回调函数(即console.log("promise2");)被添加到微任务队列。
    • 当前状态: 微任务队列:[async1剩余代码, promise2回调]
  6. console.log("script end");

    • 这是同步代码,立即执行。
    • 输出: script end

至此,所有同步代码执行完毕,调用栈清空。事件循环开始检查微任务队列。

  1. 执行微任务队列

    • 事件循环从微任务队列中取出第一个微任务:async1await后面的代码。
    • console.log("async1 end");:执行。
    • 输出: async1 end
    • 事件循环继续从微任务队列中取出下一个微任务:promise2的回调。
    • console.log("promise2");:执行。
    • 输出: promise2
    • 微任务队列清空。
  2. 浏览器渲染阶段 (如果有)

    • 在执行下一个宏任务之前,浏览器可能会进行一次渲染。此时,如果存在RAF回调,它们会在渲染之前被执行。
    • console.log("requestAnimationFrame");:执行。
    • 输出: requestAnimationFrame
    • RAF队列清空。
  3. 执行宏任务队列

    • 事件循环从宏任务队列中取出第一个宏任务:setTimeout的回调。
    • console.log("setTimeout");:执行。
    • 输出: setTimeout
    • 宏任务队列清空。

最终输出顺序:

script start
async1 start
async2
promise1
script end
async1 end
promise2
requestAnimationFrame
setTimeout

这个顺序完美地展示了同步代码优先、微任务在当前宏任务结束后立即执行、以及requestAnimationFrame在渲染前执行的特性。

requestAnimationFrame的独特之处:独立的动画队列

在上述代码分析中,我们注意到requestAnimationFrame(RAF)的回调函数在所有微任务执行完毕后,但在setTimeout这个宏任务之前执行。这并非偶然,而是RAF的独特机制所决定的。

什么是requestAnimationFrame

requestAnimationFrame是浏览器提供的一个API,用于优化动画和视觉更新。它告诉浏览器你希望执行一个动画,并请求浏览器在下一次重绘之前调用你指定的回调函数。其主要优势在于:

  1. 与浏览器刷新率同步:RAF的回调函数会在浏览器准备渲染下一帧时执行,通常是每秒60次(60fps)。这意味着你的动画与浏览器的绘制周期同步,避免了丢帧或卡顿,提供了更流畅的视觉体验。
  2. 节省资源:当页面处于非活动状态(如最小化或切换到后台标签页)时,RAF会自动暂停,从而节省CPU和电池资源。
  3. 避免布局抖动:在RAF回调中进行DOM操作,可以确保在浏览器进行布局和绘制之前完成,从而减少不必要的重排和重绘,提高性能。

RAF的队列:不属于宏任务,不属于微任务

这是一个常见的误解:许多人认为RAF的回调属于宏任务或微任务。然而,根据WHATWG HTML标准,RAF的回调既不属于宏任务,也不属于微任务。它有自己独立的调度机制,通常被称为“动画帧回调队列”或“渲染队列”。

RAF回调的执行时机:

在浏览器的事件循环中,一个完整的循环周期大致如下:

  1. 执行一个宏任务(例如,从宏任务队列中取出一个script任务或setTimeout回调)。
  2. 执行所有微任务(直到微任务队列清空)。
  3. 执行requestAnimationFrame回调:在浏览器准备进行下一次渲染之前,会执行所有已注册的RAF回调。
  4. 浏览器进行渲染(布局、绘制)。
  5. 重复上述过程,进入下一个事件循环周期,从宏任务队列中取出下一个宏任务。

正是因为RAF回调在微任务之后、下一个宏任务之前,且在浏览器渲染之前执行,它才能够确保动画的流畅性和高效性。它为开发者提供了一个在最佳时机进行视觉更新的机会。

RAF与setTimeout的区别:

特性requestAnimationFramesetTimeout(callback, 0)
调度时机浏览器下一次重绘之前至少在指定延迟后,放入宏任务队列,等待主线程空闲
与帧率同步是,与浏览器刷新率同步,通常为60fps否,可能导致丢帧或卡顿
资源消耗页面非活动时暂停,节省资源即使页面非活动,也会尝试执行,可能消耗资源
动画流畅性更流畅,避免布局抖动可能导致动画不流畅,易出现抖动
队列类型独立的动画帧回调队列宏任务队列

理解RAF的独立性及其执行时机,对于编写高性能的Web动画和优化用户体验至关重要。

图解事件循环与RAF队列

为了更直观地理解JavaScript事件循环和requestAnimationFrame的机制,我们提供了以下图示:

JavaScript事件循环概览

 

 

上图展示了JavaScript事件循环的核心组成部分:调用栈、Web APIs、微任务队列、宏任务队列以及事件循环本身。同步代码在调用栈中执行,异步任务则通过Web APIs处理后进入相应的任务队列,最终由事件循环调度执行。

包含requestAnimationFrame的事件循环

 

 

此图进一步细化了事件循环,将requestAnimationFrame的动画帧回调队列纳入其中。它清晰地描绘了在一个事件循环周期中,宏任务、微任务和RAF回调的执行顺序,以及浏览器渲染的时机。RAF回调在微任务之后、浏览器渲染之前执行,确保了动画的流畅性。

总结与思考

通过对这道字节跳动面试题的深入剖析,我们不仅复习了JavaScript事件循环的核心概念,包括同步任务、异步任务、调用栈、Web APIs、宏任务队列和微任务队列,更重要的是,我们理解了async/awaitPromise如何利用微任务机制实现高效的异步流程控制,以及requestAnimationFrame作为独立动画队列的独特优势和执行时机。

理解事件循环是成为一名优秀前端开发者的必经之路。它能帮助你:

  • 预测代码执行顺序:尤其是在复杂的异步场景下。
  • 优化性能:避免阻塞主线程,提升用户体验。
  • 调试异步问题:更快地定位和解决因异步执行顺序导致的bug。

如果您觉得这篇文章有帮助,请点个赞吧~

分享文章

相关文章

更多文章 →
javascript2025-09-18
JavaScript 的多线程能力:Worker
如果你写过一些计算量稍大的 JavaScript 代码,比如图像处理、大量数据排序或者复杂的算法,你几乎肯定遇到过浏览器“卡死”的现象。点击页面没反应,动画也停了,就像整个世界都静止了。 这就是主线程被阻塞的典型后果。因为主线程既要负责执行 JavaScript,又要负责渲染页面、响应用户操作,一旦它被繁重的计算任务占满,就无暇顾及其他,用户体验便直线下降。 这个问题的根源,正是“主线程是单线程的”。那么,如何解决呢? 答案很简单:把这...
学习面试
javascript2025-09-10
手把手教你实现一个EventEmitter
你是不是也遇到过这种情况:代码里各种事件监听和触发,回调函数套了一层又一层,最后自己都理不清哪个事件先触发、哪个后触发了? 别担心,今天我就带你从零开始实现一个自己的事件触发器(EventEmitter),让你彻底掌握事件驱动的精髓,代码清晰度直接提升200%! 为什么要懂EventEmitter? 先说说为什么我们要关心这个。Node.js的核心就是事件驱动,比如文件读写、网络请求,都是通过事件来处理的。不会EventEmitter,...
学习面试
javascript2025-08-09
Web Worker 多线程魔法
你是否曾遇到过网页因为复杂计算而卡顿的情况?今天,让我们一起探索Web Worker的神奇世界,看看如何用多线程技术解决性能瓶颈,并实现流畅的图片压缩功能! 为什么需要Web Worker? 想象一下,你在一个繁忙的餐厅里,只有一位服务员(主线程)负责点餐、上菜、结账所有工作。当客人突然暴增时,这位服务员就会手忙脚乱,其他客人只能干等着… 这就是JavaScript的现状—— 单线程 !所有任务都在一个线程中排队执行,一旦遇到复杂计算(...
学习面试
javascript2025-08-07
🌐AST抽象语法树
AST 是前端开发中一个非常重要的概念,它就像是代码的“编译器”,能够帮助我们理解和操作代码。本文将深入探讨 AST 的原理和应用,带你从零开始了解 AST。 一、什么是 AST? AST(抽象语法树)是一种树形结构,用于表示程序代码的语法结构。它将代码分解为一系列的节点,每个节点代表代码中的一个元素,如变量、函数、表达式等。AST 是编译器和解释器的基础,也是代码分析和转换的关键工具。 1\. AST 的结构 AST 的结构通常包括以...
学习面试
javascript2025-08-05
打字机效果是如何实现的
分析效果 使用 AI 对话的时候,文字是一个字一个字蹦出来的?它不是等所有内容生成完毕后一次性展示,而是以一种流畅、实时的“打字机”效果呈现,这种体验的背后,隐藏着一种高效的Web技术。它就是 服务器推送事件(Server Sent Events,简称 SSE) 什么是 SSE 从根本上说,SSE 是一种允许服务器 单向 、 持续 地向客户端(浏览器)推送数据的Web技术。 可以把它想象成一个 电台广播 : 服务器 是广播电台,持续不断...
学习面试
javascript2025-08-04
Promise 从原理到实现
前言 是 异步编程的核心概念之一,它为我们提供了一种更优雅的方式来处理异步操作。 本文将全面剖析 的工作原理,并逐步实现一个符合 规范的 。 正文 一、Promise 基础概念 代表一个异步操作的最终完成(或失败)及其结果值。它有 种状态: 1. pending : ,既不是成功,也不是失败状态 2. fulfilled :意味着操作 完成 3. rejected :意味着操作 状态一旦改变就不能再变,只能从 变为 ,或从 变为 。 二...
学习面试

评论

请登录后发表评论

去登录
加载评论中...

目录