JS 异步:Event-Loop+async/await

前言

在前端开发中,是不是经常被JS的异步代码绕晕?明明写的代码顺序一样,运行结果却大相径庭?其实核心原因就在于------JS是单线程语言,而异步操作全靠「Event-Loop(事件循环)」来调度。今天就结合具体代码案例,从进程线程、V8引擎到Event-Loop、async/await,一步步拆解,让你搞懂JS异步的底层逻辑~

一、先搞基础:进程 vs 线程

在聊JS异步之前,我们得先分清两个容易混淆的概念:进程和线程,这是理解后续内容的基石~

  • 进程 :简单来说,进程就是CPU运行指令、加载和保存上下文所需的"容器",是程序运行的独立单位。比如你打开浏览器,每多开一个Tab页,就相当于多开启了一个进程,每个进程之间相互独立,互不干扰。

  • 线程 :线程是进程内的执行单元,是CPU实际执行指令的"最小单位"。一个进程可以包含多个线程,这些线程共享进程的资源,协同完成任务。

举个浏览器的例子:每个浏览器Tab进程中,会包含多个核心线程,其中和我们JS相关的有3个:

  1. 渲染线程:负责渲染页面(HTML、CSS渲染);

  2. JS引擎线程:负责执行JS代码;

  3. HTTP请求线程:负责发送网络请求。

这里有个关键知识点⚠️:因为JS代码可以修改DOM(比如document.write、appendChild),如果JS引擎线程和渲染线程同时运行,会导致页面渲染混乱,所以JS引擎线程和渲染线程是互斥的------也就是说,JS代码执行时,渲染线程会暂停,等JS执行完,渲染线程才会继续工作。这也是为什么有时候JS代码写得太复杂,页面会出现"卡顿"的原因~

二、V8引擎:JS单线程的"幕后推手"

我们写的JS代码,最终是由V8引擎来执行的。而V8引擎在执行JS代码时,默认只开启一个JS引擎线程------这就意味着,JS代码只能"自上而下、依次执行",同一时间只能做一件事。

那问题来了:如果JS遇到耗时操作(比如setTimeout、网络请求、读取文件),难道要一直等着操作完成,再继续执行后续代码吗?这样会导致页面卡死,用户体验直接拉胯!

为了解决这个问题,JS引入了「异步机制」:单线程处理代码时,遇到同步任务 ,就立即执行;遇到异步任务,不等待、不阻塞,而是把它暂时存放到"任务队列"中,等JS引擎线程空闲时,再去执行任务队列中的异步任务。

三、核心重点:Event-Loop 事件循环

Event-Loop(事件循环)就是JS处理异步任务的"调度器",它的执行流程决定了所有同步、异步代码的运行顺序。我们先明确两个核心概念:微任务宏任务------所有异步任务,都会被分到这两个队列中。

3.1 微任务 vs 宏任务

微任务和宏任务的区别,在于它们的"优先级":微任务优先级高于宏任务,会先于宏任务执行。

  • 微任务(优先级高)

    • Promise.then()、Promise.catch()、Promise.finally()

    • process.nextTick()(Node.js环境,浏览器不支持)

    • MutationObserver(监听DOM变化的API)

  • 宏任务(优先级低)

    • 整个script脚本(最外层的同步代码,属于宏任务的开端)

    • setTimeout()、setInterval()

    • AJAX请求、I/O操作(比如读取文件)

    • UI渲染(页面渲染操作)

    易错点提醒⚠️:很多人会误以为"setTimeout(fn, 0)"会立即执行,其实不然------setTimeout的延迟时间是"最小延迟",不是"精确延迟",即使设为0,也会被放入宏任务队列,等待同步代码、微任务全部执行完毕后,才会执行。

3.2 Event-Loop 执行顺序

记住这个顺序,就能搞定80%的异步代码输出题,结合后面的代码案例理解更透彻👇:

  1. 先执行同步代码(最外层script脚本,属于宏任务),执行过程中遇到异步任务,就分别存入微任务队列、宏任务队列;

  2. 同步代码执行完毕后,清空微任务队列(所有微任务依次执行,执行过程中产生的新微任务,也会在本次微任务队列中执行完毕);

  3. 微任务全部执行结束后,若有需要(如DOM发生变化),浏览器会进行页面渲染

  4. 渲染完成后,从宏任务队列中取出第一个宏任务执行(执行该宏任务的过程中,遇到同步、异步任务,重复步骤1-2);

  5. 重复步骤1-4,形成"循环",这就是Event-Loop。

3.3 代码实操:搞懂Event-Loop执行顺序

结合你给出的第一段代码,我们一步步拆解执行过程,看看为什么输出结果是「1 2 7 3 5 4 6」:

