【js】如何较为“优雅”得写 async 及 await 异常处理

async/await不必多说、它是 ES7 中引入的异步编程模型,它使异步代码看起来像同步代码,更易于阅读和编写。async函数返回一个 Promise 对象,可以使用await关键字等待 Promise 对象的解决。

如:

1
2
3
4
5
async function getData() {
const response = await fetch('/data');
const data = await response.json();
return data;
}

当使用 async await 处理异步函数的时候,通常我们会用 try-catch 来做容错或捕获异常,很多代码、文档文章都如此建议,比如 mdn 中重写 promise 链的说明:

原本 promise:

1
2
3
4
5
6
7
8
9
function getProcessedData(url) {
return downloadData(url) // 返回一个 promise 对象
.catch(e => {
return downloadFallbackData(url); // 返回一个 promise 对象
})
.then(v => {
return processDataInWorker(v); // 返回一个 promise 对象
});
}

改为async await

1
2
3
4
5
6
7
8
9
async function getProcessedData(url) {
let v;
try {
v = await downloadData(url);
} catch (e) {
v = await downloadFallbackData(url);
}
return processDataInWorker(v);
}

因此我们可以在各种 async 异步函数定义的代码中看到大量的 try-catch。这些 try-catch 能帮我们兜底各类异常,但是大量的 try-catch 始终让人感觉可以做一些抽象处理(代码结构的改变、看起来总觉得有冗余代码),而且很多同学在开发时甚至可能会忘记 try-catch 之类的兜底处理。那么我们该如何“优雅”得进行asyncawait 异步处理呢?

异常处理的时机

首先我们需要分析一下平时需要加try-catch的场景:

1.处理异步调用时 Promise 执行的异常

在进行异步调用时,在执行 Promise 期间可能会发生某些异常(比较多的是接口请求相关,比如接口请求连接错误、超时等),一旦出现上述情况,异步请求就会产生异常,而我们知道 js 是单线程语言。代码报错后,后面的代码无法继续执行,所以需要加一个 try-catch 来捕获此时的异步请求,让代码可以继续向后执行。

比如:

1
2
3
4
5
6
7
8
9
async function getUserId() {
try {
const user = await fetchUserInfo(); // 一个请求接口并返回用户信息对象的promise封装,封装代码不重要
return user.id;
} catch (e) {
console.error(e);
return '';
}
}

当然多个异步调用时也同样会考虑使用try-catch进行处理:

1
2
3
4
5
6
7
8
9
10
11
async function getUserAsset() {
try {
const { id } = await fetchUserInfo();

const assetInfo = await fetchUserAssetById(id); // 一个请求接口并返回用户持仓信息的promise封装,封装代码不重要
return assetInfo;
} catch (e) {
console.log(e);
return {};
}
}

2.处理异步调用返回值的异常

在进行异步调用时,由于返回值也存在不确定性,因此 try-catch 也常用于 await 返回值的处理、通常是取值赋值。如:

1
2
3
4
5
6
7
8
9
10
async function getUserList() {
let list = [];
try {
const res = await fetchUserList(); // 一个请求接口并返回结果(包含code、msg、data字段)的promise封装,封装代码不重要
list = res.data.list || list; // data和list可能都不存在
} catch (e) {
console.error(e);
}
return list;
}

对应于这两种不同的情况,“优雅”得处理方式也可以有所差异

不用try-catch进行异常处理

1.面向处理异步调用时 Promise 执行的异常

这类情况触发异常的主要原因通常是因为 Promise 没有进行catch处理、从而使异常暴露到了调用侧,因此最简单粗暴的方式就是在 Promise 调用上加catch,如:

1
2
3
4
async function getUserId() {
const user = await fetchUserInfo().catch(e => console.error(e)); // 也可以在fetchUserInfo方法中进行reject或者是catch
return user?.id || '';
}

当然假如我们不希望异常后继续处理(比如不是上述取值函数、而是个 step-by-step 的执行),我们可以在catch中通过reject阻止继续执行,如:

1
2
3
4
5
6
7
8
9
10
11
async function run() {
await step1().catch(e => {
console.log('step1 failed.', e); // 也可以进行一些埋点、日志上报之类的操作
return Promise.reject(e);
});

await step2().catch(e => {
console.log('step2 failed.', e);
return Promise.reject(e);
});
}

