Angular Material日期选择器valueChanges无法取消订阅问题求助
Angular Material Datepicker 日期格式化与订阅循环问题解决
问题背景
项目通过Contentstack的JSON文件配置Angular响应式表单及对应邮件模板,部分模板包含日期控件:
- 表单中日期显示为
MM/DD/YYYY格式(如08/31/2019),但邮件预览时会输出完整的Date对象字符串(如Thurs Aug 31 2019 00:00:00 GMT+0800),需要统一为MM/DD/YYYY格式。 - 尝试在
onChange方法中遍历含Date的表单控件,订阅其valueChanges并做日期格式化后,出现两个新问题:- 选择日期后日历弹窗无法自动关闭,需点击外部关闭。
- 代码持续触发调用直到调用栈上限,形成无限循环。
问题根源
- 重复订阅:每次触发
onChange(比如任何表单控件的change事件),都会重新订阅日期控件的valueChanges,导致多个重复订阅叠加。 - 无限循环:在
valueChanges的订阅回调中调用setValue修改控件值,会再次触发valueChanges事件,形成循环调用。 - 错误的处理时机:直接修改表单控件的原始值(把Date对象改成字符串),破坏了Angular Material Datepicker对Date类型的依赖,导致弹窗无法正常关闭。
解决方案
核心思路
保持表单控件的原始值为Date类型(符合Datepicker的要求),仅在显示层和邮件渲染层做格式化处理,避免修改控件值触发循环。
具体实现步骤
- 移除重复订阅与循环触发的代码
删除onChange方法中对valueChanges的订阅逻辑,避免重复订阅和循环调用。 - 统一日期显示格式
使用Angular Material提供的MAT_DATE_FORMATS自定义日期显示格式,让Datepicker输入框直接显示MM/DD/YYYY。 - 邮件预览时格式化日期
在生成邮件内容时,对表单中的Date类型值进行格式化,而非修改表单控件本身。
修改后的代码示例
1. 配置自定义日期格式(新增或修改模块配置)
import { NgModule } from '@angular/core'; import { MatDatepickerModule } from '@angular/material/datepicker'; import { MatNativeDateModule } from '@angular/material/core'; import { DateAdapter, MAT_DATE_FORMATS, MAT_DATE_LOCALE } from '@angular/material/core'; import { NativeDateAdapter } from '@angular/material/core'; // 自定义日期格式 export const MY_DATE_FORMATS = { parse: { dateInput: 'MM/DD/YYYY', }, display: { dateInput: 'MM/DD/YYYY', monthYearLabel: 'MMM YYYY', dateA11yLabel: 'LL', monthYearA11yLabel: 'MMMM YYYY', }, }; @NgModule({ imports: [MatDatepickerModule, MatNativeDateModule], providers: [ { provide: MAT_DATE_LOCALE, useValue: 'en-US' }, { provide: DateAdapter, useClass: NativeDateAdapter }, { provide: MAT_DATE_FORMATS, useValue: MY_DATE_FORMATS }, ], }) export class DatepickerConfigModule {}
2. 修改组件TS代码
import { Component, EventEmitter, Input, OnDestroy, OnInit, Output } from '@angular/core'; import { FormBuilder, FormGroup, Validators } from '@angular/forms'; import { ContentStackService } from 'src/app/services/content-stack.service'; import { Customer } from 'src/app/models/customer.model'; import { AdditionalEmailTemplateDefintionResponse, Control, TemplateDefinition } from 'src/app/models/additional-email-template-definition.model'; import { formatDate } from '@angular/common'; import { Subscription } from 'rxjs'; @Component({ selector: 'app-ssp-send-additional-email-template-detail', templateUrl: './ssp-additional-email-template-detail.component.html', styleUrls: ['./ssp-additional-email-template-detail.component.scss'], }) export class SspSendAdditionalEmailTemplateDetailComponent implements OnInit, OnDestroy { @Input() customer : Customer; @Input() selectedTemplateId:string; @Input() formGroup: FormGroup; @Input() productType: string; @Input() stateCode: string; @Output() onTemplateDetailedFilled = new EventEmitter(); @Output() templateDefinitionEmitter = new EventEmitter<TemplateDefinition>(); customerInfoLookUp = new Map<string, string>(); public templateDefinition!: TemplateDefinition; private subscriptions: Subscription[] = []; // 用数组管理所有订阅 constructor( private contentStackService: ContentStackService, private fb: FormBuilder) {} ngOnInit(): void { console.log('*****SspSendAdditionalEmailTemplateDetailComponent****'); console.log('input customer', this.customer); this.buildCustomerLookup(); const templateSub = this.contentStackService.getAdditionalEmailTemplateDefinition(this.selectedTemplateId) .subscribe((formData: AdditionalEmailTemplateDefintionResponse) => { console.log('getAdditionalEmailTemplateDefinition', formData); this.templateDefinition = formData.template_definition; this.createForm(formData.template_definition.controls); this.templateDefinitionEmitter.emit(this.templateDefinition); }); this.subscriptions.push(templateSub); } createForm(controls: Control[]) { for (const control of controls) { const validatorsToAdd = []; for (const [key, value] of Object.entries(control.validators)) { switch (key) { case 'min': validatorsToAdd.push(Validators.min(value)); break; case 'max': validatorsToAdd.push(Validators.max(value)); break; case 'required': if (value) { validatorsToAdd.push(Validators.required); } break; case 'requiredTrue': if (value) { validatorsToAdd.push(Validators.requiredTrue); } break; case 'email': if (value) { validatorsToAdd.push(Validators.email); } break; case 'minLength': validatorsToAdd.push(Validators.minLength(value)); break; case 'maxLength': validatorsToAdd.push(Validators.maxLength(value)); break; case 'pattern': validatorsToAdd.push(Validators.pattern(value)); break; case 'nullValidator': if (value) { validatorsToAdd.push(Validators.nullValidator); } break; default: break; } } let controlValue = control.value; if (control.type === 'date') { controlValue = new Date(); // 初始化日期为当前时间 } else if (control.path && control.path.split('.')[0] === 'customer') { const pathSplit = control.path.split('.'); controlValue = this.customerInfoLookUp.get(pathSplit[1]); } this.formGroup.addControl( control.name, this.fb.control(controlValue, validatorsToAdd), ); } // 订阅整个表单的valueChanges,用于验证状态通知 const formSub = this.formGroup.valueChanges.subscribe(() => { this.onTemplateDetailedFilled.emit(this.formGroup.valid); }); this.subscriptions.push(formSub); } buildCustomerLookup() { this.customerInfoLookUp.set('firstName', this.customer.person.personNameList[0].firstName); this.customerInfoLookUp.set('lastName', this.customer.person.personNameList[0].lastName); } // 用于生成邮件预览的格式化方法 formatFormValueForEmail(formValue: any): any { const formattedValue = {...formValue}; Object.keys(formattedValue).forEach(key => { if (formattedValue[key] instanceof Date) { formattedValue[key] = formatDate(formattedValue[key], 'MM/dd/YYYY', 'en-US'); } }); return formattedValue; } ngOnDestroy(): void { // 取消所有订阅 this.subscriptions.forEach(sub => sub.unsubscribe()); } }
3. 修改组件HTML代码(移除不必要的change事件绑定)
<ng-container [formGroup]="formGroup" *ngFor="let control of templateDefinition?.controls" class="ssp-template-detail-full-width"> <!-- input type text--> <mat-form-field *ngIf="[ 'text', 'password', 'email', 'number', 'search', 'tel', 'url' ].includes(control.type)" class="ssp-template-detail-full-width"> <mat-label>{{control.label}}</mat-label> <input matInput [id]="control.name" [formControlName]="control.name" [type]="control.type"> </mat-form-field> <!-- input type textarea --> <mat-form-field *ngIf="[ 'textarea' ].includes(control.type)" class="ssp-template-detail-full-width"> <mat-label>{{control.label}}</mat-label> <textarea matInput [id]="control.name" [formControlName]="control.name"></textarea> </mat-form-field> <!-- checkbox--> <p *ngIf="[ 'checkbox' ].includes(control.type)" class="ssp-template-detail-full-width"> <mat-checkbox [id]="control.name" [formControlName]="control.name">{{control.label}}</mat-checkbox> </p> <!-- dropdown--> <mat-form-field *ngIf="[ 'dropdown' ].includes(control.type)" class="ssp-template-detail-full-width"> <mat-label>{{control.label}}</mat-label> <mat-select [id]="control.name" [formControlName]="control.name"> <mat-option *ngFor="let option of control.options" [value]="option.value"> {{option.option}} </mat-option> </mat-select> </mat-form-field> <!-- date --> <mat-form-field *ngIf="[ 'date' ].includes(control.type)" class="ssp-template-detail-full-width"> <mat-label>{{control.label}}</mat-label> <input matInput [matDatepicker]="picker" [id]="control.name" [formControlName]="control.name"> <mat-datepicker-toggle matSuffix [for]="picker"></mat-datepicker-toggle> <mat-datepicker #picker></mat-datepicker> <mat-hint>MM/DD/YYYY</mat-hint> </mat-form-field> <!-- input type text--> <mat-form-field *ngIf="[ 'time' ].includes(control.type)" class="ssp-template-detail-full-width"> <mat-label>{{control.label}}</mat-label> <input matInput [id]="control.name" [formControlName]="control.name" type="text"> </mat-form-field> </ng-container>
关键说明
- 自定义
MAT_DATE_FORMATS后,Datepicker会自动以MM/DD/YYYY格式显示日期,同时保持控件值为Date类型,避免破坏组件内部逻辑。 - 通过
formatFormValueForEmail方法在邮件渲染时统一格式化日期,不修改表单原始值,彻底避免循环触发问题。 - 使用数组管理所有订阅,在
ngOnDestroy中统一取消,避免内存泄漏。
内容的提问来源于stack exchange,提问作者apex2022
相关产品推荐
相关产品推荐

