异步编程的异常处理

本文主要参考自 @黄子毅 的 Callback Promise Generator Async-Await 和异常处理的演进, 对内容进行了一定的加工整理,并修改了原文中的一些错误。

回调

1.无法捕获的异常

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
function fetch(callback) {
setTimeout(() => {
throw Error('请求失败')
})
}
try {
fetch(() => {
console.log('请求处理') // 永远不会执行
})
} catch (error) {
console.log('触发异常', error) // 永远不会执行
}
// 程序崩溃
// Uncaught Error: 请求失败

执行回调时已经不是出于原本的执行栈了,Error发生在下一轮事件循环中,所以没有被try...catch捕获

2.Error-First约定
对于高度依赖异步回调的Node.js,采用了Error-First的约定,即:

  1. callback的第一个参数必须是一个error对象,如果没有出错则该值为null
  2. 第二个参数为异步操作成功后的结果

好处在于,进行异步函数出错时,它不需要知道如何处理Error,也不会直接让程序崩溃。而是把错误交给你,让你自行选择忽略处理或者冒泡给别的callback

Promise

前置知识:promise内部的错误不会冒泡到全局
1.reject的Error都能捕获(实质为microtask中抛出的异常)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
//以下异常都能被捕获
var prom = new Promise((resolve,reject) => {
throw Error('noo'); // 直接throw Error可捕获(因为在microtask中)
// reject('nooo'); // reject当然也可以
})
prom
.then((err) => console.log('working',err)) // 不运行
.catch((e) => console.log(e)); //运行
//又如
var prom = new Promise((resolve,reject) => {
resolve('resolved')
})
prom
.then(() => {
throw Error('nooo');//也是在microtask中抛出,可捕获
// return Promise.reject('nooo') // 返回一个rejected的promise也能捕获
})
.then((err) => console.log('working',err))// 不运行
.catch((e) => console.log(e));// 运行

注意链式调用中,能传递的错误只有在microtask中throw Error以及return rejected的promise,其它返回值均包装成resolve的promise,包括Error:

1
2
3
4
5
6
7
8
9
10
11
var prom = new Promise((resolve,reject) => {
resolve('resolved')
})
prom
.then(() => {
return Error('nooo') // 不视作异常, 返回一个Error对象
//Promise.reject('nooo') // 不视作异常,因为返回值为undefined
})
.then((err) => console.log('working',err)) // 运行
.catch((e) => console.log(e)); // 不运行

2.macrotask中抛出的异常无法捕获

1
2
3
4
5
6
7
8
9
10
11
var prom = new Promise((resolve,reject) => {
setTimeout(() => {
throw Error('nooo');
},0)
})
prom
.then((err) => console.log('working',err))
.catch((e) => console.log(e));
//程序崩溃

其实很好理解,setTimeout中的回调是进入macrotask的,即进入新一轮事件循环,此时上下文环境已改变,即相当于单独使用:

1
() => {throw Error('nooo');

另外prom实质上处于pending状态,自然也无法进入then的链式调用。

解决方案也很简单,可以使用try…catch捕获错误,然后传递给reject。

1
2
3
4
5
6
7
8
9
10
11
12
13
var prom = new Promise((resolve,reject) => {
setTimeout(() => {
try{
throw Error('nooo');
}catch(e){
reject(e) // 捕获异常,并传递给catch
}
},0)
})
prom
.then((err) => console.log('working',err))
.catch((e) => console.log(e));

当然,一个很常见的场景就是使用第三方函数,然后第三方函数在macrotask中抛出了错误

1
2
3
4
5
6
7
8
9
10
11
12
13
14
function thirdFunction() {
setTimeout(() => {
throw Error('就是任性')
})
}
Promise.resolve(true).then(() => {
thirdFunction()
}).catch(error => {
console.log('捕获异常', error)
})
// 程序崩溃
// Uncaught Error: 就是任性

很遗憾,只能让其改为promise并进行reject(可见promise是未来大势所趋)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
function thirdFunction() {
return new Promise((resolve, reject) => {
setTimeout(() => {
reject('收敛一些')
})
})
}
Promise.resolve(true).then(() => {
// 注意这里的return,缺少return会怎样?请认真阅读前文
// 然而此处的return极其容易忘记,更优雅的方案见后文async
return thirdFunction()
}).catch(error => {
console.log('捕获异常', error) // 捕获异常 收敛一些
})

3.利用macrotask抛出的异常
尽管最后又.catch()捕获错误,但万一.catch()内部抛出错误,该错误就无法被捕捉到了(promise内部的错误不会冒泡到全局)
这时利用macrotask抛出的异常能冒泡到全局这一特性,实现.done()方法

1
2
3
4
5
6
7
8
9
10
11
Promise.prototype.done = function() {
this.catch((err) => {
setTimeout(() => {throw err}, 0)
});
}
//用法
asyncFunc()
.then(f1)
.catch(r1)
.then(f2)
.done();

Promise异常处理小结:

  1. macrotask中抛出的错误(throw Error('msg'))无法捕获,需通过promise的reject来传递错误
  2. microtask中的错误,直接throw Error('msg')returnreject的promise都能传递
  3. then中除了returnreject的promise,其它返回值都会被包装成resolve的promise

Async函数

async函数就是自动运行的状态机,而状态机(Generator)又提供切出切入(并保留上下文)的功能(有没有想起前文因上下文丢失而无法处理的Error?)

Async的异常

await不会自动捕获异常,但会中断async函数的进行,而不是直接让程序崩溃。

1
2
3
4
5
6
7
8
9
10
11
12
function fetch(callback) {
return new Promise((resolve, reject) => {
reject()
})
}
async function main() {
const result = await fetch() // rejected的promise被传入
console.log('请求处理', result) // 不会执行,async函数终止
}
main()

Async捕获异常

Async函数能通过使用try...catch捕获异常(当然在macrotask中抛出的异常仍旧无法捕获,原因同promise)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
function thirdFunction() {
return new Promise((resolve, reject) => {
setTimeout(() => {
reject('收敛一些')
})
})
}
async function main() {
try {
const result1 = await secondFunction() // 如果不抛出异常,后续继续执行
const result2 = await thirdFunction() // 抛出异常
const result3 = await thirdFunction() // 永远不会执行
console.log('请求处理', result) // 永远不会执行
} catch (error) {
console.log('异常', error) // 异常 收敛一些
}
}
main()

值得注意的是,async返回的是一个promise对象,所以也可以通过对Async返回的promise对象进行异常捕获:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
function thirdFunction() {
return new Promise((resolve, reject) => {
setTimeout(() => {
reject('收敛一些')
})
})
}
async function main() {
const result1 = await Promise.resolve(true) // 如果不抛出异常,后续继续执行
const result2 = await thirdFunction() // 抛出异常
const result3 = await thirdFunction() // 永远不会执行
console.log('请求处理', result1) // 永远不会执行
}
main()
.catch((err) => console.log('异常',err)); // 异常 收敛一点

模仿async

async/await不在ES6标准中,但其实我们也可以对generator进行包装,让其自动运行:

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
function autoRun(generator) {
//首先async返回的是一个promise
return new Promise((resolve, reject) => {
var gen = generator();
function nextThrow(func) {
// 当上一个yielded的value是rejection时
// 尝试向generator内部抛出错误
try{
var next = func();
}catch(e){
// 如果generator内部没有try...catch捕获错误,则会在此处捕获
// 并让autoRun返回一个rejection,与async行为一致
reject(e)
}
if(next.done){
resolve(next.value);
}else{
// 保证rejection和非promise值能被处理
Promise.resolve(next.value).then(
(result) => nextThrow(() => gen.next(result)),
(rejection) => nextThrow(() => gen.throw(rejection))// 值为rejection时向generator内部抛出错误,在下一轮才执行是为了方便捕获
)
}
}
//启动
nextThrow(() => gen.next());
})
}
// 使用
function* gen() {...}
var resultProm = autoRun(gen);
result.then(...)
.catch(...)//能像async一样使用

冒泡处理异常

考虑一下代码:

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
const successRequest = () => Promise.resolve('a')
const failRequest = () => Promise.reject('b')
class Action {
async successReuqest() {
const result = await successRequest()
console.log('successReuqest', '处理返回值', result) // successReuqest 处理返回值 a
}
async failReuqest() {
const result = await failRequest()
console.log('failReuqest', '处理返回值', result) // 永远不会执行
}
async allReuqest() {
const result1 = await successRequest()
console.log('allReuqest', '处理返回值 success', result1) // allReuqest 处理返回值 success a
const result2 = await failRequest()
console.log('allReuqest', '处理返回值 success', result2) // 永远不会执行
}
}
const action = new Action()
action.successReuqest()
action.failReuqest()
action.allReuqest()

程序不会崩溃,因为promise内部的异常不冒泡到全局(即使throw Error也是,但如果macrotask上throw Error即使try…catch也无法捕获)

为了捕获异常,需要给每个async都包裹一层try...catch或者给async的返回结果加上.catch(),难免显得有点繁琐。于是我们的解决方案登场了:

Decorator(修饰器)

这是ES7的一个提案,目前Babel已通过插件支持。
简单来说就是修饰器能在代码编译阶段改变类的行为。
下面是一个简单的类修饰器示例

1
2
3
4
5
6
7
8
function testable(target) {
target.isTestable = true;
}
@testable
class MyTestableClass {}
console.log(MyTestableClass.isTestable) // true

类修饰器接受一个参数target,为类的构造函数。现在,我们想给类中每一个async函数都包裹一层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
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
//我们先对类装饰器进行柯里化,传入一个errorHandler作参数
const asyncErrorWrapper = (errorHandler) => (target) => {
const props = Object.getOwnPropertyNames(target.prototype);
props.forEach((prop) => {
var value = target.prototype[prop];
// 判断如果是async函数则包裹try...catch
if(Object.prototype.toString.call(value) === '[object AsyncFunction]'){
target.prototype[prop] = async function (...args) {
try{
// 注意千万不能漏掉await
await value.apply(this,args);
}catch(err){
errorHandler(err);
}
}
}
});
}
// 生成一个errorHandler为console.log的装饰器
const logError = asyncErrorWrapper((err) => console.log('异常',err));
const successRequest = () => Promise.resolve('a')
const failRequest = () => Promise.reject('b')
//应用装饰器
@logError
class Action {
async successReuqest() {
const result = await successRequest();
console.log('successReuqest', '处理返回值', result);
}
async failReuqest() {
const result = await failRequest();
console.log('failReuqest', '处理返回值', result);
}
async allReuqest() {
const result1 = await successRequest();
console.log('allReuqest', '处理返回值 success', result1);
const result2 = await failRequest();
console.log('allReuqest', '处理返回值 success', result2);
}
}
const action = new Action();
action.successReuqest()
action.failReuqest()
action.allReuqest()

通过这样的统一处理,前端可以用alert兜底,node端也可以返回500兜底,避免程序崩溃的同时也掌握了处理异常的主动权。

One more thing

别忘了可能还有漏网之鱼,我们可以通过监听全局错误来处理:
浏览器端,监听未处理的rejection

1
2
3
4
// 仅chrome49支持
window.addEventListener("unhandledrejection", function (event) {
console.warn("WARNING: Unhandled promise rejection. Shame on you! Reason: "+ event.reason);
});

Node.js端(v1.4.1已支持)

1
2
3
4
5
6
7
8
9
// 用Map记录没处理的rejection
const unhandledRejections = new Map();
process.on('unhandledRejection', (reason, p) => {
unhandledRejections.set(p, reason);
});
// 处理后从Map中删除记录
process.on('rejectionHandled', (p) => {
unhandledRejections.delete(p);
});