我一直在思考如何处理前端项目中的错误,尤其是接口请求时的「业务错误」,从最开始在 axios 响应拦截器中判断并提示;到将错误从拦截器层层向上抛,在最顶层使用 addEventListener("unhandledrejection", () => {}) 统一处理;再到现在回归最原始的 if (error) {} 判断,下面说说我的演进过程,以及我现在认为最好的错误处理方案,给有类似需求的同学一个参考。
最初-冗余的错误判断
刚开始写代码时是这样处理业务错误的
handleClick = () => {
const resp = await submitValues(values);
if (resp.code !== 0) {
// 请求失败,提示错误信息
message.error(resp.msg);
return;
}
// 请求成功
console.log(data);
};
接口请求返回 resp,结构是
{ code: number; msg: string; data: unknown };请求成功,则code为 0,否则非 0,并且msg给出具体的错误提示。
如果一个页面的请求比较多,这类模板代码就会非常多,于是演化成了这样。
统一-在响应拦截器处理错误
这可能是目前大部分同学正在使用的方案,好处是不再有过多的模板代码,业务代码简洁干净。
interceptors.response.use(() => {
// 判断 code 非 0, antd message.error(msg); 否则 return Promise.resolve();
}, { global: false });
handleClick = () => {
const data = await submitValues(values);
// 请求成功
console.log(data);
};
如果请求 code 非0,在响应拦截器就被处理掉,不会出现在业务代码。但是缺点如下
- 当希望该请求静默失败,或自定义处理错误时麻烦,需要额外拓展,比如支持在请求时指定「不处理错误」
- 请求方法与视图耦合,不便于复用。我移动端 PC 端逻辑一样,一个用 antd 一个用 antd-mobile,结果请求方法依赖 antd
但是大部分同学应该不存在「复用」的需求,所以 2 属于一个伪问题,该方案依然能满足需求,这也是该方案一直盛行的原因。
进阶-在最顶层而非最底层统一处理
如果将前端代码划分层级,那应该是这样的
视图层 <- 模型层 <- 服务层 <- 基础库层
基础库层即对 axios 进行封装,暴露出的 request 方法;服务层为调用 request 方法请求后端数据并返回;模型层大概率没有,或者是 redux、dva 这类状态管理库;视图层就是写 jsx 的地方了,在这里交互,渲染状态。
前面提到的「响应拦截器」就位于「基础库层」,如果在该层抛出一个 Promise.rejct(),会被上一层所捕获(更确切的说法是被调用基础库的方法捕获),如果上一层没有 try catch 处理,又会继续向上抛,直到视图层,如果视图层仍没有 try catch,那么就会到达全局,在控制台打印一个 Uncaught Error
借助这种类似「冒泡」的机制,既可以在顶层监听 unhandledrejection 实现统一处理,还可以在任意一层 try catch 自定义处理,同时底层库不再与视图耦合,具备更强的复用性。
// 基础库层
interceptors.response.use(() => {
// 判断 code 非 0, throw Promise.reject(new Error(msg)); 否则 return Promise.resolve();
}, { global: false });
export function request() {}
// 服务层
function submitValues(values) {
return request.post('/api/user', values);
}
// 模型层
class UserAdding {
submit() {
return submitValues(this.values);
}
}
// 视图层
handleClick = () => {
const data = await userAddingStore.submit();
// 请求成功
console.log(data);
}
// main.tsx 或者一些根文件
window.addEventListener('unhandledrejection', (error) => {
if (error.message) {
message.error(error.message);
}
});
当我实现该方案时是非常兴奋的,以为找到了错误处理的最终方案,在实践中,它的确满足了我的预期,在 PC 与移动端间复用了基础库层、服务层与模型层;默认有错误提示,try catch 可静默请求或自定义错误展示。
但是仍存在一些问题,一些库如 antd 中的表单 form.validateFields 也会抛出 Promise.reject,导致错误收集平台将这类错误也收集了,或许我应该给业务错误一个标志来区分。
这个方案在我们项目运行了 2 年多时间。
学习-类似的思路更好的实现
rust 函数支持返回一个 Result,它包含了成功与失败的可能性,需要分别处理,比如
let result = do_something();
match result {
Err(_) => {
// 请求错误
},
Ok(data) => {
// 请求成功
}
}
或者用宏来简化写法
let data = do_something().unwrap();
// 请求成功
unwrap 可以理解成 判断 result 是否是失败,如果失败,就返回错误,否则继续往下执行
也是类似「冒泡」机制。在更上一层(调用该代码的函数),继续 unwrap 或者对错误进行处理。
参考该方案,我实现了如下代码
type Result<T> = T extends null
? {
error: Error;
data: null;
}
: {
error: null;
data: T;
};
/** 构造一个结果对象 */
export const Result = {
/** 构造成功结果 */
Ok: <T>(value: T) => {
return {
data: value,
error: null,
} as Result<T>;
},
/** 构造失败结果 */
Err: (message: string) => {
return {
data: null,
error: new Error(message),
} as Result<null>;
},
};
所有函数的返回值,都是由 Result.Ok 或 Result.Err 返回的
function fetchUser() {
if (Math.random() < 0.5) {
return Result.Ok({ name: "litao" });
}
return Result.Err("请求超时");
}
(async () => {
const result = fetchUser();
if (result.error) {
console.log(result.error.message);
return;
}
console.log(result.data.name);
})();
看似回到最开始的方案,每次请求都要进行判断,多出很多模板代码,但在类型上却更加正确。上面代码如果不先判断 result.error,直接访问 result.data.name 会有错误提示
如果能再实现类似 unwrap 的方法,这个问题我认为是完美地解决了。
并且这个方案有个 js 版简化写法
/**
* @template T
* @typedef {[T, T extends null ? Error : null]} RequestResult<T>
*/
function fetchUser() {
if (Math.random() < 0.5) {
// return Result.Ok({ name: 'litao' });
/** @type {RequestResult<{ name: string }>} */
const result = [{ name: 'litao' }, null];
return result;
}
/** @type {RequestResult<null>} */
const result = [null, new Error('名称已存在')];
return result;
}
(() => {
const [data, error] = fetchUser();
if (error) {
return;
}
console.log(data.name);
})();
同样的如果不先处理 error,下面 data.name 就会提示 data 可能是 null。该方案需要开启 strictNullChecks。
这个方案我正在项目中试行,后续有其他想法或问题会继续更新,欢迎讨论。