会用 async/await 写代码不难,难的是处理好错误和控制并发。这篇文章把我在实战中踩过的坑和面试被问到的问题都整理出来了。
前言
async/await 可以说是 JavaScript 异步编程的"终极形态"了,代码写起来跟同步一样爽。但爽归爽,坑也是真的多。
我刚开始用的时候,经常遇到这些问题:
- 错误没捕获,控制台一片红,用户啥反馈没有
- 并发请求太多,服务器直接 429 限流
- 多个请求一起发,一个失败了其他也跟着挂
后来踩坑踩多了,才慢慢总结出这套方法论。今天分享出来,希望能帮你少走点弯路。
一、async/await 的本质(30秒回顾)
在开始讲错误处理之前,先快速过一下核心概念,确保咱们在同一频道:
- async 函数:返回一个 Promise。函数里
return x相当于Promise.resolve(x),throw e相当于Promise.reject(e) - await:暂停函数执行,等 Promise 有结果。如果 Promise reject 了,
await会抛出一个错误(就像throw一样)
简单说:async/await 就是 Promise 的语法糖,但错误处理的方式变了。
二、错误处理:别让错误"裸奔"
2.1 最常用:try/catch 包裹 await
async function fetchData() {
try {
const res = await fetch('/api/user');
const data = await res.json();
return data;
} catch (err) {
console.error('请求或解析失败:', err);
// 可以 throw 出去让上层处理,或者返回默认值兜底
return null;
}
}
这个 catch 能捕获啥?所有 reject 的情况:
- 网络错误(断网、DNS 解析失败等)
- fetch 返回非 2xx 状态码(需要配合
res.ok检查) - JSON 解析失败(返回的不是合法 JSON)
注意:fetch 的 404/500 不会自动 reject!需要手动检查:
async function fetchData() {
try {
const res = await fetch('/api/user');
if (!res.ok) {
throw new Error(`HTTP ${res.status}: ${res.statusText}`);
}
return await res.json();
} catch (err) {
console.error('请求失败:', err);
return null;
}
}
2.2 不包裹 try/catch 会怎样?
async function bad() {
const res = await fetch('/404'); // 如果 fetch reject(比如网络断了)
// 没有 try/catch,错误会变成 bad() 返回的 Promise 的 reject 原因
}
// 调用的时候必须 .catch(),不然就是未处理的 Promise 拒绝
bad().catch(err => console.log('外层捕获', err));
原则:调用 async 函数,要么内部 try/catch,要么外部 .catch(),别让错误裸奔。
2.3 混合 .catch() 与 await(优雅降级)
有时候你想捕获错误,但不想写一大段 try/catch,可以这么干:
const data = await fetch('/user').catch(err => {
console.warn('请求失败,使用默认数据');
return { name: 'guest' }; // 返回默认值
});
// data 要么是正常结果,要么是 fallback
console.log(data.name); // 永远不会报错
这个技巧在需要"尽力而为"的场景特别好用,比如获取用户配置失败了就用默认配置。
2.4 多个 await 时的错误隔离
async function multi() {
try {
const a = await step1(); // 如果 step1 抛出,后面都不执行了
const b = await step2(a);
const c = await step3(b);
return c;
} catch (err) {
// 问题来了:我不知道错误来自哪一步
console.error('出错了,但不知道是哪步:', err);
}
}
这种情况很常见,但 catch 里的错误信息往往不够具体。解决方案:
方案一:给每个步骤加标识
async function multi() {
try {
const a = await step1().catch(e => {
throw new Error(`step1 失败: ${e.message}`);
});
const b = await step2(a).catch(e => {
throw new Error(`step2 失败: ${e.message}`);
});
const c = await step3(b).catch(e => {
throw new Error(`step3 失败: ${e.message}`);
});
return c;
} catch (err) {
console.error(err.message); // 现在知道是哪步了
}
}
方案二:用 Promise.allSettled(后面会讲)
2.5 全局兜底:未处理的 Promise 拒绝
万一真的漏了错误处理,还有最后一道防线:
// Node.js
process.on('unhandledRejection', (reason, promise) => {
console.error('未处理的 Promise 拒绝:', reason);
// 可以在这里记录日志、上报监控
});
// 浏览器
window.addEventListener('unhandledrejection', event => {
console.error('未处理的 Promise 拒绝:', event.reason);
event.preventDefault(); // 防止控制台报错
});
注意:这是最后的防线,不能替代主动错误处理!生产环境如果看到很多未处理的 rejection,说明代码有问题。
三、并发控制:别让请求"洪水泛滥"
3.1 四个基础工具速查
| 方法 | 行为 | 适用场景 |
|---|---|---|
Promise.all | 全部成功才算成功,一个失败整体失败 | 所有任务必须成功,且互相依赖 |
Promise.allSettled | 等全部完成,返回每个结果状态 | 需要所有结果,不因某个失败而中断 |
Promise.race | 返回最快完成的那个(成功或失败) | 超时控制 |
Promise.any | 返回第一个成功的,全失败才 reject | 多个备用源(比如 CDN 回退) |
3.2 Promise.all:并行但"一损俱损"
async function parallelAll() {
try {
const [user, posts, comments] = await Promise.all([
fetch('/user').then(r => r.json()),
fetch('/posts').then(r => r.json()),
fetch('/comments').then(r => r.json())
]);
// 三者都成功才继续
return { user, posts, comments };
} catch (err) {
// 任何一个请求失败都会进这里
console.error('某个请求失败了:', err);
throw err;
}
}
适用场景:页面初始化需要同时获取多个数据,缺一不可。
3.3 Promise.allSettled:"尽力而为"
async function parallelSettled() {
const results = await Promise.allSettled([
fetch('/user').then(r => r.json()),
fetch('/posts').then(r => r.json()),
fetch('/comments').then(r => r.json())
]);
// 分别处理每个结果
const userData = results[0].status === 'fulfilled' ? results[0].value : null;
const postsData = results[1].status === 'fulfilled' ? results[1].value : [];
const commentsData = results[2].status === 'fulfilled' ? results[2].value : [];
// 即使某个请求失败了,其他的还能用
return { userData, postsData, commentsData };
}
适用场景:批量操作,想知道每个的结果,不因一个失败而影响其他。
3.4 自定义并发限制(面试高频!)
问题:有 100 个文件要上传,但服务器限制同时最多 4 个连接,怎么控制?
这是面试经典题,手写实现如下:
async function asyncPool(limit, tasks) {
const results = [];
const executing = [];
for (const task of tasks) {
// 包装成Promise
const p = Promise.resolve().then(() => task());
results.push(p);
// 将当前任务加入执行队列
const e = p.then(() => {
// 任务完成后,从executing中移除
executing.splice(executing.indexOf(e), 1);
});
executing.push(e);
// 当正在执行的任务数到达限制时,等待其中一个完成
if (executing.length >= limit) {
await Promise.race(executing);
}
}
// 等待所有任务完成
return Promise.all(results);
}
// 使用示例(需要用 async IIFE 包裹,或者删除 require 后使用 .mjs 扩展名)
const files = [1, 2, 3, 4, 5]; // 示例数据
const uploadFile = (file) => new Promise(resolve => setTimeout(() => resolve(`file-${file}`), 100));
const uploadTasks = files.map(file => () => uploadFile(file));
// 包装成 async IIFE 避免顶层 await
(async () => {
const results = await asyncPool(4, uploadTasks);
console.log('全部上传完成', results);
})();
核心思路:
- 维护一个
executing数组,记录正在执行的任务 - 当并发数达到限制时,用
Promise.race等待最快完成的任务 - 一个任务完成后,从
executing中移除,让下一个任务进来
生产环境:直接用 p-limit 库,面试时要求手写上述逻辑。
3.5 for...of + await:串行但慢
async function serial(tasks) {
const results = [];
for (const task of tasks) {
results.push(await task()); // 一个完成后再下一个
}
return results;
}
适用场景:任务之间有依赖关系,必须串行执行。或者任务数量很少,不在乎性能。
3.6 并发控制 + 错误处理的结合
上面的 asyncPool 有个问题:如果某个任务失败了,Promise.all 会整体失败。改进版本:
async function asyncPoolWithErrorHandling(limit, tasks) {
const results = [];
const executing = [];
for (const task of tasks) {
const p = Promise.resolve()
.then(() => task())
.catch(err => ({ error: true, message: err.message })); // 捕获单个错误
results.push(p);
if (tasks.length > limit) {
const e = p.then(() => executing.splice(executing.indexOf(e), 1));
executing.push(e);
if (executing.length >= limit) {
await Promise.race(executing);
}
}
}
return Promise.all(results); // 现在不会整体失败了
}
四、实战技巧
4.1 Vue 3 中的 async/await
在 <script setup> 中可以直接使用 await,但需要注意 Suspense:
<script setup>
import { ref } from 'vue'
const data = ref(null)
const error = ref(null)
const loading = ref(true)
try {
const res = await fetch('/api/items')
if (!res.ok) throw new Error(res.statusText)
data.value = await res.json()
} catch (e) {
error.value = e.message
} finally {
loading.value = false
}
</script>
<template>
<div v-if="loading">加载中...</div>
<div v-else-if="error">出错了:{{ error }}</div>
<div v-else>
<!-- 渲染数据 -->
</div>
</template>
如果组件外层没有 <Suspense>,直接使用 await 会导致组件变成异步组件,需要注意加载状态的处理。
4.2 超时控制(race 的经典用法)
function withTimeout(promise, timeout) {
const timeoutPromise = new Promise((_, reject) => {
setTimeout(() => reject(new Error('请求超时')), timeout);
});
return Promise.race([promise, timeoutPromise]);
}
// 使用
const controller = new AbortController();
const fetchPromise = fetch('/api/data', { signal: controller.signal });
try {
const data = await withTimeout(fetchPromise, 5000);
console.log(data);
} catch (err) {
if (err.message === '请求超时') {
controller.abort(); // 取消 fetch 请求
console.log('请求已取消');
}
}
注意:超时后记得用 AbortController 取消 fetch,不然请求会在后台继续跑,浪费资源。
五、面试高频追问
Q1: async/await 与 Promise 的关系是什么?
答:async 函数是 Promise 的语法糖。await 会等待 Promise resolve,如果 Promise reject,await 会抛出错误(相当于 throw)。本质上还是基于 Promise,只是写法更优雅。
Q2: 如何同时处理多个 await 并保证其中一个错误不影响其他?
答:使用 Promise.allSettled,它会等待所有 Promise 完成,不管成功还是失败。或者给每个 await 单独加 try/catch。
Q3: 用 async/await 实现一个带有超时功能的请求
答:用 Promise.race 竞速,配合 AbortController 取消超时的请求:
async function fetchWithTimeout(url, timeout = 5000) {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeout);
try {
const res = await fetch(url, { signal: controller.signal });
clearTimeout(timeoutId);
return res;
} catch (err) {
clearTimeout(timeoutId);
if (err.name === 'AbortError') {
throw new Error('请求超时');
}
throw err;
}
}
Q4: 如何取消一个正在执行的 async 函数?
答:async 函数本身不支持取消,但可以通过控制内部的 Promise 来间接实现。最常用的方式是使用 AbortController:
const controller = new AbortController();
async function fetchData() {
const res = await fetch('/api/data', { signal: controller.signal });
return res.json();
}
// 取消
controller.abort();
对于非 fetch 的场景,可以封装一个可取消的 Promise:
function createCancelablePromise(executor) {
let cancel;
const promise = new Promise((resolve, reject) => {
cancel = () => reject(new Error('Canceled'));
executor(resolve, reject);
});
return { promise, cancel };
}
六、总结速查
错误处理
- 用
try/catch包裹await - fetch 要检查
res.ok - 要么内部处理,要么外部
.catch(),别让错误裸奔 - 多个步骤时给错误加标识,方便定位
并发控制
Promise.all:全部成功才算成功Promise.allSettled:不管成功失败,都要结果Promise.race:超时控制asyncPool:限制并发数(面试常考手写)
写在最后
async/await 让异步代码写起来像同步一样爽,但"爽"的背后需要更多的思考:
- 错误处理要到位,别让用户体验"裸奔"
- 并发控制要合理,别让服务器"崩溃"
- 取消机制要考虑,别让资源"浪费"
把这些都考虑到了,你的代码才能真正称得上"生产级"。
有问题评论区见,觉得有帮助点个赞