web 端错误处理方案

本文主要内容是对两种请求错误的处理,一是状态码非 200,我们称为「请求错误」;二是状态码为 200,但在业务上请求错误的,如注册时用户名重复、删除商品时商品不存在等我们称为「业务错误」,并且这类错误往往会返回一个 code 来标志错误、一个 msg 表示错误信息。

下面以一个 todolist 为例来具体说明。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
import React, { useEffect, useState } from 'react';
import { message } from 'antd';

export default function App() {
const [tasks, setTasks] = useState([]);

useEffect(() => {
fetchTasks();
}, []);
// 获取任务列表
const fetchTasks = async () => {
try {
const response = await request("http://127.0.0.1:3001/api/tasks");
const { code, msg, data } = response;
// 错误判断并处理 <-------
if (code !== 0) {
message.error(msg);
return;
}
const { list } = data;
setTasks(list);
} catch (err) {
message.error(err.message);
}
};

return (
<div>
{tasks.map((task) => {
return <div key={task.id}>{task.name}</div>;
})}
</div>
);
}

随即我们会意识到这样写在每个地方都要写重复的代码,即判断 code !== 0 表示这次请求是错误的,然后 message.error(msg)展示错误信息。

这个逻辑在所有存在请求的地方都是一样的,所以我们往往会在请求方法内统一处理这些错误,比如axios 的响应拦截器或者自己实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// utils/request.js
import axios from "axios";
import { message } from 'antd';

export default function request(url, options = {}) {
return axios({ url, ...options })
.then((response) => {
const {
data: { code, msg, data },
} = response;
// 这里的逻辑是通用的
if (code !== 0) {
message.error(msg);
return;
}
return data;
})
.catch((error) => {
message.error(error.message);
});
}

这样做,我们在业务层,即我们的页面中就无需关注错误,因为到了页面这里就必然是成功的,不用 try catch,代码显得简洁优雅。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
import React, { useEffect, useState } from "react";

import request from "./utils/request";

export default function App() {
const [tasks, setTasks] = useState([]);

useEffect(() => {
fetchTasks();
}, []);
// 获取任务列表
const fetchTasks = async () => {
// 可以看到这里省去了大量代码
const data = await request("http://127.0.0.1:3001/api/tasks");
const { list } = data;
setTasks(list);
};

return (
<div>
{tasks.map((task) => {
return (
<div key={task.id}>{task.name}</div>
);
})}
</div>
);
}

至此似乎很完美了,但业务往往超出我们的预期,现在需要实现对于某个请求,如果请求错误不 message.error弹出错误 信息,而是在页面上展示,上面的实现肯定是满足不了,那我们就需要修改,很容易我们想到给 request 接收额外 handler 参数,如果传入该参数表示「我要自己处理错误」

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// utils/request.js
import axios from "axios";
import { message } from 'antd';

export default function request(url, options = {}) {
// 额外添加 handler 参数
const { handler } = options;
return axios({ url, ...options })
.then((response) => {
const {
data: { code, msg, data },
} = response;
if (code !== 0) {
// 判断 handler 是否存在,如果存在就调用,否则就 alert
if (handler !== undefined) {
handler({ code, msg, data });
return;
}
message.error(msg);
return;
}
return data;
})
.catch((error) => {
message.error(error.message);
});
}

业务中使用时额外传入 handler 参数

1
2
3
4
5
6
7
8
// 获取任务详情
const fetchTaskDetail = (id) => {
return request(`http://127.0.0.1:3001/api/tasks/${id}`, {
handler: ({ code, msg }) => {
// showErrorInPage(msg);
},
});
};

这样做的确能实现,但有两点不够好

  1. request.js 中调用了 message.error 等于在底层方法中调用了组件库,不够优雅
  2. 上手成本,request 方法居然还有 handler 参数

还有另一种方式,通过 window.addEventListener(‘unhandledRejection’) ,因为错误是有类似「冒泡」机制的,即 a 调用 bb 再调用 c,这样形成所谓的「调用链」,在链条任意节点发生错误,会往上抛出错误,直到被链条其中一环捕获,或者到最顶层。

所以我们在 request.js 中不处理错误,而是将这个错误抛出

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// utils/request.js
import axios from "axios";

export default function request(url, options = {}) {
return axios({ url, ...options }).then((response) => {
const {
data: { code, msg, data },
} = response;
if (code !== 0) {
// 返回一个表示失败的 Promise 并带上错误信息 msg
return Promise.reject(new Error(msg));
}
return data;
});
}

尝试去触发一个业务错误,可以在控制台看到

