前端老鸟血泪史:async-await顺序坑位全踩遍(附填坑秘籍)
如果线上环境没法调试,只能靠日志。// 调试神器:给async函数加上时间追踪${// 调试神器:给async函数加上时间追踪 function traceAsync(fn , name) {console . log(` [ ${ name } ] 开始执行 ` , args);try {console . log(` [ ${ name } ] 成功完成,耗时 ${(end - start)
前端老鸟血泪史:async-await顺序坑位全踩遍(附填坑秘籍)
前端老鸟血泪史:async-await顺序坑位全踩遍(附填坑秘籍)
开篇先唠两句
说实话,写这篇文章的时候,我手都是抖的。
不是因为激动,是因为想起那些年被async-await支配的恐惧。凌晨三点,我对着屏幕上的undefined怀疑人生,咖啡喝到第三杯,心跳快得像刚跑完八百米。你懂那种感觉吗?代码看着明明没问题,逻辑顺得像德芙巧克力,但跑起来就是各种翻车。
最离谱的一次,我盯着这段代码看了二十分钟:
async function getData() {
const user = await fetchUser();
const orders = await fetchOrders(user.id);
return orders;
}
心里想着:"这不就是先拿用户再拿订单吗?多清晰啊。"结果上线之后,用户反馈说有时候能看到别人的订单。我当场就懵了,查了半天才发现是并发请求的顺序问题——两个用户几乎同时点进来,Promise在那边乱成一锅粥。
所以这篇文章,不是什么官方文档的翻译,也不是教科书式的讲解。这就是一本踩坑实录,把我这几年流的泪、熬的夜、掉的头发,都浓缩成你能看懂的人话。你要是看完能少熬两个通宵,记得请我喝奶茶,真的。
async-await到底是个啥玩意儿
Promise的老大哥,让异步代码看起来像同步
咱们先回到那个"回调地狱"的年代。你还记得吗?就是那种一个接口套一个接口,代码往右边无限延伸,最后变成一个>形状的噩梦:
// 感受一下这坨代码,我当年居然能写出来
getUserId(function(userId) {
getUserInfo(userId, function(userInfo) {
getOrders(userInfo.id, function(orders) {
getOrderDetails(orders[0].id, function(details) {
console.log(details); // 终于拿到了,我哭了
});
});
});
});
这种代码,维护起来就是灾难。你改一行,整个链条都可能崩。后来Promise出来了,链式调用确实清爽了不少:
getUserId()
.then(userId => getUserInfo(userId))
.then(userInfo => getOrders(userInfo.id))
.then(orders => getOrderDetails(orders[0].id))
.then(details => console.log(details))
.catch(err => console.error('某个地方挂了', err));
但说实话,then来then去的,还是不够直观。特别是当你有复杂的条件判断和中间变量时,Promise链也会变得很臃肿。
然后async-await就出现了。这玩意儿说白了就是个语法糖,但糖吃多了确实开心啊:
async function getData() {
try {
const userId = await getUserId();
const userInfo = await getUserInfo(userId);
const orders = await getOrders(userInfo.id);
const details = await getOrderDetails(orders[0].id);
console.log(details);
} catch (err) {
console.error('某个地方挂了', err);
}
}
看着是不是顺眼多了?像写同步代码一样写异步,这就是async-await最大的魅力。
async函数永远返回Promise,这个得刻在脑子里
这里有个坑,我踩过不止一次。你以为async函数返回的是个普通值?太天真了。
// 你以为返回的是字符串?
async function sayHello() {
return 'Hello World';
}
const result = sayHello();
console.log(result); // Promise {<fulfilled>: 'Hello World'}
console.log(typeof result); // object,不是string!
// 想拿到值?要么await,要么then
sayHello().then(msg => console.log(msg)); // Hello World
// 或者在另一个async函数里
async function main() {
const msg = await sayHello();
console.log(msg); // Hello World
}
记住这个规律:async函数不管return什么,外面收到的都是Promise。就算你不写return,它也会给你包一个Promise.resolve(undefined)。这个特性很重要,后面我们会看到它怎么影响代码的执行顺序。
await只能用在async里面,不然直接报错教你做人
这个限制曾经让我抓狂。有时候我就想在一个普通函数里用一下await,结果浏览器直接给我个红彤彤的报错:
function normalFunction() {
const data = await fetchData(); // SyntaxError: await is only valid in async functions
console.log(data);
}
那怎么办呢?最粗暴的办法就是把外层也变成async:
async function asyncFunction() {
const data = await fetchData();
console.log(data);
}
但有时候你就是不想改函数签名,比如事件回调或者数组方法里。这时候可以用IIFE(立即执行函数表达式):
// 在普通函数里用await的黑科技
function normalFunction() {
(async () => {
const data = await fetchData();
console.log(data);
})();
}
// 或者在数组方法里
const urls = ['/api/1', '/api/2', '/api/3'];
urls.forEach(async (url) => {
const data = await fetch(url);
console.log(data);
});
不过要注意,上面的forEach例子其实是有问题的——forEach不会等待异步操作完成。这个我们后面会详细讲,先埋个伏笔。
内部顺序稳如老狗,调用顺序飘忽不定
同一个async函数里,await按代码顺序执行,这个没跑
先说清楚,async-await在单个函数内部的执行顺序,确实是符合直觉的。代码从上往下,一个await等完了才执行下一个:
async function sequentialExecution() {
console.log('开始执行');
console.time('第一个await');
const result1 = await new Promise(resolve => {
setTimeout(() => resolve('第一个结果'), 1000);
});
console.log('拿到:', result1);
console.timeEnd('第一个await'); // 大约1000ms
console.time('第二个await');
const result2 = await new Promise(resolve => {
setTimeout(() => resolve('第二个结果'), 1000);
});
console.log('拿到:', result2);
console.timeEnd('第二个await'); // 大约1000ms
console.log('全部完成');
}
sequentialExecution();
// 输出顺序:
// 开始执行
// 拿到: 第一个结果 (1000ms)
// 拿到: 第二个结果 (2000ms)
// 全部完成
看到没?第二个await确实等了第一个完成之后才开始。总耗时2000ms左右,符合预期。这个特性让我们可以很方便地处理有依赖关系的异步操作,比如先登录再拿用户信息。
多个async函数同时调用,谁先完成看天意
但是!注意了,这里是第一个大坑。当你同时调用多个async函数的时候,事情就变得有意思了:
async function taskA() {
console.log('Task A 开始');
await new Promise(resolve => setTimeout(resolve, 300));
console.log('Task A 完成');
return 'A的结果';
}
async function taskB() {
console.log('Task B 开始');
await new Promise(resolve => setTimeout(resolve, 100));
console.log('Task B 完成');
return 'B的结果';
}
async function taskC() {
console.log('Task C 开始');
await new Promise(resolve => setTimeout(resolve, 200));
console.log('Task C 完成');
return 'C的结果';
}
// 同时调用三个函数
console.log('=== 启动所有任务 ===');
taskA();
taskB();
taskC();
console.log('=== 启动完成 ===');
// 输出顺序可能是:
// === 启动所有任务 ===
// Task A 开始
// Task B 开始
// Task C 开始
// === 启动完成 ===
// Task B 完成 (100ms)
// Task C 完成 (200ms)
// Task A 完成 (300ms)
看到没?虽然A先调用,但是B先完成。因为它们都是异步的,一旦遇到await就把控制权交出去了,然后各自在自己的timeout里倒计时,谁先归零谁先执行回调。
这就好比你在餐厅点了三道菜,A是红烧排骨(慢炖30分钟),B是凉拌黄瓜(2分钟搞定),C是清蒸鱼(15分钟)。你按A、B、C的顺序点的,但上桌顺序肯定是B、C、A。async-await也是这个道理。
画个图你就明白了,Promise并发执行那点事
为了让你更直观地理解,我给你画个时间线图(假装有图,咱们用文字凑合看):
时间轴(ms): 0 100 200 300
| | | |
Task A: [开始-----------完成]
| | | |
Task B: [开始--完成]
| | | |
Task C: [开始--------完成]
| | | |
主线程: 启动A,B,C → 立即继续执行后面代码
看到关键点了吗?在0ms的时候,三个任务都"开始"了,然后主线程立刻就继续往下走,不会等着谁。等到100ms的时候,B的定时器到了,B完成;200ms的时候C完成;300ms的时候A完成。
这就是为什么你不能假设先调用的async函数会先完成。如果你代码里有这种假设,那bug就离你不远了。
代码示例走一波,让你亲眼看到顺序乱成什么样
来,咱们写个更贴近实际的例子。假设你要加载一个页面的三个模块:用户信息、推荐商品、广告位。这三个接口互相独立,但你在componentDidMount或者onMounted里这么写:
// Vue3组合式风格的伪代码
import { ref, onMounted } from 'vue';
const userInfo = ref(null);
const recommendations = ref([]);
const ads = ref([]);
onMounted(async () => {
// 看起来是按顺序调用,但实际上是并发执行的!
fetchUser(); // 没加await!
fetchProducts(); // 没加await!
fetchAds(); // 没加await!
});
async function fetchUser() {
console.time('用户信息');
userInfo.value = await api.getUser();
console.timeEnd('用户信息');
}
async function fetchProducts() {
console.time('推荐商品');
recommendations.value = await api.getProducts();
console.timeEnd('推荐商品');
}
async function fetchAds() {
console.time('广告位');
ads.value = await api.getAds();
console.timeEnd('广告位');
}
看出问题了吗?fetchUser()、fetchProducts()、fetchAds()前面都没有await!这意味着这三个函数会立即返回Promise,然后同时开始执行。哪个接口快,哪个数据就先回来。
如果api.getProducts()只需要50ms,而api.getUser()需要500ms,那你页面上会先显示推荐商品,然后过450ms才显示用户信息。这种体验其实很诡异,特别是如果推荐商品里需要显示用户名字,那就会看到"undefined的专属推荐"这种尴尬场面。
那如果我加上await呢?
onMounted(async () => {
// 这样写顺序是保证了,但性能爆炸
await fetchUser(); // 等500ms
await fetchProducts(); // 再等50ms
await fetchAds(); // 再等30ms
// 总共580ms,用户等得花儿都谢了
});
顺序是保证了的,但变成了串行执行,总时间是三个接口之和。这在移动端或者弱网环境下,用户体验直接崩盘。
所以问题来了:既要保证数据的一致性(比如推荐商品需要根据用户信息来筛选),又要尽可能并行节省时间,怎么办?
答案是:看情况。如果确实没依赖,就用Promise.all并行;如果有依赖,就把依赖的放前面,独立的后面并行。这个我们后面会详细讲。
为啥会这样,底层逻辑扒一扒
事件循环机制在那摆着,宏任务微任务排队等号
要真正理解async-await的行为,你得先搞懂JavaScript的事件循环(Event Loop)。我知道这听起来很枯燥,但相信我,搞懂这个你能少踩80%的异步坑。
JavaScript是单线程的,这意味着它一次只能干一件事。但为什么我们能同时处理多个网络请求呢?因为浏览器(或者Node.js)提供了Web API,这些API是独立于JS引擎的。当你发起一个异步请求时,实际工作是由浏览器完成的,JS引擎只是发了个指令,然后继续干别的。
事件循环的工作流程大概是这样的:
- 执行栈(Call Stack):同步代码在这里执行,后进先出。
- Web APIs:setTimeout、DOM事件、网络请求等在这里处理。
- 任务队列(Task Queue):也叫宏任务队列,setTimeout回调、I/O操作等放这里。
- 微任务队列(Microtask Queue):Promise回调、MutationObserver等放这里,优先级比宏任务高。
当执行栈空了之后,事件循环会先检查微任务队列,把所有微任务都执行完,然后再拿一个宏任务来执行。执行完这个宏任务,又回去检查微任务队列…如此循环。
await本质是Promise.then的语法糖,别被表面骗了
这是很多人误解的地方。你以为await是在"等待"?从语法上看起来是这样,但底层它其实是把后面的代码包装成了一个回调。
看这个例子:
async function example() {
console.log('A');
await Promise.resolve();
console.log('B');
}
console.log('1');
example();
console.log('2');
// 输出顺序:1 → A → 2 → B
为什么B在2后面?因为await Promise.resolve()虽然立刻就resolve了,但它仍然会把后面的代码(console.log(‘B’))放进微任务队列。而console.log(‘2’)是同步代码,在主线程上直接执行,所以比B先打印。
用Promise改写一下你就明白了:
function example() {
console.log('A');
Promise.resolve().then(() => {
console.log('B'); // 这相当于await后面的代码
});
}
console.log('1');
example();
console.log('2');
// 输出还是:1 → A → 2 → B
所以await的本质就是:把async函数在await处切成两半,await后面的代码放到微任务队列里等着。如果await的是一个实际的Promise(比如网络请求),那就要等那个Promise完成后再放进微任务队列。
异步操作扔进任务队列,回来时间谁也说不准
现在你应该明白了,当你调用一个async函数时,遇到await就相当于说:“我先去排队等着,后面的代码晚点再执行”。至于要等多久,取决于那个Promise什么时候resolve。
async function unpredictable() {
const start = Date.now();
// 这个请求可能100ms回来,也可能10秒,也可能永远不回来
const data = await fetch('/api/data');
const end = Date.now();
console.log(`等了${end - start}ms`);
return data;
}
更复杂的是,如果你同时启动了多个async函数,它们各自独立地在事件循环里排队。谁先完成取决于:
- 网络延迟(最不可控)
- 服务器处理时间
- 浏览器同时发起的连接数限制(HTTP/1.1通常一个域名6个)
这就解释了为什么我们在前面看到的例子中,Task B(100ms)会比Task A(300ms)先完成。它们同时开始倒计时,时间短的先触发回调,跟调用顺序无关。
这玩意儿好用在哪,又有哪些坑
代码可读性直接拉满,回调地狱从此说拜拜
说实话,async-await最大的优点就是好看。不是那种肤浅的好看,是让人一眼就能看懂逻辑的好看。
比如一个典型的用户登录流程:
// 回调地狱版本
function login(username, password, callback) {
validateInput(username, password, (err) => {
if (err) return callback(err);
checkUserExists(username, (err, user) => {
if (err) return callback(err);
verifyPassword(password, user.passwordHash, (err, isValid) => {
if (err) return callback(err);
if (!isValid) return callback(new Error('密码错误'));
createSession(user.id, (err, session) => {
if (err) return callback(err);
logLoginAttempt(user.id, 'success', (err) => {
if (err) console.error('记录日志失败', err);
callback(null, { user, session });
});
});
});
});
});
}
这代码我看着就头疼,错误处理还要每个回调都写一遍。换成async-await:
async function login(username, password) {
try {
await validateInput(username, password);
const user = await checkUserExists(username);
const isValid = await verifyPassword(password, user.passwordHash);
if (!isValid) {
throw new Error('密码错误');
}
const session = await createSession(user.id);
// 日志记录失败不影响登录结果
logLoginAttempt(user.id, 'success').catch(err => {
console.error('记录日志失败', err);
});
return { user, session };
} catch (err) {
// 统一错误处理,可以在这里记录失败日志
await logLoginAttempt(user.id, 'failed');
throw err;
}
}
清晰多了对吧?逻辑一目了然,错误处理也集中在一起。这就是async-await的杀手锏。
错误处理用try-catch,比回调函数香多了
在Promise链里处理错误,你得在末尾加个catch,但如果中间某个then有自己的catch,错误处理逻辑就会变得很分散:
fetchUser()
.then(user => {
return fetchOrders(user.id)
.catch(err => {
// 这里捕获了fetchOrders的错误,但fetchUser的错误会继续往上抛
console.error('获取订单失败', err);
return []; // 返回空数组让流程继续
});
})
.then(orders => {
console.log(orders);
})
.catch(err => {
// 这里只能捕获fetchUser的错误
console.error('获取用户失败', err);
});
这种分散的错误处理很容易漏掉某些异常情况。async-await的try-catch就直观多了:
async function getUserData() {
try {
const user = await fetchUser();
try {
const orders = await fetchOrders(user.id);
return { user, orders };
} catch (orderErr) {
// 只处理订单获取失败,用户还是正常的
console.error('获取订单失败,但用户存在', orderErr);
return { user, orders: [] };
}
} catch (userErr) {
// 用户获取失败是更严重的问题
console.error('获取用户失败', userErr);
throw userErr; // 继续往上抛,让上层处理
}
}
你可以很灵活地控制错误处理的范围,内层try-catch处理非致命错误,外层处理致命错误。这种结构在复杂业务流程中特别有用。
但是!并行变串行,性能可能直接掉档
好了,优点夸完了,现在该说说坑了。最大的坑就是:不小心把并行操作写成了串行。
看这段代码,你觉得有问题吗?
async function loadDashboard() {
const start = Date.now();
// 获取各种数据
const userStats = await fetchUserStats(); // 假设300ms
const systemStats = await fetchSystemStats(); // 假设300ms
const notification = await fetchNotifications(); // 假设300ms
const end = Date.now();
console.log(`总耗时: ${end - start}ms`); // 大约900ms!
return { userStats, systemStats, notification };
}
问题大了!这三个接口明显是独立的,没有任何依赖关系。但因为你用了await,它们变成了串行执行:先等userStats,再等systemStats,最后等notification。总时间接近900ms。
如果改成并行:
async function loadDashboard() {
const start = Date.now();
// 同时启动所有请求
const userStatsPromise = fetchUserStats();
const systemStatsPromise = fetchSystemStats();
const notificationPromise = fetchNotifications();
// 等待所有完成
const [userStats, systemStats, notification] = await Promise.all([
userStatsPromise,
systemStatsPromise,
notificationPromise
]);
const end = Date.now();
console.log(`总耗时: ${end - start}ms`); // 大约300ms!
return { userStats, systemStats, notification };
}
时间直接降到300ms左右,用户体验天差地别。在移动端,900ms可能让用户觉得卡,300ms则感觉流畅。
多个独立请求非要等,用户等你等到花儿都谢了
更隐蔽的坑出现在循环里。比如你要批量处理一堆数据:
// 千万别这么写!性能灾难
async function processItems(items) {
const results = [];
for (const item of items) {
// 每次循环都await,变成了串行处理
const result = await processItem(item);
results.push(result);
}
return results;
}
// 如果items有10个,每个处理100ms,总共要1000ms
如果你以为这是在并行处理,那就大错特错了。for循环里的await会阻塞下一次迭代,10个item就要等10次。如果items有100个,用户可以直接关闭页面了。
正确的做法是:
async function processItems(items) {
// 先启动所有处理,返回Promise数组
const promises = items.map(item => processItem(item));
// 等待所有完成
const results = await Promise.all(promises);
return results;
}
// 10个item还是100ms左右(理论上,实际看并发限制)
或者如果你需要控制并发数(比如不能同时发100个请求把服务器打挂),可以用一些工具库比如p-limit,或者自己实现一个队列。
实际项目里我是怎么翻车的
接口A、B、C三个请求,我以为会按顺序回来
这是我印象最深的一次翻车。当时在做一个电商详情页,需要加载:
- A:商品基础信息(必须最先有,不然页面没法渲染)
- B:用户评价(可以稍后)
- C:推荐商品(可以最后)
我当时的代码大概是这样:
async function loadProductPage(productId) {
// 启动三个请求,但都加了await,想控制顺序
const basicInfo = await fetchProductBasic(productId);
renderBasicInfo(basicInfo); // 渲染基础信息
const reviews = await fetchReviews(productId);
renderReviews(reviews); // 渲染评价
const recommendations = await fetchRecommendations(productId);
renderRecommendations(recommendations); // 渲染推荐
}
看起来没问题对吧?先等基础信息,渲染了,再等评价,再渲染,最后推荐。用户应该能看到逐步加载的效果。
但问题在于,这三个请求是串行的!总时间是A+B+C。如果A要500ms,B要300ms,C要200ms,用户要等整整1秒才能看到完整页面。
更坑的是,我当时以为"反正都是异步的,应该很快",结果在弱网环境下,A接口偶尔要2秒,整个页面就白屏2秒,然后瞬间把所有内容弹出来。用户体验极差。
正确的做法应该是:
async function loadProductPage(productId) {
// 1. 最关键的先拿,用户必须看到
const basicInfo = await fetchProductBasic(productId);
renderBasicInfo(basicInfo);
// 2. 这两个可以并行,不阻塞彼此
const [reviews, recommendations] = await Promise.all([
fetchReviews(productId),
fetchRecommendations(productId)
]);
renderReviews(reviews);
renderRecommendations(recommendations);
}
这样总时间变成了A + max(B, C),大约700ms而不是1000ms。而且用户可以更早看到基础信息。
结果C先回来了,A还在路上,数据渲染直接错乱
另一个更诡异的bug发生在数据依赖的场景。假设你有一个仪表盘,需要:
- 先获取当前用户ID
- 根据用户ID获取该用户的订单统计
- 同时获取系统公告(跟用户无关)
我当时的代码:
async function loadDashboard() {
// 同时启动,想节省时间
const userPromise = fetchCurrentUser();
const statsPromise = fetchUserStats(); // 依赖user.id!
const announcementPromise = fetchAnnouncements();
const [user, stats, announcements] = await Promise.all([
userPromise,
statsPromise, // 这里出问题了!
announcementPromise
]);
render({ user, stats, announcements });
}
看出问题了吗?fetchUserStats()在启动的时候,可能还没有user.id!如果fetchCurrentUser()比fetchUserStats()慢,那stats接口就会收到undefined的用户ID,返回错误或者别人的数据。
这种bug在本地开发很难发现,因为本地接口都很快,userPromise往往先完成。但一到线上,网络波动导致顺序错乱,就会出现"我看到别人的数据"这种严重问题。
正确的依赖处理:
async function loadDashboard() {
// 1. 先拿必须的数据
const user = await fetchCurrentUser();
// 2. 并行的请求,其中stats依赖user
const [stats, announcements] = await Promise.all([
fetchUserStats(user.id), // 确保有user.id才启动
fetchAnnouncements() // 这个独立,可以并行
]);
render({ user, stats, announcements });
}
记住:只要有依赖关系,就必须用await保证顺序。不要为了并行而并行,数据正确性永远是第一位的。
还有那种循环里用await的,性能直接原地爆炸
前面提到过循环里的await问题,我再讲个真实的惨案。当时要做批量上传图片,用户选了20张图,我要一张张上传到OSS。
我的第一版代码:
async function uploadImages(files) {
const urls = [];
for (const file of files) {
console.log(`开始上传 ${file.name}`);
// 压缩图片(异步)
const compressed = await compressImage(file);
// 获取上传凭证(异步)
const token = await getUploadToken();
// 上传到OSS(异步)
const url = await uploadToOSS(compressed, token);
urls.push(url);
console.log(`${file.name} 上传完成`);
}
return urls;
}
看起来逻辑很清晰对吧?压缩→拿token→上传→下一张。但20张图,每张如果耗时500ms(压缩100ms+token 100ms+上传300ms),总共要10秒!用户上传20张图要等10秒,这谁受得了。
而且实际上,压缩和获取token是可以并行的,上传才需要排队(因为浏览器对同一域名有并发连接限制)。
优化后的版本:
async function uploadImages(files) {
// 1. 预处理:并行压缩所有图片
console.log('开始压缩图片...');
const compressPromises = files.map(file => compressImage(file));
const compressedFiles = await Promise.all(compressPromises);
console.log('所有图片压缩完成');
// 2. 批量获取token(可以并行,或者缓存)
// 假设token可以复用,或者一次获取多个
const tokens = await Promise.all(
compressedFiles.map(() => getUploadToken())
);
// 3. 上传阶段:控制并发数
// 浏览器通常限制同一域名6个并发连接
const CONCURRENT_LIMIT = 6;
const urls = [];
for (let i = 0; i < compressedFiles.length; i += CONCURRENT_LIMIT) {
const batch = compressedFiles.slice(i, i + CONCURRENT_LIMIT);
const batchTokens = tokens.slice(i, i + CONCURRENT_LIMIT);
// 这一批6个并行上传
const uploadPromises = batch.map((file, index) =>
uploadToOSS(file, batchTokens[index])
);
const batchUrls = await Promise.all(uploadPromises);
urls.push(...batchUrls);
console.log(`第 ${i/CONCURRENT_LIMIT + 1} 批上传完成`);
}
return urls;
}
优化后,20张图:
- 压缩阶段:并行处理,假设还是100ms一张,但并行后总时间约100ms+
- 获取token:并行,约100ms
- 上传阶段:分4批(20/6),每批约300ms,总共1200ms
总时间从10秒降到1.5秒左右,用户体验天壤之别。
表单提交加loading,async没处理好,用户狂点按钮
最后说个交互层面的坑。表单提交按钮为了防止重复提交,通常会加个loading状态:
async function handleSubmit() {
this.isLoading = true; // 显示loading
try {
const result = await submitForm(this.formData);
this.showSuccess('提交成功');
this.$router.push('/success');
} catch (err) {
this.showError(err.message);
} finally {
this.isLoading = false; // 隐藏loading
}
}
看起来没问题对吧?但如果你在await之前有什么异步操作,或者isLoading的更新是异步的(比如Vue的nextTick),就可能出现loading还没显示出来,用户又点了一次按钮的情况。
更隐蔽的是,如果submitForm内部有重试逻辑,或者网络延迟导致await时间很长,用户可能会以为页面卡死了,疯狂点击按钮。这时候你需要:
async function handleSubmit() {
// 1. 立即禁用按钮,不要有延迟
this.isLoading = true;
this.isDisabled = true;
// 2. 如果框架的响应式有延迟,强制同步更新UI
// Vue里可以用nextTick,或者直接用原生DOM操作
await this.$nextTick(); // 确保loading状态已经渲染到DOM
try {
// 3. 添加防抖,即使逻辑有漏洞,多次点击也只会执行一次
if (this.isSubmitting) return;
this.isSubmitting = true;
const result = await submitForm(this.formData);
// 4. 成功后的跳转延迟一点,让用户看到成功提示
this.showSuccess('提交成功');
await new Promise(resolve => setTimeout(resolve, 500));
this.$router.push('/success');
} catch (err) {
this.showError(err.message);
// 5. 失败后才恢复按钮,成功跳转前不要恢复
this.isLoading = false;
this.isDisabled = false;
this.isSubmitting = false;
}
}
关键点:loading状态必须在任何await之前就生效,而且要确保UI已经更新。否则那个时间差里,用户多点的几次按钮就会触发多次提交。
翻车了怎么救,排查思路给你捋清楚
先看控制台,Promise状态有没有pending卡住
当你发现async函数"没反应"的时候,第一件事就是打开控制台,看看有没有未处理的Promise。Chrome DevTools的Network面板和Console面板是你的好朋友。
// 假设这个函数看起来执行了,但后面没反应
async function suspiciousFunction() {
console.log('开始');
const data = await fetchData(); // 这里可能永远pending
console.log('拿到数据', data); // 这行没打印!
return data;
}
// 调用的时候包一层调试
suspiciousFunction().then(result => {
console.log('最终结果', result);
}).catch(err => {
console.error('出错了', err); // 有没有错误?
});
如果"拿到数据"没打印,但也没报错,那很可能是Promise一直pending。去Network面板看看请求状态:
- 状态是pending?可能是网络问题,或者服务器没响应
- 状态是200但没返回?可能是服务器挂了,返回了空数据
- 根本没发请求?可能是前面的代码报错了,但被你catch吞了
再用async/await调试工具,一步步跟执行流
现代浏览器的调试工具对async-await支持很好。你可以在await那一行打断点,然后单步执行(Step Over)。
async function debugMe() {
const a = await fetchA(); // 在这里打断点
console.log('A完成', a); // 按F10单步到这里
const b = await fetchB(); // 再单步
console.log('B完成', b);
return { a, b };
}
调试的时候注意观察:
- 执行到await那一行时,是立即继续,还是真的停住了?
- 变量a的值是Promise还是实际数据?(如果是Promise,说明你忘了await)
- 执行顺序是否符合预期?
网络面板瞅瞅,请求实际发出和返回的时间点
很多时候问题不在代码逻辑,而在网络时序。打开Network面板,勾选Preserve log,然后重新复现问题。
重点关注:
- Waterfall(瀑布流):看请求是串行还是并行。如果两个请求的开始时间差很多,说明是串行的;如果时间重叠,说明是并行的。
- Timing:看每个请求的耗时分布。DNS查询、SSL握手、TTFB(首字节时间)、下载时间,哪个环节慢一目了然。
- Initiator:看请求是由哪行代码触发的。如果你发现某个请求被触发了多次,那可能是循环或者重复调用的问题。
最后加日志,把每个await前后都打上时间戳
如果线上环境没法调试,只能靠日志。写一个辅助函数来跟踪async函数的执行:
// 调试神器:给async函数加上时间追踪
function traceAsync(fn, name) {
return async function(...args) {
const start = performance.now();
console.log(`[${name}] 开始执行`, args);
try {
const result = await fn.apply(this, args);
const end = performance.now();
console.log(`[${name}] 成功完成,耗时${(end - start).toFixed(2)}ms`, result);
return result;
} catch (err) {
const end = performance.now();
console.error(`[${name}] 执行失败,耗时${(end - start).toFixed(2)}ms`, err);
throw err;
}
};
}
// 使用方式
const tracedFetchUser = traceAsync(fetchUser, 'fetchUser');
const tracedFetchOrders = traceAsync(fetchOrders, 'fetchOrders');
// 然后像正常一样调用
async function loadData() {
const user = await tracedFetchUser(123);
const orders = await tracedFetchOrders(user.id);
return { user, orders };
}
这样你能在控制台看到完整的执行时间线,很容易发现哪个环节慢了,或者哪个Promise一直没返回。
几个保命的开发技巧
独立请求用Promise.all,别傻乎乎串行等
这个前面强调过很多次了,但值得单独拿出来再说一遍。只要两个请求没有依赖关系,就一定要并行:
// ❌ 错误示范:串行
const user = await fetchUser();
const products = await fetchProducts(); // 傻等user完成,其实没关系
// ✅ 正确示范:并行
const [user, products] = await Promise.all([
fetchUser(),
fetchProducts()
]);
如果你担心其中一个失败导致整个Promise.all失败,可以用Promise.allSettled:
const results = await Promise.allSettled([
fetchUser(),
fetchProducts(),
fetchAds()
]);
// 处理结果,不管成功失败
results.forEach((result, index) => {
if (result.status === 'fulfilled') {
console.log(`请求${index}成功`, result.value);
} else {
console.error(`请求${index}失败`, result.reason);
}
});
这样即使某个接口挂了,其他的还能正常返回,页面可以部分渲染,而不是整个白屏。
循环里别直接await,map转Promise再all
再写一遍,因为这个坑太常见了:
// ❌ 慢得要死
for (const item of items) {
await process(item);
}
// ✅ 飞快
await Promise.all(items.map(item => process(item)));
// ✅ 如果需要控制并发,用p-limit库
const pLimit = require('p-limit');
const limit = pLimit(5); // 最多5个并发
await Promise.all(
items.map(item => limit(() => process(item)))
);
如果你用Node.js或者现代浏览器,还可以用for await...of配合异步生成器,但那又是另一个话题了。
超时处理加上,不然接口挂了你等着超时吧
网络请求永远不知道会卡多久,一定要加超时:
// 封装一个带超时的fetch
async function fetchWithTimeout(url, options = {}, timeout = 5000) {
const controller = new AbortController();
const id = setTimeout(() => controller.abort(), timeout);
try {
const response = await fetch(url, {
...options,
signal: controller.signal
});
clearTimeout(id);
return response;
} catch (error) {
clearTimeout(id);
if (error.name === 'AbortError') {
throw new Error('请求超时');
}
throw error;
}
}
// 使用
try {
const data = await fetchWithTimeout('/api/data', {}, 3000);
} catch (err) {
if (err.message === '请求超时') {
// 给用户提示,或者重试
}
}
如果你用axios,它自带timeout配置,更方便:
axios.get('/api/data', { timeout: 5000 })
.then(response => {
// 处理数据
})
.catch(err => {
if (err.code === 'ECONNABORTED') {
console.error('请求超时');
}
});
错误边界兜住,别让一个请求失败搞崩整个页面
在React或者Vue里,一定要做好错误边界(Error Boundary)或者全局错误处理。
// React错误边界示例
class ErrorBoundary extends React.Component {
state = { hasError: false, error: null };
static getDerivedStateFromError(error) {
return { hasError: true, error };
}
componentDidCatch(error, errorInfo) {
console.error('错误边界捕获:', error, errorInfo);
// 上报到监控平台
reportError(error, errorInfo);
}
render() {
if (this.state.hasError) {
return <h1>出错了,请刷新页面重试</h1>;
}
return this.props.children;
}
}
// 使用
<ErrorBoundary>
<AsyncComponent />
</ErrorBoundary>
在async函数内部,也要养成try-catch的习惯,特别是那些"非致命"的错误:
async function loadNonCriticalData() {
try {
const ads = await fetchAds();
return ads;
} catch (err) {
// 广告加载失败不应该影响主流程
console.error('广告加载失败', err);
return []; // 返回空数组让页面正常渲染
}
}
需要顺序的才用await,能并发的就别磨叽
总结成一句话:先想清楚数据依赖关系,再决定要不要await。
画个依赖图很有帮助:
用户详情页
├── 用户基础信息 (必须最先有)
├── 用户订单列表 (依赖用户ID)
├── 用户收藏列表 (依赖用户ID)
└── 系统推荐商品 (独立,不依赖用户)
执行策略:
1. await 用户基础信息
2. Promise.all([用户订单, 用户收藏, 推荐商品])
- 其中订单和收藏依赖用户ID,但可以并行
- 推荐商品完全独立,并行
把依赖关系理清楚,代码自然就写对了。
最后说点掏心窝子的
我当年也是信了async-await的邪,以为万事大吉。那时候刚从回调地狱爬出来,看到async-await就像看到救命稻草,什么场景都用,结果踩了一堆坑。
现在我看到async就会条件反射,先问自己三个问题:
- 这个await有必要吗? 能不能和其他并行?
- 错误处理做了吗? 失败了会怎么样?
- 超时机制有吗? 万一卡死了怎么办?
你们要是少踩几个坑,我这文章就没白写。真的,我踩过的坑够写一本书了,从"以为await会阻塞整个线程"到"在循环里await导致性能爆炸",每一个都是血泪教训。
下次遇到顺序问题,回来翻翻这篇。记得请我喝奶茶,三分糖去冰,谢谢。
哦对了,还有个小彩蛋。你知道async-await最坑的是什么吗?是当你习惯了它之后,再去看那些用Promise链写的旧代码,会有一种"这写的什么玩意儿"的感觉,然后忍不住重构,结果引入新bug。别问我怎么知道的,我去改bug了。

更多推荐



所有评论(0)