异步基础:Promise & async/await 实战|JS 进阶必会篇

45 阅读11分钟

同学们好,我是 Eugene(尤金),一个拥有多年中后台开发经验的前端工程师~

(Eugene 发音很简单,/juːˈdʒiːn/,大家怎么顺口怎么叫就好)

你是否也有过:明明学过很多技术,一到关键时候却讲不出来、甚至写不出来?

你是否也曾怀疑自己,是不是太笨了,明明感觉会,却总差一口气?

就算想沉下心从头梳理,可工作那么忙,回家还要陪伴家人。

一天只有24小时,时间永远不够用,常常感到力不从心。

技术行业,本就是逆水行舟,不进则退。

如果你也有同样的困扰,别慌。

从现在开始,跟着我一起心态归零,利用碎片时间,来一次彻彻底底的基础扫盲

这一次,我们一起慢慢来,扎扎实实变强。

不搞花里胡哨的理论堆砌,只分享看得懂、用得上的前端干货,

咱们一起稳步积累,真正摆脱“面向搜索引擎写代码”的尴尬。

写了那么久前端,接口请求写了那么多次——但每次碰到「先查 A 再根据 A 的结果查 B」还是习惯一层层回调?

Promise.allPromise.allSettled 到底该用哪个?错误该在哪里统一处理?loading 开着关不上、重复请求怎么防?

这篇文章不讲事件循环底层,就一个目标:让你看完以后,接口请求能手写、能选对方案、能少踩坑。

一、异步到底在解决什么问题

一句话定义: 异步是为了在「等待耗时操作(如接口请求)」时,不阻塞主线程,让页面继续响应用户操作。

场景处理方式
用户点击按钮发起登录请求等接口返回,再跳转或提示
页面加载时拉取用户信息 + 列表数据多个接口并行请求,等全部完成再渲染
先查省市区,再根据选中的省查市串行请求,后者依赖前者
上传文件时显示进度条监听 xhr 或 fetch 的进度事件

为什么不用同步? 因为 JS 单线程,同步等接口会卡住整个页面。所以需要「发起请求 → 去做别的事 → 请求好了再回来处理」。

二、Promise 基础:搞清楚「状态」和「链式」

2.1 三种状态

pending(进行中) → fulfilled(成功) 或 rejected(失败)
单词国际音标(美式)    国际音标(英式)中文释义
pending/ˈpendɪŋ/     /ˈpendɪŋ/进行中
fulfilled/fʊlˈfɪld/     /fʊlˈfɪld/已成功
rejected/rɪˈdʒektɪd//rɪˈdʒektɪd/已失败

状态一旦变更就不能再改。then 只会执行一次(成功走第一个回调,失败走第二个或 catch)。

2.2 链式调用的本质

fetch('/api/user')
  .then(res => res.json())
  .then(data => console.log(data))
  .catch(err => console.error(err));

fetch说明fetch 是浏览器原生提供的 API(无需引入任何库),基于 Promise 实现(所以支持 async/await 语法),专门用来发送 HTTP 请求(GET/POST/PUT/DELETE 等)、获取远程资源(接口数据、文件、图片等)

.json()说明.json() → 前端最常用的 “数据解析器”

  • 所有返回 JSON 格式的接口(比如登录、查列表、获取用户信息)都要用它;
  • 注意:如果响应内容不是合法 JSON(比如后端返回了纯文本),调用 .json() 会报错,需要用 try-catch 捕获;

fetch 响应解析方法对照表:

