Async/Await实战:现代JavaScript异步编程指南

张玥 2026年9月20日 阅读时间 35分钟
JavaScript Async/Await Promise 事件循环 错误处理 性能优化
前端开发技巧之Async/Await实战:现代JavaScript异步编程指南

async / await 从 2017 年进入 ECMAScript 标准算起,已经被使用了将近十年。它把异步代码写得像同步代码一样直观,几乎抹平了异步编程的认知门槛。但也正因为“太顺手”,大量项目里埋着同一批问题:该并发的写成了串行、被吞掉的 rejection、forEach 里的 await 完全不生效、请求失败后整个页面白屏。语法只是入场券,真正决定代码质量的是对事件循环的理解、对错误边界的把控、以及对并发模型的取舍。本篇 35 分钟长文,从异步编程的四代演进讲起,逐层拆解 async 函数的本质、await 在事件循环中的真实行为、错误处理与并发编排的工程模式,最后给出一份可直接落地的检查清单。

一、异步编程的四代演进:从回调到 async/await

JavaScript 是一门单线程语言。这句话你可能已经听过一百遍,但它真正的含义是:同一时刻,主线程只能执行一段代码。而网络请求、文件读写、定时器这些操作的耗时从几十毫秒到几秒不等,如果它们同步执行,整个页面就会卡死——按钮点不动、滚动没反应、动画直接停摆,连浏览器都会弹出“页面无响应”的提示。

所以异步不是 JavaScript 的一个可选特性,而是它能够存活至今的根本原因。为了把异步写得顺手,这门语言经历了四代方案的迭代。

第一代:回调函数(Callback)

最早的方案是把后续逻辑打包成函数,交给异步 API 在完成后调用。这在只有一个异步操作时非常清爽:

setTimeout(function () {
  console.log('1 秒后执行');
}, 1000);

问题出现在多个异步操作需要依次依赖的时候。第二个请求要用第一个请求的返回值,第三个要用第二个的,于是代码开始向右生长:

getUser(userId, function (user) {
  getOrders(user.id, function (orders) {
    getOrderDetail(orders[0].id, function (detail) {
      getLogistics(detail.no, function (logistics) {
        render(logistics);
      });
    });
  });
});

// 出错怎么办?每一层都要写一份 err 判断

这就是著名的回调地狱(Callback Hell)。它的痛点不只是缩进深,更致命的是三点:

  • 错误处理无法统一:每一层回调都要单独判断错误,很容易漏掉某一层。
  • 控制流被反转:代码的书写顺序与执行顺序不再一致,阅读时要在脑子里做栈展开。
  • 无法组合:想“三个请求都完成后再继续”或者“谁先返回用谁”,用回调几乎写不出来。

第二代:Promise

Promise 把“未来的值”抽象成一个对象,提供 then / catch / finally 三个方法,让链式调用成为可能:

getUser(userId)
  .then(user => getOrders(user.id))
  .then(orders => getOrderDetail(orders[0].id))
  .then(detail => getLogistics(detail.no))
  .then(logistics => render(logistics))
  .catch(err => console.error(err))
  .finally(() => hideLoading());

缩进被拉平了,错误处理也统一了。但新的问题随之而来:链式调用依然不是“人话”。当逻辑中混入条件分支、循环、try/catch 时,then 链会迅速退化成另一种难以维护的形态——变量作用域受限,中间值无法跨步骤共享,调试时堆栈也是断的。

第三代:Generator + 执行器

ES6 带来的 Generator 可以让函数“暂停”与“恢复”,配合 yield 与一个自动执行器(早期最著名的是 co 库),就能把异步流程写成同步的样子:

function* loadFlow(userId) {
  const user = yield getUser(userId);
  const orders = yield getOrders(user.id);
  const detail = yield getOrderDetail(orders[0].id);
  return detail;
}

// co(loadFlow)(123).then(...)

这已经是 async/await 的雏形了。它证明了“把异步写成同步”这条路是可行的,只是还需要一个第三方库、外加 function* 这种略显陌生的语法。

第四代:async / await

ES2017 把 Generator 的思路直接内建到语言里,用 async 标记异步函数,用 await 等待 Promise 落定。同样的逻辑变成:

async function loadFlow(userId) {
  try {
    const user = await getUser(userId);
    const orders = await getOrders(user.id);
    const detail = await getOrderDetail(orders[0].id);
    return detail;
  } catch (err) {
    console.error(err);
    throw err;
  }
}

