Angular RXJS 中实现 30 sec timeout 和 modal 的最佳方式
结论
不要在 subscribe.complete 中显示超时 modal。RxJS 的 complete 只表示流已经结束,并不会告诉你结束的原因。正常完成、超时 notifier、外部取消信号,以及 takeUntil(this.destroy$) 都可能触发它。
更稳妥的处理方式是:
- polling 流只输出最终状态
PROCESSED或REJECTED。 - 用
timeout将“30 秒内没有收到最终状态”转换为明确的{ kind: 'timeout' }事件。 - 在
next中分别处理最终状态和超时。 destroy$等取消信号继续通过takeUntil处理。取消只结束订阅,不显示 modal。
推荐实现
下面的示例使用 RxJS 7 的 timeout 配置写法,并假设 getPetitionInfos() 返回 Angular HttpClient Observable,每次请求只发出一个结果。
import {
EMPTY,
merge,
Observable,
of,
Subject,
timer
} from 'rxjs';
import {
catchError,
exhaustMap,
filter,
finalize,
map,
take,
takeUntil,
timeout
} from 'rxjs/operators';
type PetitionState = 'ACCEPTED' | 'PROCESSED' | 'REJECTED';
interface PetitionInfo {
state: PetitionState;
}
type PollingOutcome =
| {
kind: 'final';
response: PetitionInfo;
}
| {
kind: 'timeout';
};
private readonly destroy$ = new Subject<void>();
private pollPetitionInfo(): void {
const pollIntervalMs = 1_000;
const timeoutMs = 30_000;
// acceptedTimer$ 在这里代表业务上的外部取消信号。
// 如果它不是取消信号,不应放进 stop$。
const stop$ = merge(
this.acceptedTimer$,
this.destroy$
);
this.showPreloader = true;
const finalState$: Observable<PollingOutcome> = timer(0, pollIntervalMs).pipe(
exhaustMap(() =>
this.crossBorderService.getPetitionInfos()
),
// ACCEPTED 是中间状态,不向下游发送,继续 polling。
// 只有最终状态才能结束等待。
filter(
(response): response is PetitionInfo =>
response.state === 'PROCESSED' ||
response.state === 'REJECTED'
),
take(1),
map(response => ({
kind: 'final' as const,
response
}))
);
finalState$.pipe(
/*
* finalState$ 在 ACCEPTED 时不会发出值,因此 first: 30_000
* 表示从订阅开始,最多等待最终状态 30 秒。
*
* 超时不会进入 error,也不会依赖 complete,而是转换成一个
* 可以明确识别的业务结果。
*/
timeout({
first: timeoutMs,
with: () =>
of({
kind: 'timeout' as const
})
}),
// 组件销毁或业务主动取消时静默结束,不产生 timeout 结果。
takeUntil(stop$),
finalize(() => {
this.showPreloader = false;
})
).subscribe({
next: outcome => {
if (outcome.kind === 'timeout') {
this.openAcceptedApplicationModal();
return;
}
this.handleFinalState(outcome.response);
},
error: error => {
/*
* 这里处理真正的请求错误,例如 HTTP 错误。
* 如果后端通过 state: 'REJECTED' 表达连接失败,
* REJECTED 会在 handleFinalState 中处理,不会进入这里。
*/
this.handleErrors(error);
}
});
}
分别处理最终状态
不要用 state !== 'PROCESSED' 之类的条件把多个状态混在一起处理。PROCESSED 和 REJECTED 对应的界面行为完全不同,明确写出各自的分支更安全。
private handleFinalState(response: PetitionInfo): void {
switch (response.state) {
case 'PROCESSED':
this.showSuccessSnackbar(
'System of fast payments is connected to the account'
);
this.showConnectAccountMessage = false;
this.connectionCheckboxDisabled = true;
this.detailsFieldsDisabled = false;
break;
case 'REJECTED':
this.showErrorSnackbar(
'Error of connection to the "Prompt Payment System"'
);
this.restoreInitialScreenState();
break;
}
}
如果后端实际返回的是小写状态,例如 processed、rejected 和 accepted,判断条件应与接口的真实返回值保持一致,也可以在服务层统一规范化。不要在同一个组件中混用大小写。
超时 modal 和跳转
以 Angular Material 为例,可以等用户点击 OK 后,再关闭当前页面并执行导航:
private openAcceptedApplicationModal(): void {
const dialogRef = this.dialog.open(PetitionAcceptedDialogComponent, {
disableClose: true,
data: {
message:
'Application for registration in the Fast Payments System successfully accepted',
confirmText: 'OK'
}
});
dialogRef.afterClosed().pipe(
take(1),
takeUntil(this.destroy$)
).subscribe(result => {
if (result !== true) {
return;
}
this.closeTransferScreen();
this.router.navigate(['/home']);
});
}
modal 组件中的 OK 按钮需要把 true 作为关闭结果:
<button
type="button"
mat-button
[mat-dialog-close]="true">
OK
</button>
如果没有使用 Angular Material,控制逻辑仍然相同:只有 outcome.kind === 'timeout' 才能打开 modal。用户确认后,再关闭 transfer screen 并跳转到 Home。
初次请求也应按状态分流
只有初次请求返回 ACCEPTED 时,才需要启动 polling:
private checkPetition(): void {
this.crossBorderService.getPetitionInfos().pipe(
takeUntil(this.destroy$)
).subscribe({
next: response => {
switch (response.state) {
case 'ACCEPTED':
this.pollPetitionInfo();
break;
case 'PROCESSED':
case 'REJECTED':
this.handleFinalState(response);
break;
}
},
error: error => {
this.handleErrors(error);
}
});
}
为什么原实现会误显示 modal
原代码把 modal 放在了 complete 回调中:
complete: () => {
// Show modal window
}
但以下任何一种情况都可能触发这个回调:
takeUntil(interval(...))发出值;acceptedTimer$发出值;destroy$发出值;- polling 流正常完成;
- 其他上游操作符主动完成流。
所以,complete 不能用来表示“30 秒超时”。
原实现还存在几个问题:
interval(1000)会等一秒才发送第一次请求,timer(0, 1000)则会立即发送。- 普通
HttpClient请求通常只发出一个响应后就会完成,因此没有必要使用last()。 concatMap会把 interval 产生的 tick 排队。如果某次 HTTP 请求耗时较长,后续请求可能不断积压。exhaustMap会在当前请求结束前忽略新的 tick,从而避免请求重叠或排队。takeUntil(interval(30 * 1000 + 1000))大约会在 31 秒后触发,而且仍然无法区分超时和其他完成原因。
正确的做法是把超时表示为明确的数据事件,而不是通过 Observable 的 complete 回调猜测流为何结束。这样才能清楚地区分组件销毁、主动取消、收到最终状态和真正超时。
备注:内容仅供参考。