解析方法核心作用适用场景
.json()将响应体解析为 JavaScript 对象/数组接口返回 JSON 格式数据(最常用,如接口返回 {name:"张三"}[1,2,3]
.text()将响应体解析为 纯文本字符串返回 HTML 片段、纯文本、XML 等(如请求一个 .txt 文件、后端返回的纯文本提示)
.blob()将响应体解析为 二进制 Blob 对象图片、视频、音频、PDF 等二进制文件(如下载图片、预览上传的文件)
.formData()将响应体解析为 FormData 表单对象后端返回表单格式数据(极少用,更多是前端用 FormData 提交数据)

每一层 then 都返回一个新的 Promise。如果回调里 return 了值,下一个 then 会拿到这个值;如果 return 了 Promise,下一个 then 会等这个 Promise 完成。

常见误区: 以为 catch 只接上面那一层 then 的错误。其实 catch 会捕获前面整条链上未处理的 rejection

rejection说明rejection(名词,发音 /rɪˈdʒekʃn/)是 Promise 状态变为 rejected(已失败)时产生的 “失败信号 / 异常结果”,可以理解为:

Promise 执行失败了,抛出的 “错误信息 / 失败原因” 就是 rejection。

简单类比:

  • Promise 就像一个 “任务包裹”;
  • fulfilled(成功)→ 包裹里是 “正确结果”(对应 resolve);
  • rejected(失败)→ 包裹里是 “错误原因”(对应 reject),这个 “错误原因” 就是 rejection
fetch('/api/user')
  .then(res => res.json())  // 这里抛错
  .then(data => console.log(data))
  .catch(err => console.error(err));  // 也会被这里捕获

三、async/await 的正确打开方式

3.1 本质:语法糖

async 函数一定返回 Promise;await 会暂停函数执行,等到 Promise 完成再继续。

async function getUser() {
  const res = await fetch('/api/user');
  const data = await res.json();
  return data;
}
// 等价于
function getUser() {
  return fetch('/api/user')
    .then(res => res.json());
}

3.2 必须加 try-catch

async function loadData() {
  try {
    const res = await fetch('/api/data');
    const data = await res.json();
    return data;
  } catch (err) {
    console.error('请求失败', err);
    throw err;  // 需要让调用方知道失败了就 throw
  }
}

不写 try-catch 会怎样?

try-catch 是 async/await 里 “接住错误” 的网,没这张网,错误就会飘到全局,变成难调试的 “未处理 rejection”~

3.3 await 必须在 async 函数里

// ❌ 报错
function init() {
  const data = await fetch('/api/data');  // SyntaxError
}

// ✅ 正确
async function init() {
  const data = await fetch('/api/data');
}

四、接口请求中的串行与并行

这是日常最容易搞混也最容易写错的地方。

4.1 串行请求:后者依赖前者

// 需求:先查用户信息,再根据 userId 查订单列表
async function loadUserAndOrders() {
  const userRes = await fetch('/api/user');
  const user = await userRes.json();
  
  const ordersRes = await fetch(`/api/orders?userId=${user.id}`);
  const orders = await ordersRes.json();
  
  return { user, orders };
}

特点:一个一个等,总耗时 = 各接口耗时之和。适合「B 依赖 A 的结果」的场景。

4.2 并行请求:互不依赖,一起发

// 需求:用户信息和配置可以同时拉
async function loadPageData() {
  const [userRes, configRes] = await Promise.all([
    fetch('/api/user'),
    fetch('/api/config')
  ]);
  
  const [user, config] = await Promise.all([
    userRes.json(),
    configRes.json()
  ]);
  
  return { user, config };
}

Promise.all 的原因: 同时发请求,等最慢的那个完成。总耗时 ≈ 最慢接口的耗时,而不是两者相加。

4.3 并行:all 和 allSettled 怎么选?

方法行为适用场景
Promise.all有一个失败就整体 reject多个接口都成功才算成功
Promise.allSettled全部执行完,返回每个的结果/错误部分失败也要拿到成功的数据
// 三个接口,有一个失败就整体失败
const [a, b, c] = await Promise.all([fetchA(), fetchB(), fetchC()]);

// 三个接口,要拿到所有结果(含失败原因)
const results = await Promise.allSettled([fetchA(), fetchB(), fetchC()]);
results.forEach((r, i) => {
  if (r.status === 'fulfilled') console.log(`接口${i}成功`, r.value);
  else console.log(`接口${i}失败`, r.reason);
});

4.4 常见写法对比

// ❌ 串行写法,却当成并行在用——浪费时间
async function loadData() {
  const a = await fetchA();  // 等 A 完
  const b = await fetchB();  // 再等 B
  const c = await fetchC();  // 再等 C
  return { a, b, c };
}

// ✅ 并行
async function loadData() {
  const [a, b, c] = await Promise.all([fetchA(), fetchB(), fetchC()]);
  return { a, b, c };
}

五、错误统一处理

5.1 单个请求的 try-catch

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 (err) {
    console.error('请求失败', err);
    throw err;
  }
}

