阅读视图

发现新文章,点击刷新页面。

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」:

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

再看这段代码👇:

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的关联:

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写法,对比差异:

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

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后执行。

搞懂 Promise:告别回调嵌套,再也不怕异步代码乱成麻

前言

作为前端开发者,我们每天都要和“异步”打交道——比如等待接口返回数据、延迟执行一段代码、处理用户交互后的回调。而JavaScript默认是单线程运行的特性,让异步处理成为了入门必学的重点,也让很多新手栽在了“回调地狱”里 。今天我们就结合具体代码,从异步痛点出发,一步步解锁Promise的用法,轻松搞定异步编程~

一、先搞懂:JS为什么需要异步处理?

首先要明确一个核心知识点:JS默认是单线程运行的(V8引擎默认只开启一个主线程执行JS代码)。

为什么设计成单线程?因为JS最初是为浏览器设计的脚本语言,单线程可以节约设备性能——想象一下,如果JS同时执行多个操作,比如一边修改DOM、一边请求数据,很容易导致页面混乱,单线程能避免这种冲突。

但单线程也有个问题:如果遇到耗时操作(比如setTimeout、接口请求),如果一直等待它执行完,后面的代码就会被卡住(也就是“阻塞”)。所以V8引擎会做一件聪明的事:将耗时的异步代码挂起,先执行不耗时的同步代码,等异步操作完成后,再回头执行挂起的代码。

举个简单例子:

let a =1
setTimeout(()=>{
    a =2  // 异步代码,1秒后执行
},1000)
console.log(a);  // 同步代码,优先执行

执行结果:先打印出1,1秒后才会把a改成2。因为setTimeout是异步操作,被V8挂起,先执行同步的console.log。

二、异步的“噩梦”:回调地狱

早期处理异步,我们全靠“回调函数”——就是把一个函数作为参数,传给另一个异步函数,等异步操作完成后,再调用这个回调函数。但如果有多个异步操作需要顺序执行,就会出现“函数嵌套函数”的情况,这就是回调地狱。

典型的回调嵌套:

let a = 1
function foo() {
  setTimeout(() => {
    a = 2
    console.log('foo', a);
    bar()  // 第一个异步完成后,调用第二个异步
  }, 1000)
}
function bar() {
  setTimeout(() => {
    a = 3
    console.log('bar', a);
    baz()  // 第二个异步完成后,调用第三个异步
  }, 2000)
}
function baz() {
  console.log('baz', a);
}
foo()

这段代码的逻辑是:foo执行→1秒后修改a为2→调用bar→2秒后修改a为3→调用baz。看似正常,但如果异步操作再多几层,代码就会像“金字塔”一样越嵌套越深,可读性差、维护困难,排查bug时更是让人头大 。

这就是回调地狱的痛点:嵌套过深、代码混乱、难以维护。而Promise的出现,就是为了解决这个问题!

三、Promise登场:优雅解决异步问题

Promise是ES6引入的异步编程解决方案,它的核心作用是:将异步操作的“结果”和“处理逻辑”分离,用链式调用替代嵌套回调,让异步代码更简洁、更易读。

1. Promise的基础概念

Promise本质是一个对象,它代表了一个异步操作的最终完成(或失败)及其结果值。它有三种状态,且状态一旦改变,就不会再变:

  • 🔵 等待态(Pending):初始状态,异步操作还没完成

  • 🟢 成功态(Fulfilled):异步操作完成,返回成功结果

  • 🔴 失败态(Rejected):异步操作失败,返回错误信息

Promise的构造函数接收一个函数作为参数,这个函数有两个内置参数:resolve(成功时调用)和reject(失败时调用),对应两种状态的切换。

2.Promise的基本用法

Promise构造函数简化版,再补充完整逻辑:

class Promise {
  constructor(fn) {
    // 初始状态:等待态
    this.status = 'pending';
    // 成功的结果
    this.successResult = null;
    // 失败的原因
    this.failReason = null;

    // resolve函数:将状态改为成功态,保存成功结果
    function resolve(result) {
      // 状态一旦改变,就不能再修改
      if (this.status !== 'pending') return;
      this.status = 'fulfilled';
      this.successResult = result;
    }

    // reject函数:将状态改为失败态,保存失败原因
    function reject(reason) {
      if (this.status !== 'pending') return;
      this.status = 'rejected';
      this.failReason = reason;
    }

    // 执行传入的函数,传入resolve和reject(绑定this,避免指向错误)
    fn(resolve.bind(this), reject.bind(this));
  }

