一、写在前面的几句唠叨
-
异常处理, 或者说错误处理的重要性
上学时, C语言考试, 结果正确是得分点, 而对于输入边界的控制, 则是满分点.
本质就是要有异常处理的意识.
而到了工作时, 也是一样. 写"正确流程"的代码, 很简单. 但想将各种已知和未知的异常都能处理好, 则非常考验水平.
-
前端项目, 无论react/vue/angular etc., 都需要对ajax进行异常处理.
-
本文讨论前端的统一异常处理, 以ajax为切入点, 延伸到其他场景.
-
本文假定读者有至少3年前端开发经验, 有处理ajax报错的经验
二、先看案例
2.1 让我们将需求一点点展开, 层层推进
假设ajax response的结构是:
// sucess的结构
const successResponse = {
code: 200,
data: {
name: 'zhangsan',
age: 30
}
}
// error 的结构
const errorResponse = {
code: 500,
message: '用户名不存在或密码错误'
}
要求:
- 当code是200时, 正常返回其中的data
- 当code是500时, 用Message组件显示该message
下面是最普通的处理
缺点: 在每个业务中, 都要处理一遍
{
let userInfo;
getUserInfo().then(res => {
if (res.code === 200) {
userInfo = res.data;
} else if (res.code === 500) {
Message.error(res.message);
}
});
}
// 有其他请求, 也需要重复类似的逻辑判断
{
xxx().then(res => {
if (res.code === 200) {
yyy = res.data;
} else if (res.code === 500) {
Message.error(res.message);
}
});
}
以上写法, 也无法应对需求变更, 如:
- response结构变更: 缩短变量名 message -> msg
- Message改为Notice
- 增加另一种逻辑, code: 400
那么接下来很容易想到interceptor.
axios等各种http库, 都支持interceptor.
// 增加 response interceptor
api.interceptors.response.use(
(response) => {
const { code, data, message } = response.data
// 简化数据结构 (根据后端返回格式调整)
if (code === 200) {
return data; // 直接返回业务数据
} else if (code === 500) {
Message.error(message); // 弹MessageBox
}
return response;
},
(error) => {
// 错误处理
if (error.response) {
// 服务器返回了错误状态码 (4xx/5xx)
const status = error.response.status;
const message = error.response.data?.message || '请求错误';
switch (status) {
case 401:
alert('未授权');
break;
case 500:
alert('服务器错误');
break;
default:
console.error(`请求错误 ${status}: ${message}`);
}
}
return Promise.reject(error);
}
);
以上设计解决了之前提出的几个问题.
但仍然存在其他问题, 如:
{
const userInfo = await getUserInfo();
// 这里, 不方便判断userInfo是获取成功还是失败了
}
继续改interceptor
const { code, data, message } = response.data
if (code === 200) {
return data;
} else if (code === 500) {
Message.error(message);
return null; // 明确返回null
}
return response;
然后业务层就可以判断了
{
const userInfo = await getUserInfo();
// 这样是可以判断了
if (!userInfo) {
return;
}
showUserInfo(userInfo);
yyy;
zzz;
}
但好像每个请求, 都要做这类判断, 因为每次请求都可以报错.
不过对于以上需求来说, 到也算是实现了, 且不是太难看.
好, 现在继续加需求, 某一个api的调用处, 希望不显示MessageBox, 而是将对应的error message显示在dom上.
简而言之, 即某处调用需要对error单独处理, 不想要通用处理 (场景B)
{
const userInfo = await getUserInfo();
// 场景A, 同上 (用于与场景B对比)
if (!userInfo) {
return;
}
showUserInfo(userInfo);
yyy;
zzz;
}
{
const userInfo = await getUserInfo();
// 场景B, 想要显示在dom上, 不想显示Message
if (!userInfo) {
// 我们会发现, MessageBox的弹出是统一的, 已经无法阻止其弹出了.
showErrorMessage(errMsg); // 且, 无法得到errorMessage
return;
}
}
好, 为了解决上述问题, 可能会想到对api设置options
首先, interceptor需要改一下
const {
headers,
config: { showMsgBox = false },
} = response;
const { code, data, message } = response.data
if (code === 200) {
return data;
} else if (code === 500) {
if (showMsgBox) {
Message.error(message);
return null;
}
return message; // 需要返回message
}
return response;
再修改下调用处
{
const userInfo = await getUserInfo({showMsgBox: false});
let errMsg;
// 需要判断result的类型
if (typeof userInfo === 'string') {
errMsg = userInfo;
}
// 场景B, 想要显示在dom上, 不想显示Message
if (errMsg) {
showErrorMessageOnDom(errMsg);
return;
}
}
以上算是实现了需求, 但很强行, 存在以下问题:
-
需要新增showMsgBox, 及相应的逻辑处理代码
-
Api response的结构根据业务不同, 返回的数据结构也不同(interceptor中, 已经有obj, null, string三种形式了)
-
业务层还是需要判断result类型
-
未来, 随着showMsgBox此类业务的增加, interceptor需要返回更多种类型, 业务层也需要做更多判断
为了不出现类型不好判断的情况, 可能需要对数据进行包装, 如下:
const {
headers,
config: { showMsgBox = false },
} = response;
const { code, data, message } = response.data
if (code === 200) {
return {type: 'data', data}; // 1
} else if (code === 500) {
if (showMsgBox) {
Message.error(message);
return null;
}
return {type: 'message', data: message}; // 2
}
return {type: 'raw', response}; // 3
以上, 算是解决了, 但无论是增加新业务, 还是修改旧业务, 对心智负担的增加还是很明显.
(Q&A)
2.2 归纳分析
好了, 为什么以上的方案, 看似每一步都在正确的路上, 为什么结果还是不如意.
为了更好的理解, 我们先看另一个知识点, 回顾下ajax从callback向promise的发展.
// Callback形式
$.ajax({
url: '/api/users',
type: 'GET',
dataType: 'json',
success: function(users) {
console.log('用户列表:', users);
},
error: function(xhr, status, error) {
console.error('AJAX Error:', error);
}
});
// Promise形式
$.get('/api/users')
.done(function(data, textStatus, jqXHR) {
console.log('Success:', data);
})
.fail(function(jqXHR, textStatus, errorThrown) {
console.error('Error:', textStatus, errorThrown);
});
好, 可以看到Promise的写法, 其实也是存在callback的, done和fail两个方法的参数, 就是callback.
所以本质区别并不是在于写法, 而在于指定callback的时机.
Callback写法, 必须在请求发出前指定handler, 且无法修改, 是一种前置.
而Promise写法, 可以在后续过程中, 再指定handler, 可以后置.
const getUsersPromise = $.get('/api/users');
// 得到promsie后, 可以在后续任何流程中指定handler
getUsersPromise
.done(function(data, textStatus, jqXHR) {
console.log('Success:', data);
})
.fail(function(jqXHR, textStatus, errorThrown) {
console.error('Error:', textStatus, errorThrown);
});
同样的, 我们前面看似正确的设计, 也一直是前置的.
(Q&A)
三、统一异常处理的设计
3.1 灵感来源
以上啰嗦了那么多, 是为了大家有代入感.
下面介绍下用定制 error/异常 解决以上问题的设计.
其实就是来源于java等语言的异常处理.
先看一个java "读文件" 的代码, 重点是对异常的处理.
import java.io.*;
public class FileReaderExample {
public static void main(String[] args) {
String filePath = "example.txt";
try (BufferedReader reader = new BufferedReader(new FileReader(filePath))) {
String line;
while ((line = reader.readLine()) != null) {
System.out.println(line);
}
} catch (FileNotFoundException e) {
System.err.println("错误:找不到文件 '" + filePath + "'");
e.printStackTrace();
} catch (IOException e) {
System.err.println("错误:读取文件时发生 I/O 异常");
e.printStackTrace();
} catch (Exception e) {
System.err.println("未知错误:" + e.getMessage());
e.printStackTrace();
}
}
}
同样的, ajax 除了正常返回结果, 我们可以认为其他情况都是异常, 且可能会触发很多种异常.
Ajax will throw Error
3.2 代码实现
好, 接下来将以上构想, 用代码实现一下.
首先, 我们先构造一个Model
class ErrorMessage {
constructor(public msg) {}
}
interceptor修改如下
const { code, data, message } = response.data
if (code === 200) {
return data; // 1
} else if (code === 500) {
throw new ErrorMessage(message); // 2 抛出异常
}
return response; // 3
其次, 需要有个统一的errorHandler兜底, 就是最顶层的catch, 一切未被catch的error, 都会进入这里, 这一点非常重要.
比如 vue global errorHandler.
const app = createApp();
app.config.errorHandler = (error) => {
if (error instanceof ErrorMessage)
MessageBox.error(error.msg);
else if (error instanceof XXXError) {
// ...
}
};
然后 业务层如下
{
// 场景A (perfect, 非常简捷)
const userInfo = await getUserInfo();
}
{
// 场景B
try {
const data = await xxx();
} catch (error) {
if (error instanceof ErrorMessage) {
// 这里优先于globalErrorHandler处理
showErrorMessageOnDom(error.msg);
}
else
throw error; // 其他不想管的异常, 继续向上抛
}
}
显而易见的优点:
-
保留业务处的对异常的控制权
-
心智负担低
- 该是谁处理的, 就找谁. common处理就找common的位置, 业务特殊处理的就在业务对应位置, 不串, 不乱.
- 以后无论是 增加新的error处理, 还是对旧处理进行变更, 也都很容易找到对应位置
-
阻断后续代码的执行
- 这是异常天然的特性
- 如果出错了不想停, 还想继续执行, 也可以用try catch包起来
-
更接近原生语义, 隐藏细节
-
需要导入的包更少
-
MessageBox.error(error.msg); 这种代码, 往往需要导入UI库.
Import {MessageBox} from 'ant-design';
-
还可能需要对error.msg进行国际化后, 再显示
Import {t} from 'i18n';
t('error.user_not_found')
- 以上细节, 都在errorHandler里处理了
-
-
(Q&A)
四、思路打开 扩展用法
以上代码都是围绕ajax展开的, 但异常本身, 并不限于此.
4.1 Assert/断言
例如最常用的Assert.
function parseDatetime(value: string) {
if (!value) {
throw new AssertError('value is required');
}
return new Date(value);
}
globalErrorHandler里增加对AssertError的处理
const app = createApp();
app.config.errorHandler = (error) => {
if (error instanceof ErrorMessage)
MessageBox.error(error.msg);
else if (error instanceof AssertError) {
reportToSentry(error); // 上报给sentry
}
else if (error instanceof XXXError) {
// ...
}
};
然后 parseDatetime 就可以随便被调用了, 异常也能得到正确处理.
4.2 其他Message, 或者Notice
// 手动抛出, 请求发送前的校验
function validate(params) {
if (!params.id) {
throw new WarnMessage('ID is required');
}
}
4.3 路由跳转
如: getUserInfo失败时, 跳到登录页面
// 请求发送前的校验
function getUserInfo() {
const token = getToken();
if (!token) throw NavMessage('/login');
return Http.get('/api/userinfo', {headers: {token}});
}
// 处理token过期:
// interceptor中, 发现ajax返回401, 直接抛异常就好了
if (status === 401) throw NavMessage('/login');
4.4 服务端表单检验
将服务端校验信息, 回显到formitem上.
这是个挺常见的需求, 但很多公司会弹个Message来代替, 没有细化.
效果如下图所示
当用户post表单后, 由server端进行校验, 并返回如下结构化信息
{
"type": "VALIDATION",
"data": {
"hedgedMarginRatio": "error.overlength",
"freeMarginRatio": "error.required",
},
}
具体需求:
-
当server validation未通过时
- 在formitem上显示error message
- 不能关闭窗口
-
还需要结合client validation
- 统一前后端校验
代码实现:
与上面几个异常相比, 要复杂一些, 我们只介绍与异常处理相关的部分, 其他Form等处理就略过.
首先, 是Model
class ValidationMessage {
constructor(public data) {}
}
其次是axios interceptor
const { code, data, message, type } = response.data
if (code === 200) {
return data;
} else if (type === 'MESSAGE') {
throw new ErrorMessage(message);
} else if (type === 'VALIDATION') {
throw new ValidationMessage(data); // 抛出异常
}
return response;
然后是 Modal 大致如下
<template>
<NModal :loading="loading">
<NForm ref="formRef">
<NFormTabs>
<NFormTabPane key="basic" :tab="$tl('basic')">
<NInput v-model:value="model.name" field="name" />
</NFormTabPane>
</NFormTabs>
</NForm>
</NModal>
</template>
<script>
const { model, submit } = useModalForm();
submit(() => ajaxUpdate(model));
</script>
只上代码, 简捷明了.
写业务时, 只需要关心业务, 而不需要关心通用的逻辑.
显然, 对ValidationError处理的逻辑, 在onSubmit中了.
主要代码如下
async function submit(onSubmit) {
try {
await validate(); // client validation
await onSubmit?.(model); // ajax api
} catch (error) {
if (error instanceof ValidationError) {
setValidationData(error.data);
_formRefValue().setErrorTabs();
_formRefValue().switchToFirstErrorTab();
throw new IgnoredMessage('handled validation error');
} else throw error;
}
}
validate方法是client validation.
无论前端还是后端校验, 都会throw ValidationError, 那么处理也就统一了.
但有异常抛出时, 后续代码也不会执行, 所以modal也不会关闭.
五、再次对比 callback/promise
处理后置的魅力
之前的设计一直在前置那里搞, 却想实现后置的需求, 缘木求鱼.
关于Promise, refer to zhuanlan.zhihu.com/p/78826691
六、聊下IgnoredMessage
刚才也提到了, 异常有个特性, 会中断后续代码的执行, 例如下面代码
当func1抛出异常时, func2是没有执行机会的
try {
func1(); // throw
func2();
} catch (error) {
}
之前示例中解决多余的if判断, 就是用这个特性解决的
{
const userInfo = await getUserInfo();
// 多余的if判断
if (!userInfo) {
return;
}
showUserInfo();
yyy;
zzz;
}
所以, 有时, 当业务代码想优雅的中断执行, 就可以用IgnoreMessage
// model
class IgnoreMessage() {
constructor(public msg) {}
}
// 业务层
try {
func1(); // throw IgnoreMessage
func2();
} catch (error) {
}
顺嘴提一个代码小技巧
若有以下需求
const user = getUserInfo();
if (user) {
const company = getCompany(user.id);
if (company) {
const customers = getCompanyCustomers(company.id);
if (customers) {
//...
}
}
}
可以看到上面的每个步骤都依赖之前的结果, 所以层层嵌套.
现在我们知道了, throw error就行了, 某一步出错了, 后续代码都不会执行
代码就可以写成这样:
try {
const user = getUserInfo();
const company = getCompany(user.id);
const customers = getCompanyCustomers(company.id);
} catch (error) {}
如果就不用异常, 还有个 do/while(false) 的写法:
do {
const user = getUserInfo();
if (!user) break;
const company = getCompany(user.id);
if (!company) break;
const customers = getCompanyCustomers(company.id);
if (!customers) break;
//...
} while(false);
比层层嵌套要好很多.
类似于goto, 但没有goto心智负担重.
七、再聊语义
编程语言为什么称为语言, 因为代码即是表达.
好的代码, 如诗一样美.
7.1 代码执行顺序
回顾最开始的需求:
-
当code是200时, 正常返回其中的data
-
当code是500时, 且有message属性时, 用Message组件显示该message
- 某些特定业务时, 需要单独显示message, 而不用MessageBox
下图是以上需求的正确表达
而之前错误的设计, 一直很别扭, 在尝试用各种方法堵漏.
造成这种局面的本质原因, 就是因为语义表达的不顺畅, "代码执行的顺序"没设计好
7.2 该干什么, 就只干什么
这里并不是要聊unix的哲学, 而还是在聊语义.
这样才能设计出更解耦, 更易用, 更易维护的架构.
"好的设计的标准, 就是看起来, 它本应如此, 而不会有其他的样子"
感觉这个思路应该很容易想到的, 但现实发现很多前端小伙伴还在用前置方式解决后置需求
又或者在业务处对res.status===200进行判断.
希望对大家有所帮助