没有第三方库,没有奇怪的星号语法,错误处理回到了熟悉的 try/catch。这是目前为止最接近“人类直觉”的异步写法。

四代方案横向对比

方案 可读性 错误处理 调试体验 组合能力
回调函数 差 分散 堆栈断裂 几乎为零
Promise 链 中 统一 一般 强
Generator + co 好 统一 一般 强
async / await 好 统一 优秀 强

同一段逻辑的两种写法

点击按钮对比一下“回调嵌套”与“async/await”在真实业务中的可读性差异。两者的行为完全等价,但维护成本相差一个数量级。

function checkout(cartId, callback) {
  getCart(cartId, function (err, cart) {
    if (err) return callback(err);

    getStock(cart.items, function (err, stock) {
      if (err) return callback(err);
      if (!stock.ok) return callback(new Error('库存不足'));

      createOrder(cart, function (err, order) {
        if (err) return callback(err);

        pay(order.id, function (err, result) {
          if (err) return callback(err);
          callback(null, result);
        });
      });
    });
  });
}

四层嵌套变成了四条直线,每一个错误分支都不用手动透传,任何一步 throw 都会自动沿着 await 链向上冒泡。这就是 async/await 真正的价值:它不是让你少写几个字符,而是让你能像写同步代码一样推理异步流程。

二、async 函数的真实身份:它到底返回了什么

理解 async 函数的第一步,是把它的“外表”和“本质”分开看。一个 async 函数,无论里面写的是什么,调用它时永远返回一个 Promise。这一点是后面所有错误处理与并发编排的基础。

返回值如何被包装

async function a() { return 1; }
a() instanceof Promise; // true
a().then(v => console.log(v)); // 1

async function b() { return Promise.resolve(2); }
b().then(v => console.log(v)); // 2 —— 不会多包一层

async function c() { throw new Error('boom'); }
c().catch(e => console.log(e.message)); // boom

async function d() { }
d().then(v => console.log(v)); // undefined
return 值 被包装成 Promise.resolve(值),同步返回也一样
return Promise 直接采用该 Promise 的状态,不会出现 Promise<Promise<T>>
throw 等价于返回一个 rejected Promise
不写 return 等价于 return undefined,Promise 以 undefined 兑现

一个容易被忽略的细节:函数体的前半段是同步的

很多人以为 async 函数一调用就“整体进入异步”。这是错的。函数体会一直同步执行到第一个 await 为止,这之前的所有语句都在当前调用栈里跑完。只有遇到 await 之后,函数才会被挂起并交还控制权。

async function demo() {
  console.log('A —— 同步执行');
  await null;
  console.log('B —— 微任务阶段执行');
}

console.log('start');
demo();
console.log('end');

// 输出顺序:start → A → end → B

这个特性有两个实际影响。第一,async 函数里 await 之前抛出的错误,在函数内部用 try/catch 能捕获,但在外部则表现为返回一个 rejected Promise。第二,把耗时的同步计算放在 await 之前,一样会阻塞主线程——async 并不能把同步代码变成异步。

async 函数的几个语法边界

  • 不能作为构造函数:async function F(){} 没有 prototype 属性,new F() 会抛错。
  • 可以用在任意函数位置:函数声明、函数表达式、箭头函数、对象方法、类方法、类静态方法都支持 async。
  • 类方法简写同样支持:class A { async load() {} } 完全合法。
  • getter / setter 不能是 async:get 与 set 必须同步返回值,无法等待。
  • 构造函数不能是 async:需要异步初始化时,用静态工厂方法代替。
// 用静态工厂方法代替 async constructor
class UserStore {
  constructor(data) {
    this.data = data;
  }

  static async create(id) {
    const data = await fetchUser(id);
    return new UserStore(data);
  }
}

const store = await UserStore.create(1);

三、await 到底做了什么:从事件循环看异步

这是全文最重要的一节。绝大多数 async/await 的“诡异现象”,根源都在于对 await 行为的误解。

await 不阻塞线程,它只是暂停函数

看到 await 这个词,很多人会联想到 Java 或 C# 里的“线程阻塞”。但在 JavaScript 里,await 暂停的是当前这个 async 函数的执行,而不是整个主线程。函数被挂起后,控制权立即交还给调用者,主线程继续处理其他任务(渲染、事件、其他代码)。等 await 右侧的 Promise 落定,函数才从挂起点恢复。

如果把 await 理解成“把剩下的代码包成一个回调,注册到 Promise 上,然后立刻返回”,那你就理解对了。

