缘起
先看一段常见的前端数据请求代码:
const fetchData = async () => {
// 这里如果发生网络错误,如断网、请求超时、跨域问题或者 HTTP 状态码小于 200、大于 299
// 会抛出请求异常
const { data: responseData } = await http.get(`http://localhost/example/get`);
const { code, msg, data } = responseData;
if (code !== '000000') {
// 这里主动抛出业务异常
throw new Error(msg);
}
return data;
};
(async () => {
try {
const data = await fetchData();
// 后续操作
} catch (error) {
// 异常处理
}
})();
最近就一段类似上文数据请求的代码使用 try-catch
是否合适和同事产生不同意见,同事反对使用
try-catch
和抛异常,觉得无论成功失败,都应该返回 response
。
当然,他不是我遇到的一个抗拒使用 try-catch
和抛异常的人。去年某同事在使用我封装的客户端
API 方法时,为了解决不写 try-catch
会导致浏览器报 unhandled promise rejection
错误,全部用一个 errorCaptured
函数包一遍。该 errorCaptured
函数和用法大致如下:
async function errorCaptured(asyncFunc) {
try {
let res = await asyncFunc();
return [null, res];
} catch (e) {
return [e, null];
}
}
let [err, res] = await errorCaptured(Douya.getLocation);
if (err) {
// 异常处理
}
// 后续操作
errorCaptured
函数调用后,错误异常被放到了返回值中和正常执行结果一起返回给调用方。
该方案给人一种类似 Node.js error-first callback 风格的既视感,将异常转换为返回值。
这样写可以免去块级代码嵌套,看起来很“精简”。
但是也埋下了隐患,由于出错不会向外抛异常,即使不处理返回值中的 err
对象,程序也会继续往下执行。
溯源
这次好奇心大发,决定寻找该用法的出处。
- async/await 优雅的错误处理方法 2019-01-25
- 嘿,不要给 async 函数写那么多 try/catch 了 2019-07-10
中文范围内主要就是以上两篇去年发表的文章,都声称该写法更优雅,同时还有一篇更早的 2016 年的英文文章:
作者在发表文章的同时,将该方案开源到 GitHub: await-to-js,并发布为 npm 包。
await-to-js
和那两篇中文文章方案的区别在于入参和返回值顺序不同,await-to-js
不会默认执行函数,返回值是 Golang 风格,数据在前,错误在后。
我自己实现了一下:
async function to<T, E = Error>(
promise: Promise<T> | T,
): Promise<[T, null] | [null, E]> {
try {
return [await promise, null];
} catch (err) {
return [null, err];
}
}
也可以不用 async/await
,直接基于 Promise
:
function to<T, E = Error>(
promise: Promise<T> | T,
): Promise<[T, null] | [null, E]> {
return Promise.resolve(promise).then(
(res) => [res, null],
(err) => [null, err],
);
}
问题讨论
在两篇中文文章下,除了一些“学到了”之外,有不少人表达了反对意见:
又活生生把node带回到4.x时代,我用async await和promise生成器就是为了不要一堆堆的if err,你倒好,又开始了,错误处理就应该准循错误处理语义,抛出或者向上传递result,哪里该消化result就哪里消化,你这不伦不类的
连go都在讨论引入try,写js的反倒还学别人的糟糠,并且还觉得别人的糟糠真香,这是什么想法,错误应该传播或者捕获,不应该转为正常返回,这样error就没有存在的意义,建议看下rust的错误处理学习一下
match不会丢失result语义啊,该传播还是会传播啊,而且错误这样专为普通返回会丢失错误堆栈啊
你需要的是全局的错误处理机制和清晰的异常层次,而不是在代码中“小心翼翼”的处理每个可能的错误。通过异常冒泡的机制在不同层次做相应的处理就好了,这种错误处理方式,放在业务中只会徒增工作量
catchCode 绝大部分情况下 绝对不应该 只是 console.error ,有错误就应该往上抛,没处理掉异常接下来的代码就不应该执行,否则你的代码常常容易进入到未定义的状态里,引发更严重的 BUG
有同学更是给出了 Deno 标准库相关的讨论 suggestion: code style about async try catch #525 - denoland/deno_std,看来这个问题不仅是国内圈子会遇到。
回顾历史
既然该方案和 Node.js 的 error-first callback 风格那么像,不如我们重新回顾一下这些年异步编程中异常处理的写法变化。
先定义 3 个 Node.js callback 风格的异步函数:
const fn1 = (callbak) => {
console.log('fn1');
setTimeout(() => {
callbak(null, 1);
});
};
const fn2 = (callbak) => {
console.log('fn2');
setTimeout(() => {
callbak(null, 2);
});
};
const fn3 = (callbak) => {
console.log('fn3');
setTimeout(() => {
callbak(new Error('3'), 3);
});
};
Callback 写法
回调函数的第一个参数为 Error
或 null
。
fn1((err1, res1) => {
if (err1) {
console.error('err1', err1);
return;
}
console.log('fn1 callback', res1);
fn2((err2, res2) => {
if (err2) {
console.error('err2', err2);
return;
}
console.log('fn2 callback', res2);
fn3((err3, res3) => {
if (err3) {
console.error('err3', err3);
return;
}
console.log('fn3 callback', res3);
});
});
});
Promise 写法
后来有了 Promise
,重构所有 Node.js Callback 风格的函数代价太大,于是我们实现一个 promisify
用来包装已有函数,方便调用。
const promisify = (fn) => (...args) =>
new Promise((resolve, reject) => {
fn.call(this, ...args, (err, ...res) => {
if (err) {
reject(err);
} else {
resolve(res.length > 0 ? res : res[0]);
}
});
});
const fn1Async = promisify(fn1);
const fn2Async = promisify(fn2);
const fn3Async = promisify(fn3);
fn1Async()
.then((res1) => {
console.log('fn1 callback', res1);
fn2Async()
.then((res2) => {
console.log('fn2 callback', res2);
fn3Async()
.then((res3) => {
console.log('fn3 callback', res3);
})
.catch((err3) => {
console.error('err3', err3);
});
})
.catch((err2) => {
console.error('err2', err2);
});
})
.catch((err1) => {
console.error('err1', err1);
});
和 Callback 相比,好不到哪里去……如果希望统一处理异常,在 then()
中 return
即可。
fn1Async()
.then((res1) => {
console.log('fn1 callback', res1);
return fn2Async()
.then((res2) => {
console.log('fn2 callback', res2);
return fn3Async()
.then((res3) => {
console.log('fn3 callback', res3);
})
})
})
.catch((err) => {
console.error('err', err);
});
代码顿时简洁不少。
Generator 写法
再后来有了 Generator
写法,搭配 Promise
和 yield
自动迭代执行,便可以用同步的写法编写异步逻辑,异常处理和 Promise
一致。
function* main() {
const res1 = yield fn1Async();
console.log('fn1 callback', res1);
const res2 = yield fn2Async();
console.log('fn2 callback', res2);
const res3 = yield fn3Async();
console.log('fn3 callback', res3);
}
const runGenerator = (generator) => {
const gen = generator();
const iterate = (val) => {
const result = gen.next(val);
if (!result.done) {
return Promise.resolve(result.value).then(iterate);
}
};
return iterate();
};
runGenerator(main).catch((err) => {
console.log('err', err);
});
async/await 写法
async/await
是对 Generator
+ yield
写法的进化,代码更为精简直观,异常处理和同步写法一样使用 try-catch
语句。
const main = async () => {
try {
const res1 = await fn1Async();
console.log('fn1 callback', res1);
const res2 = await fn2Async();
console.log('fn2 callback', res2);
const res3 = await fn3Async();
console.log('fn3 callback', res3);
} catch (err) {
console.error('err', err);
}
};
main();
await-to-js 写法
现在一部分人不想写 try-catch
,于是我们实现一个 to
方法用来转换。
const to = (promise) =>
Promise.resolve(promise).then(
(res) => [res, null],
(err) => [null, err],
);
(async () => {
const [res1, err1] = await to(fn1Async());
if (err1) {
console.error('err1', err1);
return;
}
console.log('fn1 callback', res1);
const [res2, err2] = await to(fn2Async());
if (err2) {
console.error('err2', err2);
return;
}
console.log('fn2 callback', res2);
const [res3, err3] = await to(fn3Async());
if (err3) {
console.error('err3', err3);
return;
}
console.log('fn3 callback', res3);
})();
于是我们又回到了熟悉的 Callback 风格常见的 if err
时代(摸着良心说这种异常处理真的优雅吗?)。
异常的几种处理方式
下面再比较下几种异常的处理方式。
返回错误码
返回正常结果或者错误码,不方便携带具体错误信息。
const fnCStyle = () => {
if (Math.random() > 0.5) {
// success
return 0;
} else {
// failure
return -1;
}
};
返回错误对象
和错误码相比,可以携带携带具体错误信息,但返回值类型不统一。
const fnCStylePlus = () => {
if (Math.random() > 0.5) {
// success
return 0;
} else {
// failure
return new Error("Opps");
}
};
返回 Result 对象
返回一个自定义的 Result 对象,保证类型统一(类型安全)。
const fnResult = () => {
const result = {
value: null,
error: null,
};
if (Math.random() > 0.5) {
// success
result.value = 0;
} else {
// failure
result.error = new Error("Opps");
}
return result;
};
返回 Either
返回类似函数式编程中 Either
概念的对象,左值为错误,右值为正常结果,类型统一(类型安全)。
这其实就是上面的 errorCaptured
函数和 await-to-js
。
/**
* 参考自 {@link https://blog.logrocket.com/elegant-error-handling-with-the-javascript-either-monad-76c7ae4924a1/}
*/
class Left {
static of(value) {
return new Left(value);
}
constructor(value) {
this._value = value;
}
map() {
return this;
}
toString() {
return `Left(${this._value.toString()})`;
}
}
class Right {
static of(value) {
return new Right(value);
}
constructor(value) {
this._value = value;
}
map(fn) {
return Right.of(this._value);
}
toString() {
return `Right(${this._value.toString()})`;
}
}
const fnEither = () => {
if (Math.random() > 0.5) {
// success
return Right.of(0);
} else {
// failure
return Left.of(new Error("Opps"));
}
};
返回类似 Either 的元组(Tuple)
切换到完全函数式编程的代价太大,我们可以只返回类似 Either
的元组,左值为错误,右值为正常结果。这就更像上面的 errorCaptured
函数和 await-to-js
了。
const fnEitherTuple = () => {
if (Math.random() > 0.5) {
// success
return [null, 0];
} else {
// failure
return [new Error("Opps"), null];
}
};
throw Error
出错直接 throw
错误,支持 Stack trace,没有返回值类型不统一的问题。
const fnThrow = () => {
if (Math.random() > 0.5) {
// success
return 0;
} else {
// failure
throw new Error("Opps");
}
};
个人看法
从我个人角度看来 throw Error
最佳,其次是返回 Result
对象或者 Either
,其他在高级语言中基本不用考虑(与其 return new Error
还不如 throw new Error
)。如果是函数式编程,应该会比较反对使用
throw Error
这种会改变程序执行流程的操作(这样很不函数式),转而返回 Result
、Either
,把异常作为数据处理。
return 和 throw 风格的进一步对比
至于 throw Error
为何比 return Result
有优势,让我们再看下两种写法的比较。
return 风格
const fnResult1 = () => {
if (Math.random() > 0.5) {
return {
value: 0,
error: null,
};
}
return {
value: null,
error: new Error('Opps fnResult1'),
};
};
const fnResult2 = () => {
const res1 = fnResult1();
if (res1.error) {
return res1;
}
if (Math.random() > 0.5) {
return {
value: 0,
error: null,
};
}
return {
value: null,
error: new Error('Opps fnResult2'),
};
};
const fnResult3 = () => {
const res2 = fnResult2();
if (res2.error) {
return res2;
}
if (Math.random() > 0.5) {
return {
value: 0,
error: null,
};
}
return {
value: null,
error: new Error('Opps fnResult3'),
};
};
const runFnResult3 = () => {
const res3 = fnResult3();
if (res3.error) {
console.error('runFnResult3 error', res3.error);
return;
}
console.log('runFnResult3 value', res3.value);
};
runFnResult3();
throw 风格
const fnThrow1 = () => {
if (Math.random() > 0.5) {
return 0;
} else {
throw new Error("Opps fnThrow1");
}
};
const fnThrow2 = () => {
fnThrow1();
if (Math.random() > 0.5) {
return 0;
} else {
throw new Error("Opps fnThrow2");
}
};
const fnThrow3 = () => {
fnThrow2();
if (Math.random() > 0.5) {
return 0;
} else {
throw new Error("Opps fnThrow3");
}
};
const runFnThrow3 = () => {
try {
const value3 = fnThrow3();
console.log('runFnThrow3 value', value3);
} catch (error) {
console.error("runFnThrow3 error", error);
}
}
runFnThrow3();
比较结果
- 通过
return
处理错误,如果在嵌套结构中,想在外层统一处理,需要内部一层一层返回。 - 通过
throw
处理错误,可以方便在外层捕获,需要搭配try-catch
语句。
总结
对比几种异步编程和异常处理风格,很容易发现 async/await
和 try-catch
、throw error
方式的优越性。在封装底层 API 时,如果返回 Promise
,应该尊重语义,只在成功时 resolve
,
失败时请 reject
。做好类型判断和边界处理,处理不了的错该抛异常抛异常,最终在上层进行捕获处理,拒绝在内部隐藏吞掉错误。
同时,我们也对文章开头的 errorCaptured
函数有了更细致的认知,或许重新换个命名更符合它的含义:
function toEither<T, E = Error>(
promise: Promise<T> | T
): Promise<[null, T] | [E, null]> {
return Promise.resolve(promise).then(
res => [null, res],
err => [err, null]
)
}
开头的数据请求去除 try-catch
、throw error
后可以重写为:
const fetchData = async () => {
const either = await toEither(http.get(`http://localhost/example/get`));
if (either[0]) {
return either;
}
const { data: responseData } = either[1];
const { code, msg, data } = responseData;
return code === '000000' ? [null, data] : [new Error(msg), null];
};
(async () => {
const [err, data] = await fetchData();
if (err) {
// 异常处理
}
// 后续操作
})();