前端老鸟血泪史: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引擎只是发了个指令,然后继续干别的。

事件循环的工作流程大概是这样的:

  1. 执行栈(Call Stack):同步代码在这里执行,后进先出。
  2. Web APIs:setTimeout、DOM事件、网络请求等在这里处理。
  3. 任务队列(Task Queue):也叫宏任务队列,setTimeout回调、I/O操作等放这里。
  4. 微任务队列(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发生在数据依赖的场景。假设你有一个仪表盘,需要:

  1. 先获取当前用户ID
  2. 根据用户ID获取该用户的订单统计
  3. 同时获取系统公告(跟用户无关)

我当时的代码:

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就会条件反射,先问自己三个问题:

  1. 这个await有必要吗? 能不能和其他并行?
  2. 错误处理做了吗? 失败了会怎么样?
  3. 超时机制有吗? 万一卡死了怎么办?

你们要是少踩几个坑,我这文章就没白写。真的,我踩过的坑够写一本书了,从"以为await会阻塞整个线程"到"在循环里await导致性能爆炸",每一个都是血泪教训。

下次遇到顺序问题,回来翻翻这篇。记得请我喝奶茶,三分糖去冰,谢谢。

哦对了,还有个小彩蛋。你知道async-await最坑的是什么吗?是当你习惯了它之后,再去看那些用Promise链写的旧代码,会有一种"这写的什么玩意儿"的感觉,然后忍不住重构,结果引入新bug。别问我怎么知道的,我去改bug了。

在这里插入图片描述

Logo

有“AI”的1024 = 2048,欢迎大家加入2048 AI社区

更多推荐