首页 JavaScript 异步编程
JavaScript Promise async/await Event Loop

JavaScript异步编程深入理解:Promise与async/await

从回调地狱到Promise链式调用,再到async/await语法糖,结合Event Loop彻底弄懂异步编程核心原理

凡尘
2024年12月 约 20 分钟阅读 11.2k

JavaScript从诞生之初就面临一个核心问题:如何处理耗时操作。无论是网络请求、文件读取、定时器回调,还是用户交互事件,都要求程序在等待结果的同时不阻塞主线程。早期JavaScript主要依靠回调函数实现异步逻辑,但随着业务复杂度提升,纯回调模式很快暴露出控制流混乱、错误处理困难、代码纵向嵌套过深等问题。

为了解决这些问题,JavaScript生态先后发展出Promise、链式调用、生成器Generator,以及最终被广泛采用的async/await语法。理解Promise与async/await,不只是学会几个API,更重要的是建立异步思维:代码不一定按照书写顺序执行,任务会被放入不同队列,事件循环会按照优先级调度它们。

本文将从回调地狱开始,逐步推导Promise的设计思想,再到async/await的语法糖本质,结合Event Loop机制,彻底弄懂JavaScript异步编程的核心原理与最佳实践。

⏳ JavaScript 异步编程演进

CB
回调函数
P
Promise
G
Generator
A/A
async/await

01 · 异步编程的起点:回调函数与回调地狱

在Promise出现之前,处理异步操作最常见的方式就是回调函数。所谓回调函数,就是把一段逻辑作为参数传给异步任务,等任务完成后再执行这段函数。

1.1 回调函数的基本形式

回调函数基本形式
function fetchData(callback) {
  setTimeout(() => {
    const data = { username: "demo", age: 25 }
    callback(data)
  }, 1000)
}

fetchData((result) => {
  console.log("获取到数据:", result)
})

这种模式在简单场景下可以工作,但问题在于:当异步任务之间存在依赖关系时,代码会一层嵌套一层,形成所谓的"回调地狱"。

1.2 回调地狱的真实场景

假设我们需要依次完成以下操作:

  1. 获取用户ID;
  2. 根据用户ID获取订单列表;
  3. 根据订单ID获取商品详情;
  4. 最后渲染页面。
回调地狱
getUserId((userId) => {
  getUserOrder(userId, (orderList) => {
    getProductDetail(orderList[0].productId, (detail) => {
      renderPage(detail)
    })
  })
})

🔴 回调地狱四大问题

可读性差

纵向嵌套过多,阅读代码像在向右缩进

调试困难

错误堆栈不清晰,难以定位哪一步失败

控制流弱

无法方便地返回、中断、重试、并发控制

耦合严重

后续逻辑必须写在回调内部,难以复用

因此,JavaScript需要一种更优雅的方式来表示异步操作的结果。

02 · Promise的设计思想:用对象表示未来结果

Promise的核心思想是:把一个异步操作的"未来结果"用一个对象来表示。这个对象可以处于不同状态,并且可以在结果就绪后触发后续逻辑。

2.1 Promise的三种状态

状态含义
pending进行中,异步任务还未完成
fulfilled已成功,操作完成并返回结果
rejected已失败,操作出错并返回错误原因

🔄 Promise 状态流转(不可逆)

pending
fulfilled
rejected

状态一旦确定,就不会再改变

2.2 基本使用

Promise 基本使用
const promise = new Promise((resolve, reject) => {
  setTimeout(() => {
    const success = true
    if (success) {
      resolve("操作成功")
    } else {
      reject("操作失败")
    }
  }, 1000)
})

promise
  .then((result) => {
    console.log("成功:", result)
  })
  .catch((error) => {
    console.error("失败:", error)
  })

2.3 链式调用

Promise最大的优势之一就是支持链式调用。then()catch()都会返回一个新的Promise,因此可以连续调用。

✅ 链式调用 — 消除回调地狱
fetchUser()
  .then((user) => {
    return fetchOrders(user.id)
  })
  .then((orders) => {
    return fetchProducts(orders)
  })
  .then((products) => {
    console.log("商品列表:", products)
  })
  .catch((error) => {
    console.error("链路中某一步失败:", error)
  })

这样就把横向嵌套的回调地狱,改造成了纵向的链式流程。

2.4 Promise的错误穿透