一个 async 函数的完整生命周期

点击下面每个阶段,看看 JavaScript 引擎在背后做了什么。

1
调用
同步进入函数体
调用 async 函数时,引擎会同步执行函数体的代码,直到遇到第一个 await。此时还没有任何异步发生。
2
求值
计算 await 右侧
引擎先对 await 右侧的表达式求值,把它转换成 Promise(如果不是 Promise,就用 Promise.resolve 包装)。
3
挂起
保存执行上下文
函数被挂起,局部变量与执行位置被保存。此时函数返回一个 pending 状态的 Promise 给调用者。
4
交还
控制权回到调用者
主线程立刻继续执行 async 函数调用点之后的同步代码,以及处理其他已排队的任务,不会等待。
5
入队
回调进入微任务队列
当 Promise 落定,恢复函数执行的函数会被放进微任务队列,而不是宏任务队列。它会在当前宏任务结束、浏览器渲染之前执行。
6
恢复
从挂起点继续
微任务被执行,函数从 await 处恢复。若 Promise 已兑现,await 表达式的值就是兑现值;若被拒绝,则在此处抛出异常。
7
兑现
return → resolve
函数执行到 return,返回值被用来兑现最初返回的那个 Promise,所有 then 回调按序进入微任务队列。
8
拒绝
throw → reject
函数内任何未捕获的异常(包括 await 的拒绝)都会让最初返回的 Promise 进入 rejected 状态。若无人 catch,触发全局 unhandledrejection。

微任务优先于宏任务

事件循环的每一次迭代大致是:执行一个宏任务 → 清空所有微任务 → 渲染 → 取下一个宏任务。Promise 的回调属于微任务,setTimeout 属于宏任务。这解释了下面这个经典输出顺序:

console.log('1 同步');

setTimeout(() => {
  console.log('2 宏任务');
}, 0);

Promise.resolve().then(() => {
  console.log('3 微任务');
});

(async () => {
  console.log('4 async 同步段');
  await null;
  console.log('5 await 之后');
})();

console.log('6 同步结束');
11 同步
24 async 同步段
36 同步结束
43 微任务
55 await 之后
62 宏任务
—微任务整体先于宏任务

注意第 4 条:async 函数的同步段在 await 之前就执行完了,和普通函数没有区别。也注意第 3、5 条的顺序:先注册的 then 与后恢复的 await,都按进入微任务队列的先后顺序执行。

连续 await 并不会“叠加”延迟

一个常见的担忧是:“每个 await 都要等一个微任务,那么十个 await 是不是要等十次渲染?”答案是不会。微任务队列会在当前宏任务结束后被一次性清空,中间不会插入渲染。十个 await 只是排了十个微任务,它们会在同一轮里全部执行完。

但要注意,如果每个 await 后面接的是真实的异步操作(网络、定时器),那等待时间就是实打实的累加——这属于下一节要讲的并发问题,与微任务机制无关。

四、错误处理:try/catch 的边界在哪里

async/await 最大的卖点之一,就是让异步错误重新回到 try/catch 的怀抱。但“能用”和“用对”之间,还有不少细节。

await 会把 rejection 变成 throw

这是整套机制的核心:当 await 的 Promise 被拒绝时,await 表达式会抛出那个拒绝原因。因此它和同步的 throw 一样,可以被 try/catch 捕获,也会沿着调用栈向上传播。

async function load() {
  try {
    const res = await fetch('/api/data');
    if (!res.ok) {
      throw new Error('HTTP ' + res.status);
    }
    return await res.json();
  } catch (err) {
    console.error('加载失败:', err);
    throw err; // 不要吞掉错误
  }
}

错误吞噬:最隐蔽的 bug 来源

try/catch 给了你捕获错误的能力,也给了你悄悄掩盖错误的能力。下面这段代码在页面上的表现是“点击按钮没反应”,控制台一片安静,排查起来极其痛苦:

错误吞噬
try { await save(); } catch (e) {}
空 catch 块把失败信息彻底丢掉,用户不知道发生了什么,开发者也拿不到线索。
至少留下痕迹
catch (e) { console.error(e); showToast('保存失败'); throw e; }
记录、反馈、继续向上传播,三层信息都不丢。

缩小 try 的范围

把一大段逻辑全包进 try 里,会让“到底哪一步失败了”变得模糊,也容易把不该被当作异常的逻辑错误一起吞掉。更好的做法是只包裹真正会抛出异常的那一行:

// 不推荐:范围过大,失败点不明
try {
  const a = await step1();
  const b = await step2(a);
  const c = await step3(b);
  return c;
} catch (e) {
  console.error('出错了');
}
// 推荐:每个错误有自己的上下文
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); });

return await step3(b);

自定义错误类型

当业务复杂到一定程度,用 err.message 字符串判断错误种类会变得非常脆弱。定义一个错误基类,能让你用 instanceof 精确分流:

class AppError extends Error {
  constructor(message, code) {
    super(message);
    this.name = 'AppError';
    this.code = code;<
  }
}

class NetworkError extends AppError {
  constructor(message) {
    super(message, 'NETWORK');
    this.name = 'NetworkError';
  }
}

try {
  await loadData();
} catch (err) {
  if (err instanceof NetworkError) {
    showRetry();
  } else if (err instanceof AppError) {
    showToast(err.message);
  } else {
    throw err; // 未知错误继续抛出
  }
}

不要忘记最后一道防线

总有一些 Promise 会逃出所有 catch。在浏览器端,它们会触发 unhandledrejection 事件;在 Node.js 中,默认行为是打印警告甚至直接终止进程。给全局加一个兜底,至少能让问题被记录下来:

// 浏览器
window.addEventListener('unhandledrejection', event => {
  console.error('未处理的 Promise 拒绝:', event.reason);
  reportToServer(event.reason);
  // event.preventDefault(); // 阻止控制台默认输出
});

// Node.js
process.on('unhandledRejection', (reason) => {
  console.error('未处理的拒绝:', reason);
  process.exitCode = 1;
});

五、并发编排:串行与并行的取舍

这是 async/await 实践中最容易造成性能损失的地方。await 写起来太自然了,自然到让人忘记:每写一个 await,就是一次“必须等它完成”的承诺。

串行陷阱

看下面这段代码。三个接口之间没有任何依赖关系,但写法上强制它们依次等待。如果每个请求耗时 300ms,总耗时就是 900ms:

// 串行:900ms
const user = await fetchUser();
const orders = await fetchOrders();
const coupons = await fetchCoupons();

// 并发:300ms
const [user, orders, coupons] = await Promise.all([
  fetchUser(),
  fetchOrders(),
  fetchCoupons(),
]);

点击下面的按钮,直观感受一下两者的时间差。三条轨道代表三个接口调用,每个耗时 300ms。

fetchUser()300ms
A
fetchOrders()300ms
B
fetchCoupons()300ms
C
三个接口总耗时 900ms

四个组合方法的适用场景

Promise 提供了四个静态方法用于编排多个 Promise,它们的行为差异很大,选错会导致逻辑错误或性能损失。

方法 成功条件 失败条件 典型场景
Promise.all 全部兑现 任意一个拒绝即整体拒绝 多个接口都成功才能渲染页面
Promise.allSettled 永远兑现 永不拒绝 批量操作,需要逐条汇报成败
Promise.race 第一个落定(成功或失败) — 超时控制、多源竞速
Promise.any 第一个成功 全部失败 多个镜像源取最快可用
// allSettled:不关心失败,只关心每条结果
const results = await Promise.allSettled(tasks);
results.forEach(r => {
  if (r.status === 'fulfilled') {
    console.log('成功:', r.value);
  } else {
    console.log('失败:', r.reason);
  }
});

一个必须注意的细节:Promise.all 的“提前失败”

Promise.all 在任意一个 Promise 拒绝时会立即拒绝,但其他 Promise 并不会被取消,它们仍然在后台继续执行。如果那些请求带有副作用(比如写库、扣款),这可能造成数据不一致。需要真正取消时,请使用下一节要讲的 AbortController。

限制并发数量

Promise.all 会一次性发起所有请求。如果数组里有 2000 个任务,浏览器会同时建立 2000 个连接,瞬间打满并发限制、阻塞其他请求,甚至被服务端限流。控制并发上限是生产环境的必备技能:

async function pool(tasks, limit = 5) {
  const results = new Array(tasks.length);
  let cursor = 0;

  async function worker() {
    while (cursor < tasks.length) {
      const index = cursor++;
      try {
        results[index] = await tasks[index]();
      } catch (err) {
        results[index] = { error: err };
      }
    }
  }

  const workers = Array.from(
    { length: Math.min(limit, tasks.length) },
    worker
  );

  await Promise.all(workers);
  return results;
}

