最近在解决一个微前端的缺陷,在某种场景下刷新页面跨应用渲染组件会报一个组件未注册的错误,这个错误本身无关紧要,但是一旦错误抛出后控制台会打印两条错误日志(map 操作处理函数会执行2次,整个 load 函数内部通过 shareReplay 后自己订阅了一次,外部调用 load 函数时也订阅了一次),好奇心驱使我要好好研究一下 RxJS 的错误处理机制。
最终发现一篇英文博客写的特别好:RxJs Error Handling: Complete Practical Guide
所以我就使用我那极不专业的英文翻译了一下,翻译的不好勿见怪,英文好的直接看原文吧,以下是翻译内容:
错误处理是 RxJS 的一个特别重要的部分,因为我们编写响应式代码的任何时候都需要处理错误。
RxJS 中的错误处理非常难以理解,稍不注意就会踩坑,但是如果我们对于 Observable 契约的理解非常深刻,那么理解错误处理就很简单了。
在这篇文章中,我们将提供一个完整的指南,其中包含最常见的错误处理策略,这些策略将覆盖涵盖大多数实际的场景,首先让我们从基础(Observable 对象的契约)开始。
目录
- Observable 契约和错误处理
- RxJS 订阅和错误回调函数
- catchError 操作符
- 捕获和替换策略
- 捕获和再抛出策略
- 在 Observable 链中多次使用 catchError
- Finalize 操作符
- 重试策略
- retryWhen 操作符
- 创建一个通知流
- 立即重试策略
- 延迟重试策略
- delayWhen 操作符
- 创建 Observable 的函数 timer
- Github 仓储 (代码示例)
- 结束
Observable 契约和错误处理
为了理解 RxJS 中的错误处理,我们需要首先理解:任何给定的流只能出错一次。这是由 Observable 契约定义的,它表示流可以发出零个或多个值,Observable 契约这样工作是因为我们在运行时观察到的所有流的都是如此,例如:网络请求可能会失败。
一个 Stream 完成了意味着:
- Stream 已结束其生命周期,没有报任何错误
- 完成后,Stream 将不再发出任何其他值
还有一种完成就是报错了,报错意味着:
- Stream 带着错误结束了生命周期
- 当错误抛出后,Stream 将不再发出任何其他值
需要注意的是:Stream 的完成和错误是互斥的
- 如果一个 Stream 已经完成(complete)了,它不可能在之后报错
- 如果一个 Stream 报错了,它不可能在之后完成
还有就是 Stream 完成和出错是可选的,两种情况只能发生一种,不能同时发生,当一个特定的 Stream 出错时,我们就不能再使用它了,根据 Observable 的契约,你一定在想,我们怎么才能从错误中把 Stream 恢复过来呢?
RxJS 订阅和错误回调函数
当我们在 RxJS 中创建一个 Stream 并通过 subscribe
函数订阅它,subscribe
函数有三个可选参数:
- 成功处理函数
next
,每次流发出值时调用该函数 - 错误处理函数
error
,只有在发生错误时才调用该函数,此处理函数本身接收一个错误 - 完成处理函数
complete
,仅当流完成时才调用该函数
@Component({
selector: 'home',
templateUrl: './home.component.html'
})
export class HomeComponent implements OnInit {
constructor(private http: HttpClient) {}
ngOnInit() {
const http$ = this.http.get<Course[]>('/api/courses');
http$.subscribe(
res => console.log('HTTP response', res),
err => console.log('HTTP Error', err),
() => console.log('HTTP request completed.')
);
}
}
流的 Completion 行为
如果上述的流没有报错,它将会在控制台打印如下日志:
HTTP response {payload: Array(9)}
HTTP request completed.
我们可以看出, HTTP 的流只会 Emit 一个值,发出后完成了它,意味着没有错误发生。
但是如果 HTTP 请求报错,那么控制台只会打印一个错误的日志,并不会 Emit 任何值,同时也不会完成。
订阅错误处理的限制
有时候我们通过 subscribe
订阅来处理错误,但是这种处理方式是有限制的。
比如我们不能恢复错误或者 Emit 一个 回退值(fallback)替换原本期望服务端应该返回的值,接下来我们学习一些操作符来实现一些错误处理的高级策略。
catchError 操作符
在同步编程中我们可以通过 try {} catch {}
块包裹来捕获任何错误,然后在 catch
中处理错误:
try {
// synchronous operation
const httpResponse = getHttpResponseSync('/api/courses');
}
catch(error) {
// handle error
}
这样处理错误非常简单,但是 JavaScript 中大部分操作都是异步的,比如请求一个远程 API, RxJS 中提供的 catchError
操作符帮助我们处理类似的场景。(备注:不叫 catch 是因为 catch 是关键字)
catchError 如何工作的?
与其他操作符一样,catchError
只是一个函数,接受一个 Observable,输出一个 Observable。每次调用catchError
时需要传入一个错误处理函数。
catchError
操作符将一个可能出错的 Observable 作为输入,并发出和输入一样的 Observable,如果没有错误,catchError 产生的输出 Observable 与输入 Observable 完全相同。
当错误抛出会发生什么?
但是,如果发生了错误,那么catchError
逻辑将起作用catchError
运算符将接收错误并将其传递给错误处理函数。错误处理函数将返回一个 Observable,它将替代刚刚出错的流并返回给下游。
让我们记住catchError
的输入流已经出错,因此根据 Observable 的契约,我们不能再使用它了, 这个替换的 Observable 随后将被订阅,它的值将被用来代替出错的输入 Observable。
捕获和替换策略
让我们举一个例子,说明如何使用 catchError 来提供发出回退值的替换 Observable:
const http$ = this.http.get<Course[]>('/api/courses');
http$
.pipe(
catchError(err => of([]))
)
.subscribe(
res => console.log('HTTP response', res),
err => console.log('HTTP Error', err),
() => console.log('HTTP request completed.')
);
让我们来分解一下捕获和替换策略:
- 我们传递给 catchError 一个错误处理函数
- 错误处理函数不会立即调用,通常也不会调用
- 只有当 catchError 的 Observable 输入出现错误时,才会调用错误处理函数
- 如果 Observable 输入流中发生错误,则此函数将返回使用
of([])
函数构建的 Observable 对象 - of() 函数生成一个 Observable 并发出一个值 ([]) 同时完成了这个流
- 错误处理函数返回了一个恢复的流
of([])
,并且被 catchError 操作符订阅 - 恢复流的值发出值替换了输出的流,被 catchError 返回
最终结果就是 http$
Observable 将不再报错,我们将会再控制台得到如下结果:
HTTP response []
HTTP request completed.
我们将看到 subscribe 的错误处理函数将不再调用:
- 空的数组[]被发送出去
- http$ Observable 完成
尽管原来的 Observable 出错了,替换的 Observable 被用来为 http$ 的订阅者提供一个默认的回退值[]
。
注意:在返回替换的 Observable 之前,我们还可以添加一些本地错误处理,比如记录错误日志等操作。
以上是捕获和替换策略,接下来让我们看看如何使用 catchError 来重新抛出错误,而不是提供回退值。
捕获和再抛出策略
我们首先注意,替换策略中的 catchError 本身也会出错,一旦发生错误,错误将会传播到 catchError 的输出 Observable,这种错误传播行为为我们提供了一种机制,让我们可以在本地处理错误后重新抛出 catchError 捕获的错误:
const http$ = this.http.get<Course[]>('/api/courses');
http$
.pipe(
catchError(error => {
console.log('Handling error locally and rethrowing it...', error);
return throwError(error);
})
)
.subscribe(
res => console.log('HTTP response', res),
error => console.log('HTTP Error', error),
() => console.log('HTTP request completed.')
);
捕捉并重新抛出故障
让我们一步一步地分解捕获和再抛出策略:
- 像之前一样,我们捕获了错误并返回了一个替代的 Observable
- 但是这一次,我们并没有返回一个回退值 [] 作为输出,我们在 catchError 中本地处理了错误
- 在本例中,我们只是简单的记录错误到控制台,但是我们可以添加任何我们想要的错误处理逻辑,比如向用户展示错误信息
- 然后我们返回一个替换的 Observable,这次是使用 throwError 创建的
- throwError 创建了一个从不发出任何值的可观察对象,它会立即抛出一个 catchError 捕捉的错误
- 这意味着 catchError 的 Observable 输出也将与 catchError 的输入所抛出的完全相同的错误一起出错
- 这意味着我们成功地将 catchError 的 input Observable 抛出的错误重新抛出到输出 Observable
- 如果需要的话,这个错误可以被接下来的 Observable 链上处理
如果我们运行代码,将会在控制台输出错误日志
我们看到,同一个错误既被记录在 catchError 块中,也会出现在 subscribe 错误处理函数中。
在 Observable 链中多次使用 catchError
请注意,如果需要,我们可以在 Observable 链的不同点多次使用 catchError,并在链的每个点采用不同的错误策略。
例如:我们可以在 Observable 链中捕捉到一个错误,在本地处理并重新抛出,然后在 Observable 链的下一步,我们可以再次捕获相同的错误,这一次我们可以提供一个回退值(而不是重新抛出)。
const http$ = this.http.get<Course[]>('/api/courses');
http$
.pipe(
map(res => res['payload']),
catchError(err => {
console.log('caught mapping error and rethrowing', err);
return throwError(err);
}),
catchError(err => {
console.log('caught rethrown error, providing fallback value');
return of([]);
})
)
.subscribe(
res => console.log('HTTP response', res),
err => console.log('HTTP Error', err),
() => console.log('HTTP request completed.')
);
运行代码将会在控制台输出:
该错误最初确实是重新抛出了,但它从未到达 subscribe 错误处理,因为回退值 [] 按预期发出。
Finalize 操作符
JavaScript 中的try {} catch
捕获同步代码错误的时候提供了 finally 关键字,表示无论如何总会执行的代码块。finally 代码块一般用于释放昂贵的资源,比如:关闭网络连接或者释放内存。与 catch 块中的代码不同,无论是否抛出错误,finally 块中的代码将独立执行。
try {
// synchronous operation
const httpResponse = getHttpResponseSync('/api/courses');
}
catch(error) {
// handle error, only executed in case of error
}
finally {
// this will always get executed
}
RxJS 为我们提供了 finalize 操作符实现类似的功能(备注:不叫 finally 是因为 finally 在 JavaScript 中是关键字)
Finalize 操作符示例
与 catchError
操作符一样,我们可以在 Observable 链的不同位置添加多个 finalize 调用,以确保正确释放多个资源:
const http$ = this.http.get<Course[]>('/api/courses');
http$
.pipe(
map(res => res['payload']),
catchError(err => {
console.log('caught mapping error and rethrowing', err);
return throwError(err);
}),
finalize(() => console.log("first finalize() block executed")),
catchError(err => {
console.log('caught rethrown error, providing fallback value');
return of([]);
}),
finalize(() => console.log("second finalize() block executed"))
)
.subscribe(
res => console.log('HTTP response', res),
err => console.log('HTTP Error', err),
() => console.log('HTTP request completed.')
);
运行这段代码,看看多个 finalize 块是如何执行的:
注意:最后一个 finalize 块是在 subscribe value 处理函数和 completion 处理函数之后执行的。
重试策略
对于错误处理,我们上面介绍了 catchError 返回回退值,或者重新报错的方法,我们还可以简单地重试订阅出错的 Observable。
之前我们一直在强调:一旦流出错,我们就无法恢复它,但是我们可以再次订阅流的来源,并创建另一个流:
- 我们获取输入流并且订阅它,创建一个新的流
- 如果流没有出错,我们将使用它的返回值显示在输出流中
- 如果流出错了,我们重新订阅一次输入,创建一个新的流
什么时候使用 retry
最大的问题是:我们什么时候才能再次订阅 Observable 的输入,然后重试执行输入流?
- 我们需要立即重试吗?
- 我们需要等待一个小的延迟,希望问题解决后再次重试?
- 我们的重试次数是否有一个限制,当超出限制后再次报错
为了更好的回答这个问题,我们需要第二个辅助的 Observable,我们可以叫它通知流,这个通知流决定何时重试,通知流可以使用 retryWhen 操作符,它是重试策略的核心。
RxJS retryWhen 操作符弹珠图
注意开始重试是在第二行的 1-2 Observable 顶部,而不是第一行的 Observable。
第一行的 Observable 发出 r-r 值得流是通知流,它将决定何时重试。
分解 retryWhen 的工作原理
- Observable 1-2 流被订阅,它的值立即反应在 retryWhen 返回流的输出
- 即使 Observable 1-2 流已经完成,但是它仍然可以被重试
- 通知流在 Observable 1-2 流完成后发出 r 值
- 通知流可以发出任何值,在此示例中发出了 r
- 重要的是 r 值发出的时刻,将触发 1-2 Observable 被重试
- Observable 1-2 将会被 retryWhen 再次订阅,它的值反应在 retryWhen 的输出 Observable 中
- 通知 Observable 继续发出另一个 r 值,同样会触发 1-2 Observable 被重试
- 但是此时通知 Observable 完成了
- 此时,1-2 Observable 正在进行的重试尝试也提前完成,所以只发出了值1,值2还未发出
retryWhen 在每次通知 Observable 发出一个值时都会简单地重试输入 Observable,现在我们理解了 retryWhen 的工作机制,让我们开始创建一个通知流。
创建一个通知流
我们需要在传递给 retryWhen 操作符的函数中直接创建通知 Observable,此函数将一个可观察的错误作为输入参数,它将发出可观察输入的错误作为值。
因此,通过订阅这些 Observable 的错误,我们可以准确地知道错误何时发生,现在让我们看看如何使用Observable 的错误来实现立即重试策略。
立即重试策略
为了在错误发生后立即重试失败的 Observable,我们所要做的就是返回 Observable 的错误,而不做任何事情,在本例中,我们只是将 tap 操作符用于日志记录,因此 Observable 的错误保持不变:
const http$ = this.http.get<Course[]>('/api/courses');
http$.pipe(
tap(() => console.log("HTTP request executed")),
map(res => Object.values(res["payload"]) ),
shareReplay(),
retryWhen(errors => {
return errors
.pipe(
tap(() => console.log('retrying...'))
);
} )
)
.subscribe(
res => console.log('HTTP response', res),
err => console.log('HTTP Error', err),
() => console.log('HTTP request completed.')
);
让我们记住: 从 retryWhen 函数调用后的返回的值 Observable 是通知 Observable
它发出的值并不重要,它只在发出值时才重要,因为这将触发重试尝试。
立即重试控制台输出
如果我们执行上述程序,控制台会输出以下结果:
如我们所见:HTTP 请求最初失败,但随后尝试重试,第二次请求成功通过。
我们通过检查网络日志来了解两次尝试之间的延迟:
第二次尝试是在错误发生后立即发出的,正如预期的那样。
延迟重试策略
现在让我们实现另一种错误重试策略,在这种策略中,我们在错误发生后等待 2 秒,然后重试。
此策略对于尝试从某些错误中恢复非常有用,例如:由于服务器高流量而导致失败的网络请求。
在那些错误是间歇性的情况下,我们可以简单地在短时间延迟后重试同一个请求,并且请求可能会经过第二次而没有任何问题。
创建 Observable 的函数 timer
要实现延迟重试策略,我们需要在每个错误发生后两秒发出一个通知 Observable,让我们使用 timer 创建函数创建一个通知 Observable, 这个 timer 函数需要几个参数:
- 初始延迟值,在此之前不会发出任何值
- 一个周期性的间隔,希望周期性地发出新值
下图是 timer 的弹珠图:
第一个值0将在3秒后发出,然后我们每秒都有一个新值。
注意:第二个参数是可选的,如果不传意味着我们的 Observable 将在3秒后只发出一个值(0),然后完成。
这个 Observable 是延迟重试尝试的好的开始,所以让我们看看如何将它与 retryWhen
和 delayWhen
操作符结合起来。
delayWhen 操作符
关于 retryWhen 操作符,我们需要记住:定义通知 Observable 的函数只被调用一次。
所以我们只有一次机会来定义我们的通知 Observable,即当重试尝试应该完成时发出信号。
我们将通过定义通知流来获取错误,并将其应用于 delayWhen 操作符。
假设在下面的弹珠图中,源 Observable a-b-c 是发出错误的流,它会随着时间的推移发出失败的HTTP错误:
delayWhen 操作符分解
通过上述弹珠图,我们可以了解到 delayWhen 是如何工作的:
- 每一个输入错误的流都会被延迟,最后出现在输出的 Observable 中
- 每一个值得延迟都不同,并且以完全灵活的方式创建
- 为了确定延迟,我们将调用传递给 delayWhen 的函数(称为持续时间选择器函数),并把输入错误的 Observable 的值传入时间选择器函数
- 持续时间选择器函数将返回一个 Observable,这个 Observable 决定每个输入值的延迟何时结束
- 每个值 a-b-c 有它自己的持续时间选择器 Observable,它最终将发出一个值(可以是任何值),然后完成
- 当这些持续时间选择器中的每一个都发出值时,相应的输入值 a-b-c 将出现在 delayWhen 的输出中
- 注意,值 b 显示在输出值 c 之后,这是正常的
- c 在 b 之前出现是因为 b 持续时间选择器 Observable(从顶部开始的第三条水平线)是在 c 的持续时间选择器 Observable 之后发出它的值
延迟重试策略的实现
现在,让我们把所有这些放在一起,看看如何在每次错误发生2秒后连续重试失败的 HTTP 请求:
const http$ = this.http.get<Course[]>('/api/courses');
http$.pipe(
tap(() => console.log("HTTP request executed")),
map(res => Object.values(res["payload"]) ),
shareReplay(),
retryWhen(errors => {
return errors
.pipe(
delayWhen(() => timer(2000)),
tap(() => console.log('retrying...'))
);
} )
)
.subscribe(
res => console.log('HTTP response', res),
err => console.log('HTTP Error', err),
() => console.log('HTTP request completed.')
);
- 记住传递给
retryWhen
的函数只会被调用一次 - 我们在该函数中返回一个 Observable,它将在需要重试时发出值
- 每当有错误发生,delayWhen 操作符将会通过调用 timer 函数创建一个持续时间选择器的 Observable
- 这个持续时间选择器 Observable将会再 2s 后发出值(0),发出后并完成
- 一旦2s后发出来值,delayWhen 的 Observable 就知道给定输入错误的延迟已经过去
- 只有当延迟过去(错误发生后2秒),错误才会显示在通知 Observable 的输出中
- 一旦在通知 Observable 中发出一个值,retryWhen 操作符将执行一次重试尝试
重试策略控制台输出
现在让我们看看这个在控制台中是什么样子,下面是一个 HTTP 请求的示例,该请求重试了5次,前4次是错误的:
下面是网络日志:
重试只发生在错误发生2秒后,如预期的那样!
至此,我们已经完成了一些最常用的RxJS错误处理策略的指导教程,现在让我们总结一下并提供一些运行的示例代码。
Github 仓储 (代码示例)
为了尝试这些多种错误处理策略,有一个可以尝试处理失败的HTTP请求的示例是很重要的。
rxjs-course 包含了一个小带有服务端的运行程序,可以用来模拟 HTTP 错误。
以下是应用程序的外观:
结束
理解 RxJS 的错误处理就需要首先理解可观察契约的基本原理,需要记住的是:任何给定的流只能出错一次,这是流完成所独有的;这两种情况中只有一种可能发生。
为了从错误中恢复,唯一的方法是以某种方式生成一个替换流作为出错输出流的替代,就像在 catchError 或 retryWhen 运算符的情况下发生的那样。