如果链式调用中某一个Promise被拒绝,错误会一直向下传递,直到遇到第一个catch()

错误穿透
fetchUser()
  .then((user) => {
    if (!user) throw new Error("用户不存在")
    return user
  })
  .then((user) => {
    return fetchOrders(user.id)
  })
  .catch((error) => {
    console.error("错误被捕获:", error.message)
  })

这种机制非常重要:它让异步流程中的错误可以集中处理,而不是每一层回调都单独判断失败。

03 · Promise常用API:从基础到并发控制

Promise.resolve()

快速创建一个已成功的Promise

Promise.reject()

快速创建一个已失败的Promise

Promise.all()

等待全部完成,一个失败则整体失败

Promise.allSettled()

等待全部完成,无论成功或失败

Promise.race()

返回第一个改变状态的结果

Promise.any()

只要一个成功就返回,全失败才失败

3.1 Promise.resolve() / Promise.reject()

快速创建Promise
// 快速创建已成功的Promise
Promise.resolve("ok")
  .then((value) => console.log(value)) // ok

// 快速创建已失败的Promise
Promise.reject("fail")
  .catch((error) => console.error(error)) // fail

3.2 Promise.all()

等待多个Promise全部完成。如果有一个失败,整个Promise就失败。

Promise.all — 全部成功才成功
const p1 = Promise.resolve(1)
const p2 = Promise.resolve(2)
const p3 = Promise.resolve(3)

Promise.all([p1, p2, p3])
  .then((results) => {
    console.log(results) // [1, 2, 3]
  })

适用于:页面初始化需要同时加载多个接口数据,全部返回后再渲染。

3.3 Promise.allSettled()

等待所有Promise完成,无论成功还是失败。

Promise.allSettled — 无论成败
const p1 = Promise.resolve(1)
const p2 = Promise.reject("error")

Promise.allSettled([p1, p2])
  .then((results) => {
    console.log(results)
    // [{status:"fulfilled", value:1}, {status:"rejected", reason:"error"}]
  })

适用于:批量上传、批量导入、多任务执行后统一统计结果。

3.4 Promise.race()

返回第一个率先改变状态的Promise结果。

Promise.race — 最先返回的结果
const p1 = new Promise(resolve => setTimeout(() => resolve(1), 300))
const p2 = new Promise(resolve => setTimeout(() => resolve(2), 100))

Promise.race([p1, p2])
  .then((result) => {
    console.log(result) // 2(p2先完成)
  })

常用于接口超时控制。

3.5 Promise.any()

只要有一个Promise成功,就返回那个成功结果;如果全部失败,才进入失败状态。

Promise.any — 只要一个成功
const p1 = Promise.reject("error1")
const p2 = Promise.resolve("success2")

Promise.any([p1, p2])
  .then((result) => {
    console.log(result) // success2
  })

适用于:多服务器 fallback、多资源优先加载成功项。

📊 并发API对比速查

API全部成功部分失败典型场景
Promise.all返回全部结果整体失败多接口并行加载
Promise.allSettled返回全部结果返回全部状态批量操作统计
Promise.race返回最快结果最快失败则失败超时控制
Promise.any返回最快成功全失败才失败多源fallback

04 · async/await:异步代码的同步写法

虽然Promise已经解决了回调地狱问题,但链式调用仍然不够贴近同步阅读体验。于是ES2017引入了async/await

4.1 async函数

async关键字用于声明一个异步函数。它的返回值会被自动包装成Promise。

async函数自动包装Promise
async function fn() {
  return "hello"
}

fn().then(value => console.log(value)) // hello

// 等价于:
async function fn() {
  return Promise.resolve("hello")
}

4.2 await关键字

await只能在async函数内部使用。它会暂停当前异步函数的执行,等待Promise结果返回。

await暂停执行等待结果
async function fetchData() {
  const result = await new Promise(resolve => {
    setTimeout(() => resolve("数据加载完成"), 1000)
  })

  console.log(result) // 数据加载完成
}

fetchData()

4.3 async/await的优势

📖 阅读更像同步代码

逻辑从上到下书写,更容易理解

📌 变量取值自然

结果可以直接赋值给变量

🛡️ 错误处理统一

可以使用try/catch

🔍 调试更友好

断点可以按步骤执行

05 · async/await错误处理:try/catch与边界情况