5.2 封装一层统一的请求函数

async function request(url, options = {}) {
  const res = await fetch(url, {
    ...options,
    headers: { 'Content-Type': 'application/json', ...options.headers }
  });
  
  const data = await res.json();
  
  if (data.code !== 0) {
    throw new Error(data.message || '请求失败');
  }
  
  return data.data;
}

业务层只关心「成功拿到 data」或「抛错」,错误格式统一。

5.3 全局错误处理(如 axios 拦截器思路)

// 以 fetch 封装为例
async function request(url, options = {}) {
  const res = await fetch(url, options);
  const data = await res.json();
  
  if (!res.ok) {
    // 401 统一跳登录
    if (res.status === 401) {
      window.location.href = '/login';
      throw new Error('未登录');
    }
    // 其他错误统一提示
    message.error(data.message || '请求失败');
    throw new Error(data.message);
  }
  
  return data;
}

5.4 并行时的错误处理

// Promise.all:一个失败全部失败
try {
  const [a, b] = await Promise.all([fetchA(), fetchB()]);
} catch (err) {
  // A 或 B 任意一个失败都会进这里
  message.error('加载失败');
}

// Promise.allSettled:分别处理
const results = await Promise.allSettled([fetchA(), fetchB()]);
const errors = results.filter(r => r.status === 'rejected');
if (errors.length) {
  message.error(`${errors.length} 个请求失败`);
}

六、Loading 状态控制

6.1 基本写法:先开再关

async function handleSubmit() {
  setLoading(true);
  try {
    await submitForm();
    message.success('提交成功');
  } catch (err) {
    message.error('提交失败');
  } finally {
    setLoading(false);  // 无论成功失败都要关
  }
}

关键:finally 保证 loading 一定会被关掉,避免「点了没反应」的假象。

6.2 多个请求时的 loading

async function loadData() {
  setLoading(true);
  try {
    const [user, list] = await Promise.all([
      fetchUser(),
      fetchList()
    ]);
    setUser(user);
    setList(list);
  } catch (err) {
    message.error('加载失败');
  } finally {
    setLoading(false);
  }
}

一次开、一次关即可,不用每个请求单独控制。

6.3 防止重复提交:用标志位

<template>
  <button 
    @click="handleSubmit" 
    :disabled="isLoading"
  >
    {{ isLoading ? '提交中...' : '提交' }}
  </button>
</template>

<script setup>
// 1. 引入 ref 用于声明响应式状态(替代 React 的 useState)
import { ref } from 'vue'

// 2. 声明加载状态,初始值为 false(对应 React 的 useState(false))
const isLoading = ref(false)

// 3. 模拟提交请求的异步函数
const submitRequest = async () => {
  // 模拟接口请求延迟 2 秒
  return new Promise((resolve) => {
    setTimeout(() => {
      resolve('提交成功')
    }, 2000)
  })
}

// 4. 点击提交的处理函数(核心防重复提交逻辑)
const handleSubmit = async () => {
  // 如果正在加载,直接返回,阻止重复点击
  if (isLoading.value) return

  try {
    // 开始提交,设置加载状态为 true(禁用按钮)
    isLoading.value = true
    // 调用接口提交数据
    const result = await submitRequest()
    console.log(result)
    alert('提交成功!')
  } catch (error) {
    console.error('提交失败:', error)
    alert('提交失败,请重试!')
  } finally {
    // 无论成功/失败,最终都恢复加载状态为 false(启用按钮)
    isLoading.value = false
  }
}
</script>