或者如果step1()step2()如果有正常返回值设计的话也可以通过判断返回值进行中断。

但是综合来看,手动加 catch的方式仍然有一定成本,开发同学同样会忘记,并且 error 的处理还是很困难,总的来说还是不够“优雅”。

2.面向处理异步调用返回值的异常

这类情况就需要通过各类数据判断进行约束,比如上述接口取值的情况:

1
2
3
4
5
6
7
8
9
10
11
12
async function getUserList() {
let list = [];

const res = await fetchUserList().catch(e => console.error(e)); // 一个请求接口并返回结果(包含code、msg、data字

if (res?.code === 0) {
// 0时才有data和list
list = res?.data?.list || list;
}

return list;
}

不过有些时候这么处理会使得代码很臃肿。

await-to-js处理函数

有一个小范围有名的await异常处理的封装模块:await-to-jsGithub 源码>>。它就较好得解决了await异步处理异常的问题。

使用

安装

1
npm i await-to-js --save

然后在项目中import对应方法即可:

1
2
3
4
5
6
import to from 'await-to-js'

async function () {
const [err, data] = await to(somePromise());
// ...
}

其中参数

  • promise:{Promise},需要包裹处理的 promise 执行
  • errorExt:{object},异常信息的补充对象, 可选

返回值:[U, undefined][null, T]

  • 异常时返回前者,U为 catch 返回值
  • 正常时返回后者,T为 Promise 返回数据

然后我们可以根据err的存在与否来判断状态并执行后续处理。我们可以使用await-to-js来改造刚才这些方法

改造

1
2
3
4
5
6
7
8
9
10
11
12
import to from 'await-to-js';

/** 上述异常1场景 */
async function getUserId() {
const [err, user] = await to(fetchUserInfo());

if (err) {
console.error(err);
return '';
}
return user?.id;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import to from 'await-to-js';

/** 上述异常2场景 */
async function getUserList() {
let list = [];

const [err, data] = await to(fetchUserList());

if (err) {
console.error(err);
return list;
}

if (res?.code === 0) {
// 0时才有data和list
list = res?.data?.list || list;
}

return list;
}
1
2
3
4
5
6
7
8
9
10
11
12
import to from 'await-to-js';
async function run() {
const [err1, data1] = await to(step1());
if (err1) {
return console.error(err1);
}

const [err2, data2] = await to(step2());
if (err2) {
return console.error(err2);
}
}

原理分析

await-to-js源码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
/**
* @param { Promise } promise
* @param { Object= } errorExt - Additional Information you can pass to the err object
* @return { Promise }
*/
export function to<T, U = Error>(
promise: Promise<T>,
errorExt?: object
): Promise<[U, undefined] | [null, T]> {
return promise
.then<[null, T]>((data: T) => [null, data])
.catch<[U, undefined]>((err: U) => {
if (errorExt) {
const parsedError = Object.assign({}, err, errorExt);
return [parsedError, undefined];
}

return [err, undefined];
});
}

源码很简单只有 22 行,大致流程如下: 函数接受参数 promiseerrorExt
如果 Promise 成功,它会返回 [null, data]
如果异常,则判断是否有 errorExt 参数(代表传递给 err 对象的附加信息)。如果它有时与 catch 返回捕获的 err 合并,或者 [err, undefined] 如果没有。

设计分析

await-to-js 的返回格式是否让你感到熟悉?本人的第一印象就是很想 Nodejs 异常优先的回调设计。在 Nodejs 的 API 中,回调函数的第一个参数是 error,是为了方便处理错误。如果异步操作没有出错,error参数为 nullundefined,否则它会包含一个 Error 对象,其中包含有关错误的信息。比如:

1
2
3
4
5
6
7
8
9
const fs = require('fs');

fs.readFile('/path/to/file', (err, data) => {
if (err) {
console.error(err);
return;
}
console.log(data);
});

这种模式的优点是可以在回调函数中优先处理错误,而不是在调用异步函数之后检查错误。这样可以避免在异步操作之间混淆错误检查和处理代码。此外,这种模式还可以使您的代码更加简洁和易于阅读。

当前其他编程语言中也有相似的设计,比如await-to-js作者在博客中提到的 go-lang

1
2
data, err := db.Query("SELECT ...")
if err != nil { return err }

这种设计感觉比使用 try-catch 块更干净,并且更少地聚集代码,这使得代码更加可读和可维护。


相关链接