async / await 从 2017 年进入 ECMAScript 标准算起,已经被使用了将近十年。它把异步代码写得像同步代码一样直观,几乎抹平了异步编程的认知门槛。但也正因为“太顺手”,大量项目里埋着同一批问题:该并发的写成了串行、被吞掉的 rejection、forEach 里的 await 完全不生效、请求失败后整个页面白屏。语法只是入场券,真正决定代码质量的是对事件循环的理解、对错误边界的把控、以及对并发模型的取舍。本篇 35 分钟长文,从异步编程的四代演进讲起,逐层拆解 async 函数的本质、await 在事件循环中的真实行为、错误处理与并发编排的工程模式,最后给出一份可直接落地的检查清单。
一、异步编程的四代演进:从回调到 async/await
JavaScript 是一门单线程语言。这句话你可能已经听过一百遍,但它真正的含义是:同一时刻,主线程只能执行一段代码。而网络请求、文件读写、定时器这些操作的耗时从几十毫秒到几秒不等,如果它们同步执行,整个页面就会卡死——按钮点不动、滚动没反应、动画直接停摆,连浏览器都会弹出“页面无响应”的提示。
所以异步不是 JavaScript 的一个可选特性,而是它能够存活至今的根本原因。为了把异步写得顺手,这门语言经历了四代方案的迭代。
第一代:回调函数(Callback)
最早的方案是把后续逻辑打包成函数,交给异步 API 在完成后调用。这在只有一个异步操作时非常清爽:
console.log('1 秒后执行');
}, 1000);
问题出现在多个异步操作需要依次依赖的时候。第二个请求要用第一个请求的返回值,第三个要用第二个的,于是代码开始向右生长:
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 三个方法,让链式调用成为可能:
.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 库),就能把异步流程写成同步的样子:
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 落定。同样的逻辑变成:
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”在真实业务中的可读性差异。两者的行为完全等价,但维护成本相差一个数量级。
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。这一点是后面所有错误处理与并发编排的基础。
返回值如何被包装
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
Promise.resolve(值),同步返回也一样
Promise<Promise<T>>
return undefined,Promise 以 undefined 兑现
一个容易被忽略的细节:函数体的前半段是同步的
很多人以为 async 函数一调用就“整体进入异步”。这是错的。函数体会一直同步执行到第一个 await 为止,这之前的所有语句都在当前调用栈里跑完。只有遇到 await 之后,函数才会被挂起并交还控制权。
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:需要异步初始化时,用静态工厂方法代替。
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 引擎在背后做了什么。
await。此时还没有任何异步发生。await 右侧的表达式求值,把它转换成 Promise(如果不是 Promise,就用 Promise.resolve 包装)。pending 状态的 Promise 给调用者。await 处恢复。若 Promise 已兑现,await 表达式的值就是兑现值;若被拒绝,则在此处抛出异常。return,返回值被用来兑现最初返回的那个 Promise,所有 then 回调按序进入微任务队列。await 的拒绝)都会让最初返回的 Promise 进入 rejected 状态。若无人 catch,触发全局 unhandledrejection。微任务优先于宏任务
事件循环的每一次迭代大致是:执行一个宏任务 → 清空所有微任务 → 渲染 → 取下一个宏任务。Promise 的回调属于微任务,setTimeout 属于宏任务。这解释了下面这个经典输出顺序:
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 同步结束');
注意第 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 捕获,也会沿着调用栈向上传播。
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 精确分流:
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:
const user = await fetchUser();
const orders = await fetchOrders();
const coupons = await fetchCoupons();
// 并发:300ms
const [user, orders, coupons] = await Promise.all([
fetchUser(),
fetchOrders(),
fetchCoupons(),
]);
点击下面的按钮,直观感受一下两者的时间差。三条轨道代表三个接口调用,每个耗时 300ms。
四个组合方法的适用场景
Promise 提供了四个静态方法用于编排多个 Promise,它们的行为差异很大,选错会导致逻辑错误或性能损失。
| 方法 | 成功条件 | 失败条件 | 典型场景 |
|---|---|---|---|
Promise.all |
全部兑现 | 任意一个拒绝即整体拒绝 | 多个接口都成功才能渲染页面 |
Promise.allSettled |
永远兑现 | 永不拒绝 | 批量操作,需要逐条汇报成败 |
Promise.race |
第一个落定(成功或失败) | — | 超时控制、多源竞速 |
Promise.any |
第一个成功 | 全部失败 | 多个镜像源取最快可用 |
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 个连接,瞬间打满并发限制、阻塞其他请求,甚至被服务端限流。控制并发上限是生产环境的必备技能:
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 个任务在执行,且所有任务都会被处理。
依赖关系决定并发结构
并不是所有“看起来无关”的请求都真的无关。判断标准很简单:后一个请求的参数是否依赖前一个请求的结果。如果不依赖,就并发;如果依赖,就必须串行。混在一起时,可以分层并发:
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 { 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() 在较新的浏览器中可以直接生成一个“到期自动中止”的信号:
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 从调用入口一直传递到最底层的请求:
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 回调
items.forEach(async (item) => {
await save(item);
});
console.log('全部完成'); // 会先打印
原因在于 forEach 的实现:它只是对每个元素调用一次回调,并不关心回调返回什么。回调返回的 Promise 被直接丢弃,forEach 也就无法等待。而且所有回调是同时被调用的,等于变相并发(但没有并发控制)。
三种正确的写法
for (const item of items) {
await save(item);
}
console.log('全部完成');
// 2. for await...of:处理异步可迭代对象
for await (const chunk of stream) {
console.log(chunk);
}
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 之上,解决的是生产环境里的高频问题。
模式一:带指数退避的重试
网络抖动导致的失败,重试往往就能解决。但固定间隔的重试会在服务端压力大时雪上加霜,指数退避是更稳妥的做法:
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,可以让并发的重复请求共用同一个结果:
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 中移除,这样“进行中”和“已完成”的请求被清晰地区分开,缓存不会无限增长。
模式三:带过期的缓存
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;
};
}
模式四:节流式批量提交
高频触发的事件(滚动、输入、点击)如果每次都发请求,服务端会直接崩溃。把短时间内的多次调用合并成一次批量请求,是标准解法:
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);
});
};
}
模式五:优雅的加载状态管理
真实的业务函数往往需要同时处理“加载中”“成功”“失败”三种状态。用一个通用的包装器可以把这套样板逻辑抽出来:
setLoading(true);
try {
return await fn();
} finally {
setLoading(false); // 成功失败都会关闭 loading
}
}
// 使用
const data = await withLoading(
v => (loading.value = v),
() => fetchData()
);
finally 在这里的作用是不可替代的:无论请求成功还是失败,加载状态都会被关闭。用 then + catch 各写一遍,很容易漏掉其中一条路径,导致 loading 永远转下去。
九、十二个高频陷阱清单
下面这些坑,几乎每一个都在真实项目里出现过。逐条对照检查,能省下大量排查时间。
await,拿到的是一个 Promise 而不是值,后续计算全部变成 NaN 或 [object Promise]
forEach 里用 async 回调,以为会等待,实际所有回调同时启动且无法等待
catch 块吞掉错误,页面表现为“点了没反应”,排查时毫无线索
async,或者试图 new 一个 async 函数
await,白白浪费几倍加载时间
Promise.all 处理可能部分失败的批量任务,一个失败导致全部结果丢失
await 发请求,几百个元素串行执行,页面卡死
await 会阻塞主线程,把耗时同步计算放在 await 之后以为能“让出线程”
AbortError,把用户主动取消搜索也当成请求失败上报
async 函数里用 this 但写成普通函数回调,导致 this 丢失
return await 用在 try 之外的地方,多产生一次微任务而毫无收益
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 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,可以把长任务切成小块,每块之间让出一次主线程,让浏览器有机会渲染:
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 到底在等什么,以及为什么必须等。当你能够回答这个问题时,异步代码就从“能跑”变成了“可靠”。