这个模式的核心是“固定数量的工人 + 共享游标”:启动 limit 个 worker,每个 worker 循环地从任务数组里取下一个任务,直到取完为止。这样任意时刻最多只有 limit 个任务在执行,且所有任务都会被处理。

依赖关系决定并发结构

并不是所有“看起来无关”的请求都真的无关。判断标准很简单:后一个请求的参数是否依赖前一个请求的结果。如果不依赖,就并发;如果依赖,就必须串行。混在一起时,可以分层并发:

// 第一层:user 是后面所有请求的前提,必须先拿到
const user = await fetchUser();

// 第二层:三个请求都依赖 user.id,但彼此无关,可以并发
const [orders, coupons, address] = await Promise.all([
  fetchOrders(user.id),
  fetchCoupons(user.id),
  fetchAddress(user.id),
]);

// 总耗时 = 第一层 + 第二层,而不是四层累加

六、超时、取消与 AbortController

Promise 从诞生起就有一个先天缺陷:它无法被取消。一个已经发出的请求,就算你已经不需要它的结果了,它依然会跑完、依然会占用带宽和连接。这个缺口,最终由 AbortController 补上。

AbortController 的基本用法

const controller = new AbortController();
const { signal } = controller;

fetch('/api/search?q=vue', { signal })
  .then(res => res.json())
  .catch(err => {
    if (err.name === 'AbortError') {
      console.log('请求被主动取消');
    } else {
      throw err;
    }
  });

// 需要取消时
controller.abort();

封装一个通用的超时函数

把超时与取消结合起来,可以得到一个非常实用的工具函数。AbortSignal.timeout() 在较新的浏览器中可以直接生成一个“到期自动中止”的信号:

function withTimeout(promise, ms, message = '请求超时') {
  let timer;

  const timeout = new Promise((_, reject) => {
    timer = setTimeout(() => reject(new Error(message)), ms);
  });

  return Promise.race([promise, timeout])
    .finally(() => clearTimeout(timer));
}

const data = await withTimeout(fetch(url), 5000);

注意 finally 里必须清除定时器,否则即使请求提前成功,那个定时器依然会挂在事件循环里,积累多了会造成内存泄漏。

把 signal 一路传下去

真正健壮的取消方案,需要把 signal 从调用入口一直传递到最底层的请求:

async function search(keyword, signal) {
  const suggestions = await fetchSuggestions(keyword, signal);
  const results = await fetchResults(keyword, signal);
  return { suggestions, results };
}

// 输入框里每次输入都取消上一次的搜索
let current;
input.addEventListener('input', (e) => {
  current?.abort();
  current = new AbortController();
  search(e.target.value, current.signal)
    .then(render)
    .catch(err => {
      if (err.name !== 'AbortError') console.error(err);
    });
});

除了 fetch,addEventListener、ReadableStream、WebSocket 等一批现代 API 都已支持 signal 选项。一个统一的取消信号,可以让整条异步链路在用户切换页面时干净地停下来。

七、循环中的异步:forEach 为什么不生效

“在 forEach 里写 await,为什么没等就往下走了?”——这是 JavaScript 社区被问得最多的问题之一,答案其实很直接。

forEach 不吃 async 回调

// 错误的写法:await 完全不起作用
items.forEach(async (item) => {
  await save(item);
});
console.log('全部完成'); // 会先打印

原因在于 forEach 的实现:它只是对每个元素调用一次回调,并不关心回调返回什么。回调返回的 Promise 被直接丢弃,forEach 也就无法等待。而且所有回调是同时被调用的,等于变相并发(但没有并发控制)。

三种正确的写法

// 1. for...of:串行,逐个等待
for (const item of items) {
  await save(item);
}
console.log('全部完成');

// 2. for await...of:处理异步可迭代对象
for await (const chunk of stream) {
  console.log(chunk);
}
// 3. map + Promise.all:并发,不保证顺序
await Promise.all(
  items.map(item => save(item))
);
console.log('全部完成');

// 4. 需要保序的并发
const results = await Promise.all(
  items.map((item, i) => save(item))
);
// Promise.all 保证结果数组顺序与输入一致
串行 for...of + await,前一个完成才处理下一个,适合有顺序依赖或需要限速的场景
并发 map + Promise.all,同时发起,总耗时取决于最慢的一个
流式 for await...of,逐个消费异步迭代器产出的值,内存占用恒定
避免 forEach + async,既不等结果也无法捕获错误

for...of 里使用 await 的性能提醒

