PAYATHON 2026

Angular RXJS 中实现 30 sec timeout 和 modal 的最佳方式

支付小周

结论

不要在 subscribe.complete 中显示超时 modal。RxJS 的 complete 只表示流已经结束,并不会告诉你结束的原因。正常完成、超时 notifier、外部取消信号,以及 takeUntil(this.destroy$) 都可能触发它。

更稳妥的处理方式是:

  1. polling 流只输出最终状态 PROCESSED 或 REJECTED。
  2. 用 timeout 将“30 秒内没有收到最终状态”转换为明确的 { kind: 'timeout' } 事件。
  3. 在 next 中分别处理最终状态和超时。
  4. 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 回调猜测流为何结束。这样才能清楚地区分组件销毁、主动取消、收到最终状态和真正超时。

备注:内容仅供参考。