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
相关产品推荐
相关产品推荐

