async/await 让异步代码看起来像同步代码,但也让错误更容易被忽略:一个没接住的 reject、一个没 await 的 Promise、一次被空 catch 吞掉的异常,都可能让程序静默失败------页面没反应、数据没写入、日志里却什么都没有。本文讲 6 个最常见的坑和对应的稳妥写法。
1. 先立规矩:await 要放在 try 里面
async 函数里抛出的错误,本质上是返回的 Promise 被 reject。如果 await 外面没有 try/catch,错误会沿着调用链向上传递,直到没人处理。
```js
async function loadUser(id) {
try {
const res = await fetch(`/api/users/${id}`);
if (!res.ok) {
throw new Error(`请求失败:${res.status}`);
}
return await res.json();
} catch (err) {
console.error('加载用户失败', err);
throw err; // 别在这里悄悄吞掉,让调用方决定怎么处理
}
}
```
注意两点:第一,fetch 本身只在网络失败时 reject,HTTP 404、500 不会自动抛错,所以要手动检查 res.ok;第二,catch 里如果只是打印日志又不重抛,调用方会误以为函数成功了。
2. 最容易漏的:没有 await 的 Promise
下面这段代码看起来没问题,实际上 try/catch 根本抓不到错误:
```js
// 错误写法:save() 返回的 Promise 没人 await,也没人 catch
async function handleSubmit(data) {
try {
save(data); // 漏了 await
showToast('已保存');
} catch (err) {
showToast('保存失败');
}
}
```
save() 内部 reject 时,这个 Promise 变成无人处理的 rejected Promise,页面却已经提示"已保存"。修复方法很简单:要么 await 它,要么显式接住它。
```js
async function handleSubmit(data) {
try {
await save(data);
showToast('已保存');
} catch (err) {
showToast('保存失败');
}
}
```
一条可以贴在工位上的规则:创建 Promise 就要立刻决定它的归属------await、return,或者 .catch(),不要让它"裸奔"。
3. 并行任务:Promise.all 和 Promise.allSettled 别用反
Promise.all 有一个失败,整个结果就失败,其他任务的结果也拿不到;适合"缺一个就不行"的场景,比如同时拉取用户信息和权限,缺了谁都不能渲染。
```js
try {
const user, permission = await Promise.all([
fetchUser(id),
fetchPermission(id),
]);
render(user, permission);
} catch (err) {
renderError('关键数据加载失败');
}
```
如果任务之间彼此独立,比如批量给 10 个用户发通知,某个失败不应该拖垮全部,就用 Promise.allSettled,事后再逐个检查 status:
```js
const results = await Promise.allSettled(users.map((u) => sendNotice(u)));
const failed = results.filter((r) => r.status === 'rejected');
if (failed.length > 0) {
console.error('部分通知发送失败', failed);
}
```
选错组合的代价很具体:该用 allSettled 的地方用了 all,一次偶发失败会让整批任务白跑;该用 all 的地方用了 allSettled,后续代码又可能在缺数据的情况下继续跑。
4. 循环里的错误:一次失败不要拖垮整批
用 for...of 顺序处理时,把 try/catch 放在循环体内,单个失败只跳过当次,不中断整批:
```js
for (const file of files) {
try {
await upload(file);
} catch (err) {
console.error(`上传失败:${file.name}`, err);
// 记录失败项,继续处理下一个
}
}
```
如果把 try/catch 包在循环外面,第一个失败就会直接跳出循环,后面的文件一个都不会处理。反过来,如果追求速度而用 map + Promise.all,也要先想清楚:这批任务是不是允许一个失败就全部作废。
5. 不要写空 catch,也不要只打印日志就当处理完了
这两种写法在代码评审里都应该被拦下来:
```js
try {
await syncData();
} catch (err) {} // 坏:错误被彻底吞掉,排查时无从下手
try {
await syncData();
} catch (err) {
console.log(err); // 不够:用户看到的是"没反应",而不是"同步失败,请重试"
}
```
一个可用的错误处理至少要做到三选一:重抛给调用方、返回一个调用方能区分的失败结果、或者给用户一个可操作的反馈并上报日志。只打印日志,等于把问题留给下一个排查的人。
6. 给异步操作加超时:别让请求无限挂起
网络请求可能既不成功也不失败,只是长时间挂起。结合 AbortController 和 Promise.race 可以给它一个明确的上限:
```js
async function fetchWithTimeout(url, ms = 5000) {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), ms);
try {
const res = await fetch(url, { signal: controller.signal });
if (!res.ok) throw new Error(`请求失败:${res.status}`);
return await res.json();
} finally {
clearTimeout(timer); // 无论成功失败,都要清理定时器
}
}
```
这里 finally 的作用很关键:请求正常返回后如果不清理定时器,定时器仍会在后台触发一次 abort,虽然对已完成请求无害,但属于不必要的副作用。
发布前自查清单
-
每个 await 是否都在 try/catch 的保护范围内?
-
有没有创建后既没 await、又没 return、也没 .catch() 的 Promise?
-
并行任务用的是 all 还是 allSettled,是否符合"缺一不可还是彼此独立"?
-
catch 里是重抛、返回失败结果,还是给了用户反馈?有没有空 catch?
-
可能挂起的请求有没有超时和清理逻辑?
async/await 的错误处理没有魔法,核心就一句话:让每一个 Promise 都有明确的归属,让每一次失败都有明确的去向。做到这两点,静默失败就会少很多。