for...of + await 是严格串行的。如果循环体里是一次网络请求,且元素有几十上百个,总耗时会是单次耗时的整数倍。判断标准依然是:后一次迭代是否依赖前一次的结果。不依赖,就该换成并发写法,或者用前面提到的并发池限制上限。

八、工程实战:五个可以立刻用上的模式

掌握了语法和原理之后,真正拉开差距的是这些工程模式。它们都建立在 async/await 之上,解决的是生产环境里的高频问题。

模式一:带指数退避的重试

网络抖动导致的失败,重试往往就能解决。但固定间隔的重试会在服务端压力大时雪上加霜,指数退避是更稳妥的做法:

async function retry(fn, { retries = 3, base = 300 } = {}) {
  let lastError;

  for (let attempt = 0; attempt <= retries; attempt++) {
    try {
      return await fn();
    } catch (err) {
      lastError = err;

      if (attempt === retries) break;
      if (err.code === 'AUTH') break; // 不可重试的错误

      const delay = base * 2 ** attempt;
      const jitter = Math.random() * base;

      await new Promise(r => setTimeout(r, delay + jitter));
    }
  }

  throw lastError;
}

const data = await retry(() => fetchData(url));

两个细节值得注意:一是加入了 jitter(随机抖动),避免大量客户端在同一时刻集体重试;二是对不可重试的错误(如鉴权失败)提前退出,不做无谓尝试。

模式二:请求去重

同一个接口在短时间内被多次调用,是列表页、详情页的常见现象。用一张 Map 缓存进行中的 Promise,可以让并发的重复请求共用同一个结果:

const pending = new Map();

function dedupe(key, fn) {
  if (pending.has(key)) {
    return pending.get(key);
  }

  const promise = fn()
    .finally(() => pending.delete(key));

  pending.set(key, promise);
  return promise;
}

// 三次调用只会发一次请求
const [a, b, c] = await Promise.all([
  dedupe('user:1', () => fetchUser(1)),
  dedupe('user:1', () => fetchUser(1)),
  dedupe('user:1', () => fetchUser(1)),
]);

关键在于 finally 里的清理:只有请求真正结束后才把 key 从 Map 中移除,这样“进行中”和“已完成”的请求被清晰地区分开,缓存不会无限增长。

模式三:带过期的缓存

function memoize(fn, ttl = 60000) {
  const cache = new Map();

  return async function (...args) {
    const key = JSON.stringify(args);
    const hit = cache.get(key);

    if (hit && Date.now() - hit.time < ttl) {
      return hit.value;
    }

    const value = await fn(...args);
    cache.set(key, { value, time: Date.now() });
    return value;
  };
}

模式四:节流式批量提交

高频触发的事件(滚动、输入、点击)如果每次都发请求,服务端会直接崩溃。把短时间内的多次调用合并成一次批量请求,是标准解法:

function createBatcher(flush, delay = 50) {
  let queue = [];
  let timer = null;

  return function (item) {
    return new Promise((resolve, reject) => {
      queue.push({ item, resolve, reject });

      if (timer) return;

      timer = setTimeout(async () => {
        const batch = queue;
        queue = [];
        timer = null;

        try {
          const results = await flush(batch.map(b => b.item));
          batch.forEach((b, i) => b.resolve(results[i]));
        } catch (err) {
          batch.forEach(b => b.reject(err));
        }
      }, delay);
    });
  };
}

模式五:优雅的加载状态管理

真实的业务函数往往需要同时处理“加载中”“成功”“失败”三种状态。用一个通用的包装器可以把这套样板逻辑抽出来:

async function withLoading(setLoading, fn) {
  setLoading(true);
  try {
    return await fn();
  } finally {
    setLoading(false); // 成功失败都会关闭 loading
  }
}

// 使用
const data = await withLoading(
  v => (loading.value = v),
  () => fetchData()
);

finally 在这里的作用是不可替代的:无论请求成功还是失败,加载状态都会被关闭。用 then + catch 各写一遍,很容易漏掉其中一条路径,导致 loading 永远转下去。

九、十二个高频陷阱清单

下面这些坑,几乎每一个都在真实项目里出现过。逐条对照检查,能省下大量排查时间。