javascript 复制代码
console.log(1); // 同步代码:输出1
new Promise((resolve) => {
  console.log(2); // Promise构造函数内是同步代码:输出2
  resolve()
})
.then(() => {
  console.log(3); // 微任务:存入微任务队列
  setTimeout(() => {
    console.log(4); // 宏任务:存入宏任务队列(延迟0ms)
  }, 0)
})
setTimeout(() => {
  console.log(5); // 宏任务:存入宏任务队列(延迟0ms)
  setTimeout(() => {
    console.log(6); // 宏任务:存入宏任务队列(延迟0ms)
  }, 0)
}, 0)
console.log(7); // 同步代码:输出7

执行步骤拆解👇:

  1. console.log(1):同步,输出「1」;

  2. new Promise:构造函数内是同步代码,console.log(2),输出「2」;调用resolve(),将then回调存入微任务队列(记为微1);

  3. 遇到setTimeout(延迟0ms):宏任务,存入宏任务队列(记为宏1);

  4. console.log(7):同步,输出「7」;

  5. 同步代码执行完毕,开始清空微任务队列:执行微1(then回调),console.log(3),输出「3」;遇到setTimeout(延迟0ms),宏任务,存入宏任务队列(记为宏2);

  6. 微任务队列清空,渲染页面(本次无明显渲染);

  7. 执行宏任务队列第一个宏任务(宏2):console.log(5),输出「5」;遇到setTimeout(延迟0ms),宏任务,存入宏任务队列(记为宏3);

  8. 宏2执行完毕,再次检查微任务队列(无新微任务),执行下一个宏任务(宏2):console.log(4),输出「4」;

  9. 宏3执行完毕,检查微任务队列(无),执行下一个宏任务(宏3):console.log(6),输出「6」。

所以最终输出顺序就是:1 2 7 3 5 4 6 ✅

3.4 再练一题:巩固Event-Loop

再看这段代码👇:

javascript 复制代码
console.log(1);

setTimeout(() => {
  console.log(2);
  setTimeout(() => {
    console.log(3)
  }, 1000)
}, 0)

setTimeout(() => {
  console.log(4)
}, 2000)
console.log(5);

执行步骤拆解👇:

  1. 执行同步代码:console.log(1) → 输出 1

  2. 遇到第一个 setTimeout(..., 0):延迟 0ms 后,把回调(输出 2 + 嵌套定时器)推入宏任务队列

  3. 遇到第二个 setTimeout(..., 2000):延迟 2000ms 后,把回调(输出 4)推入宏任务队列

  4. 执行同步代码:console.log(5) → 输出 5

  5. 同步代码执行完毕,开始处理宏任务队列

    • 取出第一个宏任务:执行 → 输出 2
    • 执行中遇到嵌套的 setTimeout(..., 1000):延迟 1000ms 后,把回调(输出 3)推入宏任务队列;
  6. 此时宏任务队列里,只有「延迟 2000ms 的输出 4」在等待

  7. 时间流逝:

    • 1000ms 到:输出3 被推入宏任务队列 → 立刻执行 → 输出 3
    • 再等 1000ms(总计 2000ms):输出4 被推入宏任务队列 → 执行 → 输出 4

最终输出顺序:1 → 5 → 2 → 3 → 4

宏任务队列是先进先出,为什么 3 比 4 先输出?

关键点:两个定时器不是同时入队

  • 输出 4 的定时器:一开始就设定了 2000ms 延迟,2000ms 后才入队;
  • 输出 3 的定时器:等第一个宏任务执行完(瞬间完成),才设定 1000ms 延迟 ,1000ms 后就入队执行。1000ms < 2000ms,所以 3 必然比 4 先执行,和宏任务队列顺序无关。

JavaScript 中setTimeout的延迟时间是回调函数加入宏任务队列的等待时间 ,而非执行时间;宏任务队列遵循先进先出,但不同定时器的回调不是同时入队,谁的延迟时间先耗尽,谁就先入队先执行。

四、进阶:async/await 异步语法糖

async/await 是ES7引入的异步语法,本质是Promise的"语法糖",让异步代码写起来更像同步代码,可读性大大提升。

4.1 async/await 核心规则

  • async关键字 :函数前面加async,等同于函数内部自动返回一个Promise实例对象。比如: async function fn() { return 1; // 等同于 return Promise.resolve(1); } fn().then(res => console.log(res)); // 输出1

  • await关键字:必须跟async配合使用,不能单独使用;如果await后面接的不是Promise对象,await就无法"约束"它,会直接执行后续代码;如果await后面接Promise对象,会"暂停"当前async函数的执行,等待Promise状态变为resolved(成功)或rejected(失败),再继续执行后续代码。

  • 关键原理 :await fn() 之所以能"当成同步看待",核心是------await会把它后续的代码(当前async函数内,await后面的所有代码),挤到微任务队列中,等await后面的Promise执行完成后,再执行这个微任务。

4.2 代码实操:async/await 执行顺序

先看这段基础代码,理解async/await和Promise的关联:

javascript 复制代码
function a() {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      console.log('a'); // 宏任务
      resolve()
    }, 1000)
  })
}
function b() {
  console.log('b'); // 同步
}