  // 补充then方法(简化版)
  then(onFulfilled, onRejected) {
    // 如果状态是成功态,执行onFulfilled,传入成功结果
    if (this.status === 'fulfilled') {
      onFulfilled(this.successResult);
    }
    // 如果状态是失败态,执行onRejected,传入失败原因
    if (this.status === 'rejected') {
      onRejected(this.failReason);
    }
  }
}

// 测试一下
new Promise((resolve, reject) => {
  resolve('成功啦~');
}).then((res) => {
  console.log(res); // 打印:成功啦~
})

创建Promise实例时,需要传入一个函数(通常是异步函数),这个函数接收两个参数:resolve和reject,分别对应“成功时调用”和“失败时调用”。下面是一个封装定时器的Promise:

// qc()函数:3秒后执行,调用reject(注意:你写的“返回成功状态”是笔误哦)
function qc() {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      console.log('闹钟响了');
      reject('又被关了')  // 调用reject,状态变为失败态
    }, 3000)
  })
}

// sy()函数:2秒后执行,调用reject
function sy() {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      console.log('该刷牙了');
      reject('该洗脸了')  // 调用reject,状态变为失败态
    }, 2000)
  })
}

//  work()函数:1秒后执行(同步函数,无Promise)
function work() {
  setTimeout(() => {
    console.log('出门了');
  }, 1000)
}

3. Promise的链式调用:.then() 和 .catch()

Promise对象有两个核心方法:.then() 和 .catch(),用于处理异步结果,实现链式调用,彻底告别嵌套。

  • .then(res = > {}):接收异步操作的成功结果

  • .catch(err = > {}):接收异步操作的错误信息(只有状态为Rejected时执行)

重点:.then() 本身也会返回一个Promise对象,所以我们可以一直链式调用 .then(),让多个异步操作按顺序执行。

看你的链式调用代码(解析执行过程):

qc()
.then(() => {  // 若qc()成功,执行这里
  return sy()  // 返回sy()的Promise,让下一个.then()等待sy()完成
})
.then(() => {  // 若sy()成功,执行这里)
  work()
})
.catch((err) => {  // 捕捉整个链式中所有的失败信息
  console.log('catch', err);
})

执行结果解析:

  1. 执行qc(),立即返回一个Promise对象,初始状态为「等待态」;

  2. 3秒后,qc()内部调用reject('又被关了'),Promise状态变为「失败态」;

  3. 因为状态是失败态,所以后面的两个.then()都不会执行,直接跳转到.catch();

  4. 最终打印:闹钟响了catch 又被关了

小修改:让链式调用正常执行

如果想让.then()正常执行,只需把reject改成resolve(切换为成功态),比如:

function qc() {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      console.log('闹钟响了');
      resolve('起床啦')  // 改为resolve,状态变为成功态
    }, 3000)
  })
}

function sy() {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      console.log('该刷牙了');
      resolve('刷牙完成')  // 改为resolve
    }, 2000)
  })
}

// 重新执行链式调用
qc()
.then((res) => {
  console.log('qc成功:', res); // 打印:qc成功:起床啦
  return sy()
})
.then((res) => {
  console.log('sy成功:', res); // 打印:sy成功:刷牙完成
  work()  // 执行出门函数
})
.catch((err) => {
  console.log('catch', err);
})

此时执行结果:3秒后打印「闹钟响了」→「qc成功:起床啦」→ 等待2秒打印「该刷牙了」→「sy成功:刷牙完成」→ 等待1秒打印「出门了」,完美实现异步顺序执行 。

四、总结:Promise的核心优势

  • ✅ 解决回调地狱:用链式调用替代嵌套,代码更简洁、易维护;

  • ✅ 状态可控:三种状态一旦确定,不会再改变,避免异步结果混乱;

  • ✅ 结果分离:异步操作的“执行”和“结果处理”分离,逻辑更清晰;

  • ✅ 链式调用:支持多个异步操作顺序执行,无需嵌套。

最后提醒一句:Promise虽然解决了回调地狱,但如果有多个并行的异步操作,或者更复杂的异步场景,还可以结合async/await,让异步代码看起来更像同步代码。

希望这篇文章能帮你搞懂Promise,从此告别异步烦恼,轻松拿捏JS异步编程!

❌