1. 错误处理的三层防线:为什么你的 try/catch 总是不够用
几年前我在代码评审里看到过一个非常典型的场景:一整个提交里,所有接口请求的代码都长成同一个模样——整段的 try/catch,然后 catch 块里统一弹个 Toast 提示"网络异常",最后在 finally 里关掉 loading。代码能跑、功能能通,但一旦接口报错,业务方就分不清到底是参数不对、后端 500 还是权限过期;排障的人得去翻后端的日志,前端这边只能看到一条笼统的"网络异常"。这种写法也不是错了,但它把所有错误都压在同一层处理,属于典型的"把 catch 当垃圾桶"。
这一节我不想重复那些老生常谈的"try/catch 基本用法",而是按我自己的经验,把 async/await 的错误处理拆成三层防线来讲。
1.1 第一层防线:让异常在"最合适的地方"被捕获
先说结论:永远不要在每个 await 后面都手动加 try/catch,也不要只在一个顶层大函数里包一个 try/catch 处理所有事情。 正确的做法是,让异常在"它最合适被处理的那一层"被捕获。
什么叫最合适?我们看一个具体场景。假设你有一个提交表单的流程:先校验表单,再调接口,成功之后跳转页面。代码天然可以分成三层:
- 点击事件处理函数(UI 层):判断"提交成功了吗?成功了就跳转"
- 表单提交函数(业务层):负责"调接口、拦异常、给用户反馈"
- API 请求函数(数据层):只负责"发请求、拿数据,别管业务"
大多数人的写法是把三个方法写成一个,然后在最外层 try/catch:
javascript复制async function handleSubmit() {
try {
const data = await api.createOrder(params);
if (data.code === 0) {
router.push('/order-list');
} else {
message.error(data.msg);
}
} catch (e) {
message.error('网络异常');
}
}
这段代码的问题在于:如果 createOrder 失败是因为后端业务上的"库存不足",它抛出的应该是一个业务错误,前端应该给用户提示"库存不足";如果是因为断网,那是网络错误,提示"网络异常"。这两种错误应该是在 createOrder 这个业务函数内部就拦截并转换掉的,而不是一路抛到最外层让 UI 层来统一猜。
所以我推荐的拆分方式是:
javascript复制// 数据层:只管请求和数据解构
async function fetchCreateOrder(params) {
const resp = await request('/api/order', { method: 'POST', body: params });
return resp.data;
}
// 业务层:负责业务错误转换与反馈
async function submitOrder(params) {
try {
const data = await fetchCreateOrder(params);
if (data.code !== 0) {
message.error(data.msg);
return null;
}
return data;
} catch (e) {
message.error('网络异常,请稍后重试');
return null;
}
}
// UI 层:只关心结果
async function handleSubmit() {
const data = await submitOrder(params);
if (data) {
router.push('/order-list');
}
}
注意这里我把返回值的约定改成了"失败返回 null",而不是"失败抛异常"。这不是什么高深技巧,但它在团队协作里非常有用——调用方不需要记得"这里会抛什么异常",只需要判断返回值。当然,业务层到底用 try/catch 转换错误还是直接向上抛,要看你们的团队约定,没有绝对正确的答案。但有一条原则是通用的:一个函数只用一种错误表达方式,别一会儿抛异常一会儿返回错误码,那样调用方会被逼疯。
1.2 第二层防线:兜底错误处理,永远不该缺席
第三层防线的名字叫"兜底"。不管你在业务层处理得多细致,总有漏网之鱼:比如 JSON.parse 解析后端返回的数据时抛错,比如某个第三方 SDK 内部出现未处理的 rejection。这时候如果没有一个全局兜底,用户看到的就是页面无响应、按钮一直转圈,而控制台里安静地躺着一条 unhandled promise rejection。
至少要做两件事:
第一,在浏览器环境给 window 注册 unhandledrejection 事件;第二,如果你们用 axios,给 axios 实例配置一个全局的响应拦截器,在这里统一处理 HTTP 状态码异常(401 跳登录、403 提示无权限、5xx 提示服务器开小差)。这两件事解决的问题不一样:前者是兜住"代码里没 catch 住的错误",后者是兜住"HTTP 层的基础错误"。
javascript复制// 兜底 1:未处理的 Promise rejection
window.addEventListener('unhandledrejection', (event) => {
const reason = event.reason;
console.error('捕获到未处理的 Promise 异常:', reason);
// 上报监控系统
});
// 兜底 2:axios 响应拦截器里的网络层错误
http.interceptors.response.use(
(response) => response.data,
(error) => {
if (error.response?.status === 401) {
redirectToLogin();
}
return Promise.reject(error);
}
);
经常有人问,全局兜底要不要弹错误提示?我的建议是:全局兜底只是最后的防线,不要依赖它给用户反馈。 因为到了这一步,错误信息往往已经丢失了上下文,用户能看到的只有"网络异常"四个字。真正的错误反馈应该发生在业务层,全局兜底只负责"别让页面白屏、别让状态卡死、把错误上报给监控"。
1.3 return await 的坑:为什么 lint 规则建议你删掉它
有一个 eslint 规则叫 no-return-await,很多人第一次看到时会懵:我都 return await foo() 了,这不是很正常吗?删掉 await 只写 return foo() 难道不会导致 try/catch 捕获不到异常吗?
这其实是很多人的误区。return foo() 时,如果 foo 是一个 async 函数(或者返回 Promise),那么它的 rejection 会被外层函数的调用方捕获到,和你写 return await foo() 的效果完全一样。唯一的区别在于性能:return await 会多创建一层微任务,让当前 async 函数多等一拍才返回。
但是在 try/catch 里,情况会变化:
javascript复制async function bad() {
try {
return await doSth(); // 这里能抓住 doSth 的异常
} catch (e) {
// 正常捕获
}
}
async function good() {
try {
return doSth(); // 注意:这里缺少 await 的话,doSth 的异常不会被捕获!
} catch (e) {
// 捕获不到 doSth 的异常
}
}
所以完整版本的建议是:在 try 块内,要么 return await,要么不要 return,直接赋值给变量再 return。 在 try 块外,直接 return fn()。别小看这个细节,它直接影响你的异常能不能被正确捕获。一旦你和同事之间关于"到底该不该写 await"没有达成一致,代码里就会出现两种风格的混用,排查问题时非常痛苦。
我把常见的几种写法和效果列个表,方便一目了然:
| 写法 | try 内能否捕获异常 | 额外微任务 | 结论 |
|---|---|---|---|
return fn()(try 块外) |
调用方捕获,当前函数不捕获 | 无 | 推荐 |
return await fn()(try 块内) |
能捕获 | 有 | 推荐 |
return fn()(try 块内) |
不能捕获 | 无 | 不推荐 |
const result = await fn(); return result; |
能捕获 | 无 | 推荐 |
1.4 顺带聊聊 PHP 的错误处理:异常之外的另一种哲学
既然热词里提到了 PHP 错误处理,这里顺带说一句题外话。PHP 8 之前的错误处理非常分裂:一部分错误是 Error,需要 set_error_handler 去拦截;另一部分是 Exception,可以用 try/catch 捕获;还有一部分是致命错误,你根本拦不住。这导致很多老 PHP 项目的代码里混杂着 if ($result === false)、set_error_handler、try/catch 三种错误处理风格。
PHP 8 之后,大部分错误都被纳入 Throwable 接口体系,TypeError、ValueError 这些都成了异常家族的一员,可以用统一的 try/catch 捕获。这个演进方向其实和前端 async/await 的错误处理思路是一样的:先统一异常类型,再统一处理入口,最后才是对用户友好地提示。 如果你是从 PHP 转前端的,理解这条演进路径,会对"为什么 async/await 要把异常当成一等公民"有更深的感觉。我在后面第 4 节还会提到团队规范里如何借鉴这个思路统一错误类型。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 从嵌套地狱到扁平化:串行流程、并行流程与条件分支的写法
如果说错误处理是 async/await 的"安全性"问题,那嵌套就是"可读性"问题。异步代码一多,业务一复杂,嵌套就像雪球一样滚起来。这一节我要分享的不只是"不要嵌套",而是"遇到什么样的流程该用什么样的写法"。
2.1 串行流程的三种写法对比:为什么 await 比 then 优雅
先看一个常见的串行流程:登录之后拿用户信息,再拿用户订单列表。很多早期项目会写成这样:
javascript复制login(account)
.then(user => {
return getUserInfo(user.id);
})
.then(info => {
return getOrderList(info.id);
})
.then(orders => {
renderOrders(orders);
})
.catch(err => {
handleLoginError(err);
});
这种写法还没到"回调地狱"的程度,但它的信息密度很差:每一层都只干了一点事,却占了整整一行;中间变量(user、info)的类型和名字都要靠开发者自己心算。改成 async/await 之后:
javascript复制const user = await login(account);
const info = await getUserInfo(user.id);
const orders = await getOrderList(info.id);
renderOrders(orders);
三行直落的代码,每一步的产物都有变量接住,阅读顺序就是执行顺序,心智负担小得多。这就是 async/await 的核心价值:把异步流程重新映射回我们最熟悉的人类线性思维。
但注意,我这里的例子是"每一步都要用上一步的结果"——这种天然的串行依赖,没有更好的写法。如果你发现一段 async 函数里连续写了五六个 await,而且每一步之间没有数据依赖,那就要警惕了:这些请求本可以并行,你却在串行等待。这正好引到下一小节。
2.2 并行请求用 Promise.all:别用 for 循环 await
并行请求是另一个高频场景:页面上要同时展示用户信息、公告列表、系统配置。很多人图省事,会写 for 循环:
javascript复制const ids = [1, 2, 3, 4, 5];
const results = [];
for (const id of ids) {
const data = await fetchDetail(id); // 串行!总共耗时为每个请求耗时之和
results.push(data);
}
这么写最大的问题不是错,而是慢。每个请求都在等上一个请求完成才发出,5 个请求如果每个都耗时 200ms,总耗时就是 1000ms;而 Promise.all 并发发出,总耗时只有 200ms 左右。数据无依赖时,我推荐用 Promise.all:
javascript复制const [userInfo, noticeList, systemConfig] = await Promise.all([
fetchUserInfo(),
fetchNoticeList(),
fetchSystemConfig(),
]);
这里有一个容易忽略的点:Promise.all 是"全有或全无"——只要其中一个请求失败,整个 Promise.all 就 rejected,其他成功的请求结果也全部丢弃。这在某些场景下不符合需求,比如三个请求里公告接口挂了,但用户信息已经拿到了,页面还是可以把用户信息先渲染出来。这时候可以用 Promise.allSettled,它会等所有请求都结束,返回每个请求的状态和结果,不会因为某一个失败而中断。
javascript复制const [userResult, noticeResult, configResult] = await Promise.allSettled([
fetchUserInfo(),
fetchNoticeList(),
fetchSystemConfig(),
]);
if (userResult.status === 'fulfilled') {
renderUser(userResult.value);
}
if (noticeResult.status === 'fulfilled') {
renderNotice(noticeResult.value);
}
Promise.allSettled 在 Node 18+ 和现代浏览器里都支持,用起来不需要额外 polyfill。团队规范里我通常建议一条原则:关键数据用 all,非关键数据用 allSettled,绝不用 for 循环串行 await。 这条规则执行起来非常简单,评审时一眼就能看出问题。
2.3 条件分支:提前 return 与状态机之间的平衡
异步流程里的条件分支是最容易写出嵌套的地方。举个例子:用户点击"领取优惠券",要先判断是否登录,再判断活动是否开始,再判断是否已领取。新手写法:
javascript复制async function handleCoupon() {
if (isLogin()) {
const activity = await getActivity();
if (activity.started) {
const record = await getCouponRecord();
if (!record.received) {
await receiveCoupon();
showSuccess();
} else {
showError('您已领取');
}
} else {
showError('活动未开始');
}
} else {
showError('请先登录');
}
}
这种嵌套其实还算轻,三级 if 而已,但你已经能闻到"箭头形代码"的味道了——括号一层套一层,阅读时得来回跳。优化后的写法是"卫语句 + 提前 return":
javascript复制async function handleCoupon() {
if (!isLogin()) {
showError('请先登录');
return;
}
const activity = await getActivity();
if (!activity.started) {
showError('活动未开始');
return;
}
const record = await getCouponRecord();
if (record.received) {
showError('您已领取');
return;
}
await receiveCoupon();
showSuccess();
}
每一个条件判断都是一道"关卡",不满足就直接打回,通过就继续往下走。这种写法的好处非常明显:正常路径是一条没有缩进的直线,异常路径全部横向 return。 以后要加一个新的判断条件,只要继续往函数后面追加一个 if 块就行,不需要动任何现有逻辑。
遇到更复杂的流程(比如一个页面有启动、加载中、成功、失败、空数据、离线等多种状态),单纯用 if/else 会越写越乱,这时候我建议引入状态机或者一个简单的事件驱动模型。但那是另一个话题了,就本项目规范而言,记住一句:条件分支永远优先考虑提前 return,而不是往深处嵌套。 这条规则对任何语言都成立,不只是 JavaScript。
3. 防重复请求:竞态、抖动与幂等性的实战防线
防重复请求是我在 async/await 规范里最想重点写的一节,因为它在实际项目里发生的频率远超大多数人想象,而且很难用单元测试一次性覆盖住。我见过太多"按钮快速点了两下,后端生成了两条订单"的生产事故。很多团队把锅甩给后端幂等性,但前端其实可以从多个层面把它拦住。
3.1 重复请求的来源:双击、重试、组件卸载,你是怎么踩坑的
重复请求通常有三个来源。
第一类是用户操作层:按钮没有做 loading 处理,用户手快连点两下,表单被提交两次。这是最普遍的一种,也是最好解决的。
第二类是业务重试层:请求超时了、网络抖动,代码在 catch 里自动重试,但重试前没有判断上一次请求是否已经失败并返回了数据。这种情况下,用户看到的是请求卡了很久,然后突然变成了两条数据。
第三类是组件生命周期层:用户进入一个页面,请求还没返回,就切到了别的路由。旧页面组件虽然卸载了,但它的 async 函数还在继续执行,等数据回来时 setState 一个已经不存在的组件。React 18 之前会直接在控制台报一个"Can't perform a React state update on an unmounted component"的警告,虽然不影响功能,但说明你有一半的代码在做无用功。
后两类问题都有一个共同的技术本质:异步操作的执行结果已经与页面当前的状态脱节了。所以只解决"点击一下只发一个请求"是不够的,还得解决"请求发出之后,结果回来之前,怎么管理它的生命周期"。
3.2 第一道闸门:按钮 loading 与请求状态锁
最简单也最有效的防重复手段,就是按钮 loading。这个每个前端都会写,但很多人只做了视觉上的 loading,没有做逻辑上的锁。比如:
javascript复制const [submitting, setSubmitting] = useState(false);
async function handleSubmit() {
if (submitting) return; // 逻辑锁,挡住连点
setSubmitting(true);
try {
await submitOrder(params);
} finally {
setSubmitting(false);
}
}
注意两个细节:if (submitting) return 是逻辑锁,它和按钮的 disabled={submitting} 是两回事。按钮 disabled 是防用户,逻辑锁是防代码里的其他入口(比如回车键提交、脚本调用),两者都要有。另外,重置 submitting 的动作一定要放在 finally 里,不然一旦请求抛出异常,锁就永远解不开了,用户会被永久卡在"提交中"状态。
如果是原生 JavaScript 环境,没有 setState 这种响应式状态,可以用一个模块级的 flag:
javascript复制let isSubmitting = false;
async function handleSubmit() {
if (isSubmitting) return;
isSubmitting = true;
try {
await submitOrder(params);
} finally {
isSubmitting = false;
}
}
这个 pattern 几乎不需要成本,却能把 80% 的双击问题挡在门外。
3.3 第二道闸门:请求竞态与最后一次结果优先
如果说双击问题算是"粗粒度重复",那竞态就是一个更隐蔽的"细粒度问题"。典型场景是搜索框:用户输入关键字,每输入一个字符就触发一次搜索请求。网络状况不稳定时,先发的请求可能后返回,后发的请求反而先返回。最后页面上显示的结果,可能不是用户最后一次输入对应的结果。
解决竞态的核心思想是:不追求"请求不重复",而是追求"过期的结果不生效"。 我常用的两种方案:
方案 A:请求序号对比。每次发起请求时把计数器的当前值记下来,请求返回后检查这个值是否还是最新的,不是就丢弃。
javascript复制let searchSeq = 0;
async function handleSearch(keyword) {
const seq = ++searchSeq; // 先递增,得到本次请求的序号
const results = await fetchSearch(keyword);
if (seq !== searchSeq) return; // 如果序号已经过期,丢弃结果
renderResults(results);
}
方案 B:AbortController 真正取消上一次请求。浏览器的 fetch 支持 AbortController,可以把上一次还没返回的请求直接 abort 掉,而不是等它白白跑完再丢弃。这种做法不仅节省流量,还能避免浏览器层面对同一端口的并发连接数限制。
javascript复制let currentAbortController = null;
async function handleSearch(keyword) {
currentAbortController?.abort(); // 取消上一次请求
currentAbortController = new AbortController();
try {
const results = await fetch(`/api/search?q=${keyword}`, {
signal: currentAbortController.signal,
});
renderResults(await results.json());
} catch (e) {
if (e.name === 'AbortError') return; // 被取消的请求,不当作错误处理
throw e;
}
}
在团队项目里,我通常建议:能用 AbortController 就用 AbortController,它比序号对比更彻底;但如果你们的请求库对 abort 支持不好(比如某些老版本 axios),退而求其次用序号对比。
3.4 第三道闸门:接口层幂等与去重
前端三层防线之外,最后一道防线是后端的幂等性。前端防重复再好,也无法覆盖所有场景——比如用户提交订单请求到了后端,后端处理超时,前端自动重试了一次,结果两个请求都到达了后端。这种场景前端能做的就是利用一个业务幂等键,比如订单号、设备唯一标识等,让后端识别出"这个请求是重试的",直接返回上一次的结果。
前端的配合方式很简单:在请求 header 里带上一个请求 ID,或者在后端约定的字段里带上幂等键。很多团队会忽略这一步,觉得"前端已经做了 loading 防连点,不会重复了",这其实是不够的。真正完整的设计是:前端防交互层面,中间件防重试层面,后端防系统层面。这三层都通了,才能说你的异步请求是可靠的。
javascript复制async function submitOrder(params) {
const idempotencyKey = crypto.randomUUID();
return http.post('/api/order', params, {
headers: { 'X-Idempotency-Key': idempotencyKey },
});
}
这一段看着简单,但在"编码语法规范"里,它是优雅异步代码的最后一块拼图。没有它,你前端的 loading 做得再完美,还是会漏。
4. 规范落地的实操清单:从代码评审到团队习惯
前面几节讲的是具体写法,这一节我想站在"团队规范制定者"的角度,聊一聊如何让这些原则真正落地。规范的难点不在于"写下来",而在于"被执行"。我这里整理了一份可以直接拿去做 Code Review 检查项的清单,以及几个常见的认识误区。
4.1 可以直接写进 Code Review Checklist 的硬性规则
以下规则建议直接粘贴进团队的 Code Review 模板里,每一条都有明确的检查姿势:
-
所有 async 函数必须有错误处理路径。 检查方式:搜索
async function,看函数体内是否有 try/catch、或函数调用方是否有 catch、或函数是否有明确的返回值约定(如 null 或 Result 对象)。三者至少占其一。注意"async 函数抛异常"不算错误处理路径,因为调用方不一定会处理。 -
try 块内的 return 必须带 await。 检查方式:搜索 try 块内的
return fn()写法,如果 fn 返回 Promise,这一行必须改成return await fn()或拆成变量再 return。 -
禁止在 for/forEach/map 内直接 await。 检查方式:搜索
for、forEach里的await。如果数据之间有依赖,只能用 for...of 串行,那要在注释里说明为什么不能并行;如果无依赖,必须改为 Promise.all 或 Promise.allSettled。 -
每个"提交"类操作必须有请求锁。 检查方式:找到所有调 POST/PUT/DELETE 接口的函数,确认函数开头有没有防重复入参判断(如
if (submitting) return或if (locked) return)。这不只是针对鼠标点击,也包括键盘提交、脚本调用等其他入口。 -
竞态敏感场景(搜索、下拉刷新、tab 切换)必须处理过期结果。 检查方式:找到这些场景的请求函数,确认请求返回后有没有判断"当前触发序号是否为最新",或者是否使用了 AbortController。
-
全局兜底不能只有一个 console.error。 检查方式:查看
unhandledrejection事件绑定里,除了打印日志外,有没有上报监控系统(或至少有一个明确的人知道这个错误该反馈给谁)。
这个清单里每一条的背后,都是我们在真实项目里踩过的坑。比如第四条,我们团队曾有一个"批量导入"按钮,双击后虽然按钮变灰了,但因为回车键还能触发提交,用户按住回车不放,一口气导入了十几份重复数据。所以说,逻辑锁和视觉 loading 真的缺一不可。
4.2 容易被忽略的三个认识误区
第一个误区是"async/await 是用来替代回调的"。这种说法不准确,async/await 并没有消灭异步,它只是改变了异步的表达方式。底层仍然是 Promise、仍然是事件循环。理解了这一点,你就不会在某天遇到"为什么 await 之后代码没有按预期顺序执行"时感到困惑——因为 await 只保证当前 async 函数内部的顺序,不保证外部事件循环的时序。
第二个误区是"用 async/await 之后性能会变差"。单独看,async/await 和原生 Promise 链的性能差异在绝大多数业务场景下可以忽略不计。真正影响性能的是有没有做并行化——也就是我在 2.2 节里说的,该用 Promise.all 的时候用了 for 循环串行,这个差距才是数量级的。
第三个误区是"防重复请求是后端的事"。这个前面已经详细展开过了,前端可以做的有三道闸门:交互锁、竞态处理、幂等键。如果只靠后端做幂等,用户在前面看到的就是"转圈圈转了很久,然后提示下单成功/失败",体验已经差了。
4.3 一个综合示例:用户列表页的完整异步流程
最后用一个综合示例,把前面所有原则串起来。场景是:用户进入一个"用户管理"页面,需要同时拉取用户列表和角色字典;列表支持按关键字搜索;点击"删除用户"需要二次确认并调用删除接口,删除期间按钮禁用。
javascript复制// 数据层:直接返回解构后的数据,不处理业务错误
async function fetchUserList(params) {
const data = await http.get('/api/users', { params });
return data.list;
}
async function fetchRoleDict() {
const data = await http.get('/api/roles');
return data.dict;
}
// 业务层:搜索的竞态处理
let searchSeq = 0;
async function loadUsers(keyword = '') {
const seq = ++searchSeq;
const [list, roles] = await Promise.all([
fetchUserList({ keyword }),
fetchRoleDict(),
]);
if (seq !== searchSeq) return; // 过期结果直接丢弃
renderTable(list);
renderRoleDict(roles);
}
// UI 层:删除的防重复与错误处理
let deleting = false;
async function handleDelete(userId) {
if (deleting) return;
deleting = true;
try {
const ok = await confirmDialog('确定删除该用户?');
if (!ok) return;
await http.delete(`/api/users/${userId}`);
await loadUsers(currentKeyword);
message.success('删除成功');
} catch (e) {
message.error('删除失败,请稍后重试');
} finally {
deleting = false;
}
}
// 搜索框输入事件:重置于竞态标志
function onSearchInput(keyword) {
loadUsers(keyword);
}
这个示例里,并行请求用的 Promise.all,搜索用的序号竞态,删除用的逻辑锁和 finally 释放,错误处理在 UI 层做统一提示,没有一层 try/catch 嵌套到别的层里。我拿这个例子给团队新人做入门讲解的时候,通常只需要二十分钟,他们就能理解前面所有的规范原则。
我在实际操作中发现,团队代码质量提升最快的节点,往往不是某次大重构,而是在一次线上事故复盘之后,把上面这些原则一条条写进 Code Review 清单,然后强制执行两三个迭代。两三个迭代之后,老员工会形成肌肉记忆,新员工有章可循,异步代码的自然风格就稳定下来了。再往后,即使你不强制,也很少有人会写出嵌套地狱或者裸奔式的 async 请求了。如果你所在的项目还在为异步代码的可读性头疼,不妨从这一套小清单开始试起。
