Angular中如何使用exhaustMap防止按钮多次点击重复提交
使用exhaustMap防止表单重复提交方案
核心原理
exhaustMap是RxJS提供的高阶映射操作符,核心特性为:当上游触发新事件时,若当前内部处理的Observable尚未完成,会直接丢弃新事件,直到内部Observable结束后才会响应新的上游事件。完全适配按钮重复点击场景,无需手动维护提交锁,即可实现同一时间仅发起一次提交请求的效果。
最佳实践要点
- 将按钮点击事件统一封装为RxJS Subject流,所有点击事件送入该流统一处理,不要在点击回调中直接发起请求
- 点击流通过exhaustMap映射为后端请求Observable,自动丢弃请求未完成期间的所有点击事件
- 单个请求的异常要在流内部捕获,避免单次请求失败导致整个点击流终止,后续点击无法响应
- 组件销毁时主动取消点击流的订阅,避免内存泄漏
- 可配合按钮
disabled状态绑定加载变量,从UI层面给用户不可点击的反馈,形成双重保障
示例代码改造
改动说明
- 新增点击事件Subject接收保存按钮的点击动作
- 抽离原有保存逻辑为独立方法,返回请求Observable
- 在组件初始化阶段订阅点击流,通过exhaustMap处理请求映射
- 移除原有冗余的
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
相关产品推荐
相关产品推荐