5.1 基本错误处理

try/catch基本用法
async function load() {
  try {
    const res = await fetch("/api/data")
    const data = await res.json()
    console.log(data)
  } catch (error) {
    console.error("请求失败:", error)
  }
}

load()

5.2 接口状态错误也要处理

⚠️ fetch只有在网络失败时才会进入rejected。如果接口返回404、500、401,Promise仍然会成功,因此需要额外判断状态码。
✅ 判断HTTP状态码
async function fetchData() {
  try {
    const res = await fetch("/api/data")

    if (!res.ok) {
      throw new Error(`HTTP错误:${res.status}`)
    }

    const data = await res.json()
    return data
  } catch (error) {
    console.error("请求异常:", error)
    throw error
  }
}

5.3 await-to-js模式

在真实项目中,很多人会封装一个工具函数,避免每处都写try/catch

✅ to()模式 — 函数化错误处理
function to(promise) {
  return promise
    .then(data => [null, data])
    .catch(error => [error, null])
}

async function load() {
  const [error, data] = await to(fetchData())

  if (error) {
    console.error("失败:", error)
    return
  }

  console.log("成功:", data)
}

这种模式让错误处理更函数化,尤其适合业务接口层。

06 · 异步控制流:串行、并行、限流

串行执行

逐个等待,后依赖前

并行执行

同时发起,全部等完

并发限流

限制同时执行数量

6.1 串行执行

串行 — 后任务依赖前结果
async function serialTask() {
  const result1 = await task1()
  const result2 = await task2(result1)
  const result3 = await task3(result2)
  return result3
}

6.2 并行执行

并行 — 无依赖同时发起
async function parallelTask() {
  const [result1, result2, result3] = await Promise.all([
    task1(),
    task2(),
    task3()
  ])
  return { result1, result2, result3 }
}

6.3 并发限流

如果一次性发起几百个请求,可能会造成浏览器或服务器压力过大。此时需要并发限流。

并发限流实现
async function limitConcurrency(tasks, limit) {
  const results = []
  const executing = []

  for (const task of tasks) {
    const promise = task()
    results.push(promise)

    if (limit <= tasks.length) {
      const e = promise.then(() => executing.splice(executing.indexOf(e), 1))
      executing.push(e)

      if (executing.length >= limit) {
        await Promise.race(executing)
      }
    }
  }

  return Promise.all(results)
}

这在批量上传、批量导出、数据同步等场景中非常实用。

07 · 事件循环机制:理解异步代码真正怎么运行

Promise和async/await之所以能够不阻塞主线程,关键在于JavaScript的事件循环机制

7.1 同步代码与异步代码

JavaScript是单线程执行的,也就是说同一时间只能做一件事。但它可以把异步任务交给浏览器或Node.js环境处理,等任务完成后再把回调加入队列。

执行顺序经典题
console.log(1)

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

Promise.resolve().then(() => {
  console.log(3)
})

console.log(4)

// 输出顺序:1 → 4 → 3 → 2

🔁 Event Loop 执行流程

同步代码
微任务队列
宏任务队列
console.log(1) ✅ console.log(4) ✅ Promise.then → 3 setTimeout → 2

7.2 微任务与宏任务

类型常见API执行顺序
同步代码普通语句、函数调用最先执行
微任务Promise.then()queueMicrotask()、MutationObserver同步代码结束后优先执行
宏任务setTimeout()setInterval()、I/O回调、UI事件下一轮事件循环执行

7.3 为什么Promise比setTimeout先执行?

因为Promise的回调属于微任务,而setTimeout属于宏任务。事件循环的优先级大致是:

同步代码 → 微任务 → 宏任务

这就是即使setTimeout(..., 0)也会晚于Promise.then()执行的原因。

08 · 常见异步编程面试题与实战陷阱

8.1 为什么await后面的代码会暂停?

await会暂停当前async函数的执行,但不会阻塞整个JavaScript主线程。暂停期间,事件循环仍然可以处理其他任务。

await不阻塞主线程
async function demo() {
  console.log("start")
  await new Promise(resolve => setTimeout(resolve, 1000))
  console.log("end")
}

demo()
console.log("其他代码继续执行")

// 输出:start → 其他代码继续执行 → (1秒后)end

8.2 async/await和Promise的关系是什么?

async/await本质上是Promise的语法糖。它并没有改变JavaScript异步模型,只是让代码更易读。

