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);
}
);
经常有人问,全局兜底要不要弹错误提示?我的建议是:全局兜底只是最后的防线,不要依赖它给用户反馈。 因为到了这一步,错误信息往往已经丢失了上下文,用户能看到的只有"网络异常"四个字。真正的错误反馈应该发生在业务层,全局兜底只负责"别让页面白屏、别让状态卡死、把错误上报给监控"。
