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

Angular中使用exhaustMap防止保存按钮多点触发重复提交的最佳实践

Angular 中使用exhaustMap防止表单重复提交的实现方案

实现原理

exhaustMap 是RxJS提供的高阶映射操作符,核心特性是:当内部Observable(此处为接口请求)未完成时,会直接忽略外层Observable(此处为按钮点击事件)发射的所有新值,完美适配「请求完成前忽略所有重复点击」的需求。
原有代码中直接在点击回调中订阅请求+debounceTime的方案无效,因为每次点击都会创建独立的Observable流,debounceTime仅对单个流生效,无法拦截多次点击产生的多流请求。


适配代码的改造步骤

第一步:修改TS代码

首先引入RxJS相关依赖,创建点击事件流,改造原有提交逻辑:

// 首先引入需要的RxJS依赖
import { Subject, of } from 'rxjs';
import { exhaustMap, takeUntil, tap, catchError } from 'rxjs/operators';
import { Component, OnInit, OnDestroy } from '@angular/core';

@Component({
  // 保留你的组件原有配置
})
export class YourComponent implements OnInit, OnDestroy {
  // 新增:保存按钮点击事件流
  saveClick$ = new Subject<void>();
  // 新增:组件销毁流,用于自动取消订阅防止内存泄漏
  private destroy$ = new Subject<void>();

  ngOnInit(): void {
    // 初始化时订阅点击流,通过exhaustMap处理提交逻辑
    this.saveClick$
      .pipe(
        exhaustMap(() => this.handleSaveDeal()),
        takeUntil(this.destroy$)
      )
      .subscribe();
  }

  ngOnDestroy(): void {
    // 组件销毁时发射值,取消所有订阅
    this.destroy$.next();
    this.destroy$.complete();
  }

  // 把原有saveDeal的逻辑抽成返回Observable的方法,不要内部订阅
  private handleSaveDeal() {
    this.isLoading = true;
    let request$;
    if (!this.isExistingDeal) {
      const dealTypeValues = {
        "id": 0,
        "name": this.dealPLSFormFields.dealName,
        "summary": this.dealPLSFormFields.summary,
        "mlasId": this.dealPLSFormFields.partner,
        "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)
      }
      request$ = 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)
      }
      request$ = this._dealService.updateDeal(payload);
    }

    // 统一处理请求回调
    return request$.pipe(
      // 不需要额外加debounceTime,exhaustMap已经解决重复提交问题
      tap(res => {
        this.isLoading = false;
        if (!this.isExistingDeal) {
          this._notificationService.showSuccess('Deal was successfully created.');
        }
        if(res.isSuccess){
          this.refreshDealDetailsPage(res.data);
        }  
      }),
      catchError(err => {
        this.isLoading = false;
        console.log(this.isExistingDeal ? 'Error updating deal' : 'Error creating deal');
        // 返回空Observable避免点击流因为单次请求报错终止
        return of(null);
      })
    )
  }

  // 原有其他逻辑保留:cancelEdit等
}

第二步:修改HTML代码

仅需要修改保存按钮的点击事件绑定,把原来直接调用saveDeal改为往点击流发射值即可:

<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()">
    </app-page-section-cards-btn>
  </div>
</ng-template>

效果说明

改造完成后,无论用户点击多少次保存按钮,只要上一次的创建/更新请求还没返回,后续所有点击都会被直接忽略,只会执行一次提交逻辑,完全符合需求。

内容的提问来源于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 17:24:02