6.4 防重复请求:AbortController

  • 英文全称:Abort Controller
  • 音标:/əˈbɔːt kənˈtrəʊlə(r)/
  • 中文谐音:呃-波特 / 肯-吹-勒(大致发音,可参考词典音频)
  • 中文翻译:中止控制器

AbortController 是浏览器提供的一个 Web API,专门用来取消异步操作(比如网络请求、定时器等)。

let controller = null;

async function search(keyword) {
  controller?.abort();  // 取消上一次
  controller = new AbortController();
  
  try {
    const res = await fetch(`/api/search?q=${keyword}`, {
      signal: controller.signal
    });
    return res.json();
  } catch (err) {
    if (err.name === 'AbortError') return;  // 被取消,不处理
    throw err;
  }
}

在这段代码里,它的作用是:

  • 当用户快速连续触发搜索(比如输入框实时联想)时,前一个未完成的请求会被自动取消,只保留最新的那次请求。

  • 这样既避免了无效请求浪费服务器资源,也防止了旧请求的返回结果覆盖最新结果。

核心概念拆解

  1. 创建控制器

    const controller = new AbortController();
    

      每次发起新请求时,先创建一个新的 AbortController 实例。

  2. 传递信号

    fetch('/api/search', { signal: controller.signal });
    

      把控制器的 signal(信号)传给 fetch,让它和这个请求绑定。

  3. 取消请求

    controller.abort();
    

      调用 abort() 方法,就能立即终止绑定了这个 signal 的请求。

  4. 处理取消错误

    请求被取消后,fetch 会抛出一个名为 AbortError 的错误,我们需要在 catch 里捕获并忽略它,避免控制台报错。

七、真实踩坑案例

坑 1:for 循环里 await 写成串行(浪费请求时间)

场景:批量请求接口(如根据多个ID获取数据),本意是让所有请求并行执行,提升效率,却因写法错误导致串行执行,耗时翻倍。

// ❌ 错误写法:本意是并行,实际是串行(一个请求完成才会发起下一个)
const ids = [1, 2, 3];
const results = [];
for (const id of ids) {
  const res = await fetch(`/api/item/${id}`); // 每次await都会阻塞循环
  results.push(await res.json());
}
// ✅ 正确写法:真正并行(同时发起所有请求,等待全部完成)
const ids = [1, 2, 3];
// 用Promise.all包裹所有请求,批量并行执行
const results = await Promise.all(
  ids.map(id => fetch(`/api/item/${id}`).then(r => r.json()))
);

避坑要点:Vue中批量请求时,避免在for循环内直接写await,优先使用Promise.all,减少请求总耗时;若需限制并行数量,可搭配第三方工具(如p-limit)。

坑 2:忘记在 catch 里重新 throw(上层无法感知错误)

场景:异步请求失败后,仅提示错误信息,未重新抛出错误,导致调用该方法的上层代码无法判断请求是否成功,进而出现逻辑异常(如拿到undefined继续执行)。

// ❌ 错误写法:catch中未throw,调用方拿不到失败信息
async function loadData() {
  try {
    return await fetch('/api/data'); // 模拟Vue项目中接口请求
  } catch (err) {
    // 仅提示错误,未抛出,上层会认为请求“成功”
    ElMessage.error('数据加载失败'); // Vue中常用Element Plus提示
  }
}

// 调用方(Vue组件中)
const data = await loadData(); 
// 失败时data是undefined,若后续用data做操作(如data.list),会报“undefined”错误
// ✅ 正确写法:catch中重新throw,让上层感知错误并处理
async function loadData() {
  try {
    return await fetch('/api/data');
  } catch (err) {
    ElMessage.error('数据加载失败');
    throw err; // 关键:重新抛出错误,上层可通过try/catch捕获
  }
}