async/await
async function fn() {
  return await Promise.resolve("hello")
}
等价的Promise
function fn() {
  return Promise.resolve("hello")
}

8.3 await一定要加吗?

不一定。如果不需要等待结果,也不需要返回值,可以不加。但如果后面逻辑依赖异步结果,就必须加await

❌ 缺少await
const result = fetchData()
console.log(result) // Promise对象
✅ 等待结果
const result = await fetchData()
console.log(result) // 实际数据

8.4 如何处理多个await失败但不互相影响?

Promise.allSettled — 互不影响
const results = await Promise.allSettled([
  uploadFile1(),
  uploadFile2(),
  uploadFile3()
])

results.forEach(result => {
  if (result.status === "fulfilled") {
    console.log("成功:", result.value)
  } else {
    console.error("失败:", result.reason)
  }
})

8.5 如何实现请求超时?

✅ Promise.race实现超时控制
function fetchWithTimeout(promise, timeout = 5000) {
  return Promise.race([
    promise,
    new Promise((_, reject) =>
      setTimeout(() => reject("请求超时"), timeout)
    )
  ])
}

async function load() {
  try {
    const data = await fetchWithTimeout(fetchData(), 3000)
    console.log(data)
  } catch (error) {
    console.error(error)
  }
}

09 · 真实项目中的异步编程最佳实践

9.1 接口请求统一封装

不要在每个业务组件里直接写fetchaxios,建议统一封装请求方法。

统一请求封装
async function request(url, options = {}) {
  try {
    const res = await fetch(url, options)
    if (!res.ok) throw new Error(`HTTP错误:${res.status}`)
    return res.json()
  } catch (error) {
    console.error("请求失败:", error)
    throw error
  }
}

9.2 loading状态统一管理

loading + try/catch/finally
async function loadData() {
  loading.value = true
  try {
    data.value = await request("/api/data")
  } catch (error) {
    errorMessage.value = error.message
  } finally {
    loading.value = false
  }
}

9.3 错误信息不要直接暴露原始异常

❌ 暴露技术堆栈
catch (error) {
  alert(error.stack)
}
✅ 友好提示
catch (error) {
  alert("提交失败,请稍后重试")
}

9.4 关键异步操作要支持重试

重试机制
async function retry(fn, times = 3, delay = 1000) {
  for (let i = 0; i < times; i++) {
    try {
      return await fn()
    } catch (error) {
      if (i === times - 1) throw error
      await new Promise(resolve => setTimeout(resolve, delay))
    }
  }
}

9.5 避免无限等待

所有关键异步任务都应该设置超时,避免用户一直停留在加载状态。

超时保护
const data = await fetchWithTimeout(request("/api/data"), 5000)

📋 异步编程五条军规

1

统一请求封装 — 不要在每个组件里直接写fetch/axios

2

合理处理错误 — try/catch + HTTP状态码 + 友好提示

3

控制并发数量 — 串行/并行/限流按场景选择

4

设置超时时间 — Promise.race控制,避免无限等待

5

支持重试机制 — 网络不稳定场景自动重试

10 · 写在最后:异步编程的核心不是语法,而是控制流

JavaScript异步编程从回调函数发展到Promise,再到async/await,本质上都是在解决同一个问题:如何清晰地组织等待、成功、失败、重试、并发、超时这些控制流

🏗️ 三代异步方案解决的问题

回调函数
解决了"能执行"的问题,但难以维护
Promise
解决了"可链式组合"的问题
async/await
解决了"可读性和调试体验"的问题

但无论语法如何变化,底层仍然依赖事件循环、任务队列和Promise状态机制。

真正掌握JavaScript异步编程,需要理解三件事:

  1. 代码不一定按书写顺序执行
  2. Promise代表未来结果,状态一旦确定就不可变
  3. async/await是Promise的语法糖,不是新的异步模型

在真实项目中,写出稳定的异步代码,关键在于:统一请求封装、合理处理错误、控制并发数量、设置超时时间、避免无限等待。只有把这些基础能力做扎实,才能写出健壮、清晰、易维护的JavaScript应用。

异步编程核心三件事

执行顺序
≠ 书写顺序
Promise
状态不可变
async/await
= 语法糖

凡尘

前端工程化实践者,专注 JavaScript 核心原理与异步编程