陷阱 1 忘记写 await,拿到的是一个 Promise 而不是值,后续计算全部变成 NaN 或 [object Promise]
陷阱 2 在 forEach 里用 async 回调,以为会等待,实际所有回调同时启动且无法等待
陷阱 3 空 catch 块吞掉错误,页面表现为“点了没反应”,排查时毫无线索
陷阱 4 在构造函数中使用 async,或者试图 new 一个 async 函数
陷阱 5 把互不依赖的请求写成串行 await,白白浪费几倍加载时间
陷阱 6 用 Promise.all 处理可能部分失败的批量任务,一个失败导致全部结果丢失
陷阱 7 在循环里直接 await 发请求,几百个元素串行执行,页面卡死
陷阱 8 以为 await 会阻塞主线程,把耗时同步计算放在 await 之后以为能“让出线程”
陷阱 9 忘记处理 AbortError,把用户主动取消搜索也当成请求失败上报
陷阱 10 在 async 函数里用 this 但写成普通函数回调,导致 this 丢失
陷阱 11 把 return await 用在 try 之外的地方,多产生一次微任务而毫无收益
陷阱 12 在模块顶层直接 await 而不确认运行环境是否支持顶层 await

几个需要展开说的点

关于“忘记 await”:这类 bug 最麻烦的地方在于它不会报错,只会静默地产生错误结果。使用 ESLint 的 require-await 与 no-floating-promises(需要 TypeScript 类型信息)规则,可以自动拦截绝大多数此类问题。TypeScript 中,忘记 await 会得到一个 Promise<T> 类型,赋给 T 类型的变量时编译器会直接报错。

关于 return await:在 try/catch 内部,return await 是必要的——只有真正 await 过,这个 catch 才能捕获到拒绝。但在 try 之外,return await fn() 与 return fn() 的结果完全一致,而后者少一次微任务调度。因此有一条常见建议:只在 try 块内使用 return await。

关于顶层 await:ES 模块中允许在顶层直接使用 await,这让“模块加载完成即数据就绪”成为可能。但它有代价:所有依赖该模块的模块都会被阻塞,构建工具需要特殊处理,且不能在 CommonJS 中使用。它适合配置加载、数据库连接这类真正的初始化场景,不适合作为常规的异步写法。

危险写法
async function init() { const cfg = await loadConfig(); }
调用 init() 时忘记 await,函数返回的 Promise 被丢弃,配置尚未加载代码就已经继续往下跑。
安全写法
await init(); 或 init().catch(handleError);
要么等待它完成,要么显式处理它的失败,绝不让 Promise 悬空。

十、调试与性能:让异步代码可观测

异步代码的调试难度天然高于同步代码,因为调用栈会被 await 打断。好在现代工具已经提供了不少支持。

异步调用栈追踪

Chrome DevTools 默认开启“Async”调用栈,能在断点处显示完整的异步调用链:从最外层的 await 一路追溯到最初的调用点。如果发现堆栈不完整,检查 DevTools 右上角设置中的“Async stack traces”开关是否被关闭。

在 Node.js 中,可以用 --async-stack-traces 参数(新版默认开启)获得同样的能力。

给异步链路命名

匿名 async 箭头函数在性能面板中会显示为 (anonymous),排查时非常难以辨认。给关键异步函数起名字,能显著提升火焰图的可读性:

// 难辨认
items.map(async item => { ... });

// 可辨认
items.map(async function saveItem(item) { ... });

测量真实耗时

const t0 = performance.now();
const data = await loadAll();
const t1 = performance.now();

console.log(`加载耗时 ${(t1 - t0).toFixed(1)}ms`);

// 更精细:用 Performance API 打点
performance.mark('load-start');
await loadAll();
performance.mark('load-end');
performance.measure('load', 'load-start', 'load-end');

不要在主线程上做重活

async/await 只解决“等待”的问题,不解决“计算”的问题。一段耗时 200ms 的同步循环,即使写在 async 函数里、即使前面有 await,执行时依然会完全阻塞主线程,掉帧、卡顿一个都不会少。

操作 是否阻塞主线程 解决方向
await fetch() 不阻塞 本身就是异步,放心使用
大数组 sort / filter 阻塞 分片处理或移入 Web Worker
JSON.parse 巨型字符串 阻塞 流式解析,或 Web Worker
大规模 DOM 批量写入 阻塞 使用 DocumentFragment 一次性插入
密集的正则回溯 阻塞 优化正则,或加超时保护
加密 / 压缩计算 阻塞 Web Worker + postMessage

分片让出主线程

如果实在无法使用 Worker,可以把长任务切成小块,每块之间让出一次主线程,让浏览器有机会渲染:

async function processInChunks(items, chunkSize = 500) {
  for (let i = 0; i < items.length; i += chunkSize) {
    const chunk = items.slice(i, i + chunkSize);
    process(chunk);

    // 让出主线程,浏览器可以插入渲染
    await new Promise(r => setTimeout(r, 0));
  }
}

这里用 setTimeout 而不是 await Promise.resolve() 是有意的:微任务会在当前宏任务里被连续清空,无法让出渲染机会;只有宏任务才能让浏览器真正喘一口气。如果需要更精确的控制,可以使用 requestIdleCallback 或 scheduler.yield()。

十一、Async/Await 检查清单与代码基线

把全文的结论浓缩成一份可以贴在工位上的清单。每次写完异步代码扫一眼,能挡掉绝大多数问题。

  • 每个 Promise 都有归宿:要么被 await,要么被 .catch(),绝不悬空。
  • 互不依赖的请求用并发:Promise.all / allSettled,避免无谓的串行等待。
  • 有依赖的请求明确串行:不为了“看起来快”而强行并发导致逻辑错误。
  • 批量任务加并发上限:不要让 Promise.all 一次发起上千个请求。
  • 不用 forEach + async:需要等待时改用 for...of 或 map + Promise.all。
  • 错误处理不吞异常:catch 里至少做记录、反馈、继续抛出中的一件。
  • 只包裹真正会抛错的行:避免大段 try 让失败点变得模糊。
  • 用自定义错误类型分流:不要靠字符串匹配判断错误种类。
  • 设置全局兜底:监听 unhandledrejection,把漏网错误上报。
  • 为耗时请求加超时:Promise.race 或 AbortSignal.timeout。
  • 为可取消的操作传 signal:搜索、切换页面、路由跳转时主动中止旧请求。
  • 正确处理 AbortError:主动取消不算失败,不要误报为错误。
  • loading 状态用 finally 关闭:保证成功和失败两条路径都会重置。
  • 只在 try 内使用 return await:其他地方直接 return 即可。
  • 重试要带退避和抖动:并区分可重试与不可重试的错误。
  • 高频调用要合并或去重:用批处理、缓存或 Promise 复用。
  • 重计算不要放在 async 里装作异步:该上 Worker 就上 Worker。
  • 开启 ESLint 的 no-floating-promises:让工具帮你拦截忘记 await 的问题。
  • 关键异步函数起个名字:性能分析和错误堆栈都会感谢你。
  • 用 Tab 键走一遍页面:确认加载状态下的交互行为符合预期。
// 一份可直接复用的异步工具模块

export function withTimeout(promise, ms, msg = '请求超时') {
  let timer;
  const timeout = new Promise((_, reject) => {
    timer = setTimeout(() => reject(new Error(msg)), ms);
  });
  return Promise.race([promise, timeout])
    .finally(() => clearTimeout(timer));
}

export async function pool(tasks, limit = 5) {
  const results = new Array(tasks.length);
  let cursor = 0;
  async function worker() {
    while (cursor < tasks.length) {
      const i = cursor++;
      try {
        results[i] = { status: 'ok', value: await tasks[i]() };
      } catch (err) {
        results[i] = { status: 'error', reason: err };
      }
    }
  }
  const n = Math.min(limit, tasks.length);
  await Promise.all(Array.from({ length: n }, worker));
  return results;
}

export async function retry(fn, { retries = 3, base = 300, shouldRetry } = {}) {
  let lastError;
  for (let i = 0; i <= retries; i++) {
    try {
      return await fn();
    } catch (err) {
      lastError = err;
      if (i === retries) break;
      if (shouldRetry && !shouldRetry(err)) break;
      const wait = base * 2 ** i + Math.random() * base;
      await new Promise(r => setTimeout(r, wait));
    }
  }
  throw lastError;
}

export function dedupe(cache, key, fn) {
  if (cache.has(key)) return cache.get(key);
  const p = fn().finally(() => cache.delete(key));
  cache.set(key, p);
  return p;
}

async/await 最容易被低估的地方,是它把异步代码的复杂度从语法层面转移到了设计层面。以前你花时间纠结回调怎么嵌套,现在你需要花时间思考:哪些操作可以并发、错误应该在哪个层级处理、请求在什么情况下应该被取消。

这些问题的答案没有标准模板,但它们都有一个共同的起点:清楚地知道每一行 await 到底在等什么,以及为什么必须等。当你能够回答这个问题时,异步代码就从“能跑”变成了“可靠”。