// 调用方(Vue组件中)
try {
  const data = await loadData();
  // 只有请求成功才执行后续逻辑
  handleData(data);
} catch (err) {
  // 失败时做额外处理(如重置状态)
  resetData();
}

避坑要点:Vue中封装异步请求方法时,若需要上层组件感知错误、做后续处理,catch中必须重新throw错误;若无需上层处理,可在catch中直接处理完所有异常(如重置页面状态)。

坑 3:接口返回的「业务错误」没当错误处理(逻辑异常)

场景:接口HTTP状态码为200(请求成功),但返回的业务状态码异常(如code≠0),未主动抛出错误,导致后续逻辑继续执行,出现异常(如渲染错误数据)。

// ❌ 错误写法:仅判断HTTP成功,未处理业务错误
async function submitForm(formData) {
  const res = await fetch('/api/submit', {
    method: 'POST',
    body: JSON.stringify(formData)
  });
  const data = await res.json();
  // 忽略业务错误(如code=400,参数错误),直接返回数据
  return data.data; // 业务错误时data.data可能不存在,导致后续报错
}
// ✅ 正确写法:主动判断业务状态,异常时throw错误
async function submitForm(formData) {
  const res = await fetch('/api/submit', {
    method: 'POST',
    body: JSON.stringify(formData)
  });
  const data = await res.json();
  // 接口约定:code=0为业务成功,非0为业务错误(如参数错误、权限不足)
  if (data.code !== 0) {
    // 主动抛出错误,触发catch逻辑
    throw new Error(data.message || '提交失败');
  }
  return data.data; // 只有业务成功,才返回有效数据
}

避坑要点:Vue项目中,接口请求需同时判断「HTTP状态」和「业务状态」;大多数后端接口会约定code字段,务必在请求后校验,业务错误时主动throw,避免后续逻辑异常。

八、实战最佳实践总结

维度建议
串行 vs 并行无依赖用 Promise.all 并行;有依赖就老老实实串行 await
错误处理async 函数内必有 try-catch;统一封装可放在 request 层
Loadingfinally 关闭;防重复用标志位或 AbortController
并行选型都要成功用 all;部分失败也要结果用 allSettled
React在 useEffect 里做请求时,用取消标志防止 setState 到已卸载组件

一句话:

异步写多了会自然形成一套习惯,但串行/并行、错误边界、loading 开关这几个点,稍微规范一下就能少踩很多坑。建议把常用的 request 和错误/loading 逻辑封装好,业务代码只关心「调接口、处理结果」。

🔍 本系列专栏导航

一、《闭包实战:从原理到防抖・节流・缓存|JS 进阶必会篇》

二、《异步基础:Promise & async/await 实战|JS 进阶必会篇》

三、《常用工具函数:深拷贝・去重・扁平化手写实战|JS 进阶必会篇》

四、《事件循环与宏微任务:log 顺序实战解析|JS 进阶必会篇》

五、《设计模式实战:单例・发布订阅・策略 JS 轻量用法|JS 进阶必会篇》

六、《浏览器存储实战:localStorage/sessionStorage/cookie 用法详解|JS 进阶必会篇》

👉 跟着系列慢慢学,把技术功底扎扎实实地打牢~


学习本就是一场持久战,不需要急着一口吃成胖子。哪怕今天你只记住了一点点,这都是实打实的进步。

后续我还会继续用这种大白话、讲实战方式,带大家扫盲更多前端基础。

关注我,不迷路,咱们把那些曾经模糊的知识点,一个个彻底搞清楚。

如果你觉得这篇内容对你有帮助,不妨点赞+收藏,下次写代码卡壳时,拿出来翻一翻,比搜引擎更靠谱。

我是 Eugene,你的电子学友,我们下一篇干货见~