控制台报错

这表示存在一个 Promise 错误但我们没有去捕获它,那我们现在来处理它。

在项目入口文件,可以是调用 ReactDOM.render 的地方,如果没有也可以在 App.jsx 这种文件,增加如下代码

1
2
3
4
5
6
7
8
9
10
11
import { message } from 'antd';

window.addEventListener("unhandledrejection", (event) => {
event.preventDefault();
const { reason } = event;
if (reason instanceof Error) {
message.error(reason.message);
return;
}
message.error(event.message);
});

没有被处理的错误会被这里处理。
在页面中如果我们希望自己处理错误,只需要将代码块 try ... catch 包裹,表示「我要捕获错误了」

1
2
3
4
5
6
7
8
// 获取任务详情
const fetchTaskDetail = (id) => {
try {
return request(`http://127.0.0.1:3001/api/tasks/${id}`);
} catch (err) {
// showErrorInPage(err.message);
}
};

错误不会被 window.addEventListener(‘unhandledrejection’) 重复处理,相比传入 handler 的方式更加「优雅」。

但这仍不是最好的解决方案,当我们 try {} 代码块中出现例如语法错误、类型错误时,同样会被我们的 catch {} 块捕获,并在页面上展示,但这种错误我们其实并不希望在页面上展示,同时如果项目内有错误收集系统,这些错误就被我们吞掉导致无法被收集。

如果是开发环境并使用了 react-error-overlay 的项目,出现业务错误也会展示错误遮罩,非常影响开发体验。

所以对上面的代码再进行优化,

1
2
3
4
5
6
7
8
9
10
// 业务错误(code 不是指「代码」)
class CodeError {
static isCodeError = (error) => {
return error instanceOf CodeError;
}

constructor(params) {
Object.assign(this, params);
}
}

首先我们声明一个「业务错误」类,并将之前 code !== 0 时抛出 Error 改成抛出 CodeError

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// utils/request.js
import axios from "axios";

export default function request(url, options = {}) {
return axios({ url, ...options }).then((response) => {
const {
data: { code, msg, data },
} = response;
if (code !== 0) {
// 返回一个表示失败的 Promise 并带上错误信息 msg
return Promise.reject(new CodeError({ code, msg }));
}
return data;
});
}

之前 message.error 展示错误信息的地方都需要修改

1
2
3
4
5
6
7
8
9
10
11
12
13
window.addEventListener("unhandledrejection", (event) => {
event.preventDefault();
const { reason } = event;
if (CodeError.isCodeError(reason)) {
message.error(reason.msg);
return;
}
if (reason instanceof Error) {
message.error(reason.message);
return;
}
message.error(event.message);
});
1
2
3
4
5
6
7
8
9
10
11
12
// 获取任务详情
const fetchTaskDetail = (id) => {
try {
return request(`http://127.0.0.1:3001/api/tasks/${id}`);
} catch (err) {
if (CodeError.isCodeError(err)) {
// showErrorInPage();
return;
}
throw err;
}
};

在自己处理业务错误时,非业务错误必须通过 throw 再次抛出,这样一些语法错误等就能被错误收集捕获。

这里提供了一段代码,直接在项目中使用即可

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// 业务错误(code 不是指「代码」)
class CodeError {
static isCodeError = (error) => {
return error instanceOf CodeError;
}

constructor(params) {
Object.assign(this, params);
}
}

window.addEventListener("unhandledrejection", (event) => {
event.preventDefault();
const { reason } = event;
if (CodeError.isCodeError(reason)) {
message.error(reason.msg);
return;
}
if (reason instanceof Error) {
message.error(reason.message);
return;
}
message.error(event.message);
});

在项目中使用

1
2
3
4
5
import { handleError } from 'error-handler';

handleError((error) => {
message.error(error.message);
});

在请求方法中业务错误时抛出该错误

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// utils/request.js
import axios from "axios";
import { CodeError } from 'error-handler';

export default function request(url, options = {}) {
return axios({ url, ...options }).then((response) => {
const {
data: { code, msg, data },
} = response;
if (code !== 0) {
// 返回一个表示失败的 Promise 并带上错误信息 msg
return Promise.reject(new CodeError(msg, { code, msg }));
}
return data;
});
}

如果想自己处理错误

1
2
3
4
5
6
7
8
// 获取任务详情
const fetchTaskDetail = (id) => {
return tryCatch(() => {
return request(`http://127.0.0.1:3001/api/tasks/${id}`);
}).catch((err) => {
// showErrorInPage(err);
});
};