You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

Angular中如何使用exhaustMap防止按钮多次点击重复提交

使用exhaustMap防止表单重复提交方案

核心原理

exhaustMap是RxJS提供的高阶映射操作符,核心特性为:当上游触发新事件时,若当前内部处理的Observable尚未完成,会直接丢弃新事件,直到内部Observable结束后才会响应新的上游事件。完全适配按钮重复点击场景,无需手动维护提交锁,即可实现同一时间仅发起一次提交请求的效果。

最佳实践要点

  • 将按钮点击事件统一封装为RxJS Subject流,所有点击事件送入该流统一处理,不要在点击回调中直接发起请求
  • 点击流通过exhaustMap映射为后端请求Observable,自动丢弃请求未完成期间的所有点击事件
  • 单个请求的异常要在流内部捕获,避免单次请求失败导致整个点击流终止,后续点击无法响应
  • 组件销毁时主动取消点击流的订阅,避免内存泄漏
  • 可配合按钮disabled状态绑定加载变量,从UI层面给用户不可点击的反馈,形成双重保障

示例代码改造

改动说明

  1. 新增点击事件Subject接收保存按钮的点击动作
  2. 抽离原有保存逻辑为独立方法,返回请求Observable
  3. 在组件初始化阶段订阅点击流,通过exhaustMap处理请求映射
  4. 移除原有冗余的debounceTime,exhaustMap已完全覆盖防重复需求

修改后的HTML代码

<ng-template #editButtons>
  <div class="flex" *ngIf="isEditing">
    <app-page-section-cards-btn
      [btnData]="pageSectionsOptions.btnData.cancel"
      (btnClickEvent)="cancelEdit()">
    </app-page-section-cards-btn>
    <app-page-section-cards-btn
      [btnData]="pageSectionsOptions.btnData.save"
      (btnClickEvent)="saveClick$.next()"
      [disabled]="isLoading"
    >
    </app-page-section-cards-btn>
  </div>
</ng-template>

修改后的TS代码

首先引入必要的RxJS依赖:

import { Subject, tap, exhaustMap, takeUntil, catchError, of } from 'rxjs';

组件内部逻辑调整:

export class YourComponent implements OnInit, OnDestroy {
  // 保存按钮点击流
  private saveClick$ = new Subject<void>();
  // 销毁流,用于清理订阅
  private destroy$ = new Subject<void>();
  isLoading = false;

  ngOnInit(): void {
    // 订阅保存点击流
    this.saveClick$.pipe(
      tap(() => this.isLoading = true),
      // 核心:请求未完成前忽略所有后续点击
      exhaustMap(() => this.getSaveDealObservable().pipe(
        // 捕获单个请求的异常,避免终止整个点击流
        catchError(err => {
          console.log('Error saving deal', err);
          return of(null);
        })
      )),
      takeUntil(this.destroy$)
    ).subscribe(res => {
      this.isLoading = false;
      if (!res) return;
      if (res.isSuccess) {
        this._notificationService.showSuccess('Deal was successfully saved.');
        this.refreshDealDetailsPage(res.data);
      }
    });
  }

  ngOnDestroy(): void {
    this.destroy$.next();
    this.destroy$.complete();
  }

  // 抽离原有保存逻辑,返回请求Observable
  private getSaveDealObservable() {
    if (!this.isExistingDeal) {     
      const dealTypeValues = {
        "id": 0,
        "name": this.dealPLSFormFields.dealName,
        "summary": this.dealPLSFormFields.summary,
        "mlasId": this.dealPLSFormFields.partner,
        "startDate": AppUtils.convertDateStringToYYYYMMDD(this.dealPLSFormFields.startDate),
        "estimatedOtherRevenue":this.dealPLSFormFields.estimatedOtherRevenue,
        "descriptionOfOtherRevenue":this.dealPLSFormFields.descriptionOfOtherRevenue,
        "totalMonthlyRentAndFees":this.dealPLSFormFields.totalMonthlyRentAndFees,
        "buildOutCostReimbursement":this.dealPLSFormFields.buildOutCostReimbursement,
        "dealId": 0,
        "startDateString": AppUtils.convertDateStringToYYYYMMDD(this.dealPLSFormFields.startDate),
        "endDateString":  AppUtils.convertDateStringToYYYYMMDD(this.dealPLSFormFields.endDate),
      } 
     
      const payload = {
        "id": 0,
        "name": this.dealPLSFormFields.dealName,
        "dealType": "Partner Location Submission",
        "annualRentProposed": null,
        "annualRentCurrent": null,
        "firmTermRemaining": null,
        "firmTermAdded": null,
        "maxAvailableTerm": null,
        "status": null,
        "capitalContribution": null,
        "parentCloneId": null,
        "accountId": this.currentAccount.accountId,
        "transactionId": this.transactionData.id,
        "dealTypeValues": JSON.stringify(dealTypeValues)
      }
      return this._dealService.createDeal(payload);
    } else {
      const dealTypeValues = {
        "id": this.dealData.dealTypeValues.id,
        "name": this.dealPLSFormFields.dealName,
        "summary": this.dealPLSFormFields.summary,
        "mlasId": this.dealPLSFormFields.partner,
        "startDate": AppUtils.convertDateStringToYYYYMMDD(this.dealPLSFormFields.startDate),
        "endDate": AppUtils.convertDateStringToYYYYMMDD(this.dealPLSFormFields.endDate),
        "securityMonitoringMonthly": this.dealPLSFormFields.securityMonitoringMonthly,
        "rent": this.dealPLSFormFields.rent,
        "cam": this.dealPLSFormFields.cam,
        "supportServicesFee": this.dealPLSFormFields.supportServicesFee,
        "estimatedOtherRevenue":this.dealPLSFormFields.estimatedOtherRevenue,
        "descriptionOfOtherRevenue":this.dealPLSFormFields.descriptionOfOtherRevenue,
        "totalMonthlyRentAndFees":this.dealPLSFormFields.totalMonthlyRentAndFees,
        "buildOutCostReimbursement":this.dealPLSFormFields.buildOutCostReimbursement,
        "dealId": this.dealData.dealTypeValues.dealId,
        "startDateString": AppUtils.convertDateStringToYYYYMMDD(this.dealPLSFormFields.startDate),
        "endDateString":  AppUtils.convertDateStringToYYYYMMDD(this.dealPLSFormFields.endDate),
      } 

      const payload = {
        "id": this.dealData.id,
        "name": this.dealPLSFormFields.dealName,
        "dealType": "Partner Location Submission",
        "annualRentProposed": null,
        "annualRentCurrent": null,
        "firmTermRemaining": null,
        "firmTermAdded": null,
        "maxAvailableTerm": null,
        "status": this.dealData.status,
        "capitalContribution": null,
        "parentCloneId": null,
        "accountId": this.currentAccount.accountId,
        "transactionId": this.transactionData.id,
        "dealTypeValues": JSON.stringify(dealTypeValues)
      }
      
      return this._dealService.updateDeal(payload);
    }
  }
}

效果说明

改造完成后,即使用户上百次连续点击保存按钮,在第一次请求返回(成功/失败均视为请求完成)之前,所有后续点击都会被exhaustMap直接忽略,不会发起任何重复请求。同时结合按钮的disabled状态,用户在加载期间可以直观感知到按钮不可点击,体验更友好。

内容的提问来源于stack exchange,提问作者Martin Lautz

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.09.29 18:30:02