// 案例1:Promise.then写法
a().then(() => {
  b()
})
console.log('hello'); // 同步

输出顺序:hello → a → b(同步代码先执行,a()是Promise,then回调是微任务,等待a()的宏任务执行完,再执行微任务b())

再看async/await写法,对比差异:

javascript 复制代码
function a() {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      console.log('a'); // 宏任务
      resolve()
    }, 1000)
  })
}
function b() {
  console.log('b'); // 同步
}

async function foo() {
  setTimeout(() => {
    console.log('c'); // 宏任务(延迟1500ms)
  }, 1500)

  await a()  // 等待a()的Promise resolve,后续代码进入微任务
  b()
  console.log('hello');
}

foo()

执行步骤拆解👇:

  1. 调用foo(),执行async函数内部代码;

  2. 遇到setTimeout(延迟1500ms):宏任务,存入宏任务队列(记为宏A);

  3. 遇到await a():a()返回Promise,里面有setTimeout(延迟1000ms,宏任务,记为宏B);此时foo()暂停执行,等待宏B执行完毕、Promise resolve;

  4. 同步代码执行完毕(此时foo()暂停,无其他同步代码),检查微任务队列(无),执行宏任务队列;

  5. 先执行宏B(延迟1000ms):console.log('a'),输出「a」;调用resolve(),此时await等待结束,将foo()后续的代码(b()、console.log('hello'))存入微任务队列;

  6. 宏B执行完毕,检查微任务队列,执行微任务:b()输出「b」,console.log('hello')输出「hello」;

  7. 微任务执行完毕,执行下一个宏任务(宏A,延迟1500ms):console.log('c'),输出「c」。

最终输出顺序:a → b → hello → c ✅

4.3 综合案例:async/await + Promise + setTimeout

javascript 复制代码
console.log('script start'); // 同步
async function async1() {
  await async2() // 等待async2()执行,后续代码进入微任务
  console.log('async1 end'); // 微任务
}
async function async2() {
  console.log('async2 end'); // 同步(async函数内,await前的代码是同步)
}
async1()
setTimeout(() => {
  console.log('setTimeout'); // 宏任务
}, 0)
new Promise((resolve, reject) => {
  console.log('promise'); // 同步
  resolve()
})
  .then(() => {
    console.log('then1'); // 微任务
  })
  .then(() => {
    console.log('then2'); // 微任务(then1执行完后存入)
  }); 
console.log('script end'); // 同步

输出顺序:script start → async2 end → promise → script end → async1 end → then1 → then2 → setTimeout

💡 关键提醒:async函数内,await前面的代码是同步执行的;await后面的代码会被放入微任务队列,和Promise.then的微任务优先级相同,按顺序执行。

五、总结:异步核心知识点梳理

  1. JS是单线程,由V8引擎的JS引擎线程执行,与渲染线程互斥;

  2. 异步任务分为微任务(优先级高)和宏任务(优先级低);

  3. Event-Loop执行顺序:同步代码 → 微任务队列 → 页面渲染 → 宏任务队列(循环);

  4. async/await是Promise语法糖,await会将后续代码放入微任务队列,等待Promise resolve后执行。

相关推荐
程序员库里1 小时前
AI协同写作应用-TipTap基础功能
前端·javascript·面试
程序员阿峰1 小时前
【JavaScript面试题-算法与数据结构】手写一个 LRU(最近最少使用)缓存类,支持 `get` 和 `put` 操作,要求时间复杂度 O(1)
前端·javascript·面试
im_AMBER2 小时前
AJAX vs Fetch API:Promise 与异步 JavaScript 怎么用?
前端·javascript·面试
用户9751470751362 小时前
关于通过react使用hooks进行数据状态处理
前端
GISer_Jing2 小时前
React:从SPA到全场景渲染的进化之路
前端·react.js·前端框架
Highcharts.js2 小时前
Highcharts React v4 迁移指南(上):核心变更解析与升级收益
前端·javascript·react.js·react·数据可视化·highcharts·v4迁移
SuniaWang2 小时前
《Spring AI + 大模型全栈实战》学习手册系列 · 专题八:《RAG 系统安全与权限管理:企业级数据保护方案》
java·前端·人工智能·spring boot·后端·spring·架构
菌菌的快乐生活2 小时前
在 WPS 中设置 “第一章”“第二章” 这类一级编号标题自动跳转至新页面
前端·javascript·wps
hh随便起个名2 小时前
useRef和useState对比
前端·javascript·react