抽象「上帝组件」时遇MatFormFieldControl运行时错误的解决方法
mat-form-field must contain a MatFormFieldControl错误 我接手的客户项目里有个生成表单字段的「上帝组件」,非移动端用Material,移动端用Ionic,逻辑复杂。它支持多种字段类型(文本框、文本域、下拉框等),可传入占位符、最值等属性,使用时只需:
<god-component type="textarea" placeholder="Something cool" controlName="SomethingCool"></god-component>
但这个组件的问题是修改后必须全量回归测试所有输入类型,风险很高。我需要重构它,要求保留原组件的所有输入属性和对外接口,同时把不同控件类型拆成独立子组件,重构步骤:
- 创建非移动端表单字段包装组件,负责标签、前缀、后缀,用
ng-content渲染子控件 - 创建移动端表单字段包装组件,实现相同功能
- 创建文本域子组件,根据环境切换带
matInput的textarea或ion-textarea - 在「上帝组件」中替换文本域相关逻辑,用上包装组件和子组件
现有代码片段
非移动端表单字段包装组件
<mat-form-field [ngClass]="{ 'condensed': condensed }" [floatLabel]="condensed ? 'never' : 'auto'"> <!-- Label --> <mat-label *ngIf="label">{{label}}</mat-label> <!-- Prefix --> <span *ngIf="prefix" matPrefix>{{prefix}}</span> <!-- Control --> <ng-content></ng-content> <!-- Suffix --> <span *ngIf="suffix" matSuffix>{{suffix}}</span> </mat-form-field>
文本域子组件
<textarea *ngIf="!isMobile; else isMobileContainer" matInput [placeholder]="placeholder" [formControl]="control"> </textarea> <ng-template #isMobileContainer> <ion-textarea [placeholder]="placeholder" [formControl]="control"> </ion-textarea> </ng-template>
上帝组件修改后的部分代码
<ng-container *ngIf="control && type && isProofOfConcept"> <span>Proof of Concept</span> <!-- non mobile --> <form-field-wrapper *ngIf="!isMobile; else isMobileFormField" [label]="label" [prefix]="prefix" [suffix]="suffix"> <ng-container [ngSwitch]="type"> <ng-container *ngSwitchCase="'textarea'" [ngTemplateOutlet]="textAreaTemplate"></ng-container> </ng-container> </form-field-wrapper> <!-- mobile --> <ng-template #isMobileFormField> <mobile-form-field-wrapper [label]="label" [prefix]="prefix" [suffix]="suffix"> <ng-container [ngSwitch]="type"> <ng-container *ngSwitchCase="'textarea'" [ngTemplateOutlet]="textAreaTemplate"></ng-container> </ng-container> </mobile-form-field-wrapper> </ng-template> <!-- template --> <ng-template #textAreaTemplate> <textarea-child [placeholder]="placeholder" [control]="control"> </textarea-child> </ng-template> </ng-container>
运行时错误
mat-form-field must contain a MatFormFieldControl.
虽然最终DOM里带matInput的textarea是mat-form-field的直接子元素,但推测是因为包装组件用了ng-content,加上上帝组件里的ngSwitch和ngTemplateOutlet,导致Material无法识别内部的MatFormFieldControl。
需要在保留原「上帝组件」接口、避免大量代码复用的前提下,解决这个问题,完成控件抽象并符合Material规则。
方案1:让文本域子组件实现MatFormFieldControl接口
Material的mat-form-field需要直接识别到实现了MatFormFieldControl的控件,你的textarea-child目前只是包裹原生textarea,没有实现该接口,导致Material无法检测到。
步骤:
- 在
textarea-child组件中实现MatFormFieldControl接口,同时注入MatFormField(可选,用于关联表单字段) - 实现接口要求的所有属性和方法,比如
value、stateChanges、focused等 - 注册该组件为
MatFormFieldControl提供者
代码示例(textarea-child.component.ts):
import { Component, Input, HostBinding, Optional, Self, OnInit } from '@angular/core'; import { MatFormFieldControl, MatFormField } from '@angular/material/form-field'; import { FormControl, NgControl } from '@angular/forms'; import { Subject } from 'rxjs'; @Component({ selector: 'textarea-child', templateUrl: './textarea-child.component.html', providers: [{ provide: MatFormFieldControl, useExisting: TextareaChildComponent }] }) export class TextareaChildComponent implements MatFormFieldControl<string>, OnInit { @Input() control: FormControl; @Input() placeholder: string; @Input() isMobile = false; // MatFormFieldControl 必需属性 value: string | null = ''; stateChanges = new Subject<void>(); focused = false; empty = true; shouldLabelFloat = true; required = false; disabled = false; errorState = false; controlType = 'textarea-child'; @HostBinding('class.floating') get shouldLabelFloatClass() { return this.shouldLabelFloat; } constructor( @Optional() @Self() public ngControl: NgControl, @Optional() public matFormField: MatFormField ) { if (this.ngControl != null) { this.ngControl.valueAccessor = this; } } ngOnInit() { // 监听表单控件变化,更新自身状态 this.control.valueChanges.subscribe(value => { this.value = value; this.empty = !value; this.stateChanges.next(); }); } // MatFormFieldControl 必需方法 setDescribedByIds(ids: string[]): void {} onContainerClick(event: MouseEvent): void {} // 实现 ControlValueAccessor 方法(因为 ngControl 绑定) writeValue(value: string): void { this.value = value; this.control.setValue(value); } registerOnChange(fn: any): void { this.control.valueChanges.subscribe(fn); } registerOnTouched(fn: any): void {} setDisabledState(isDisabled: boolean): void { this.disabled = isDisabled; this.control.disable({ emitEvent: false }); this.stateChanges.next(); } // 处理焦点变化 onFocus() { this.focused = true; this.stateChanges.next(); } onBlur() { this.focused = false; this.stateChanges.next(); } }
修改textarea-child的模板,添加焦点事件绑定:
<textarea *ngIf="!isMobile; else isMobileContainer" matInput [placeholder]="placeholder" [formControl]="control" (focus)="onFocus()" (blur)="onBlur()"> </textarea> <ng-template #isMobileContainer> <ion-textarea [placeholder]="placeholder" [formControl]="control" (ionFocus)="onFocus()" (ionBlur)="onBlur()"> </ion-textarea> </ng-template>
这样修改后,textarea-child会被Material识别为合法的MatFormFieldControl,即使通过ng-content和ngTemplateOutlet嵌套,也能被正确检测到。
方案2:调整组件结构,避免多层模板嵌套
如果不想实现MatFormFieldControl,可以调整上帝组件的结构,让mat-form-field直接包裹textarea-child,而不是通过form-field-wrapper的ng-content传递。
优化后的上帝组件代码:
<ng-container *ngIf="control && type && isProofOfConcept"> <span>Proof of Concept</span> <!-- non mobile --> <ng-container *ngIf="!isMobile; else isMobileFormField"> <mat-form-field [ngClass]="{ 'condensed': condensed }" [floatLabel]="condensed ? 'never' : 'auto'"> <mat-label *ngIf="label">{{label}}</mat-label> <span *ngIf="prefix" matPrefix>{{prefix}}</span> <ng-container [ngSwitch]="type"> <textarea-child *ngSwitchCase="'textarea'" [placeholder]="placeholder" [control]="control" [isMobile]="false"> </textarea-child> </ng-container> <span *ngIf="suffix" matSuffix>{{suffix}}</span> </mat-form-field> </ng-container> <!-- mobile --> <ng-template #isMobileFormField> <!-- 移动端Ionic的包装结构 --> <ion-item> <ion-label *ngIf="label" position="floating">{{label}}</ion-label> <ng-container [ngSwitch]="type"> <textarea-child *ngSwitchCase="'textarea'" [placeholder]="placeholder" [control]="control" [isMobile]="true"> </textarea-child> </ng-container> </ion-item> </ng-template> </ng-container>
这种方式虽然会让上帝组件里重复一些包装代码,但可以通过创建模板片段来减少重复,比如把标签、前缀、后缀的逻辑抽成模板:
<!-- 定义通用模板片段 --> <ng-template #desktopFieldTemplate let-label="label" let-prefix="prefix" let-suffix="suffix" let-condensed="condensed"> <mat-form-field [ngClass]="{ 'condensed': condensed }" [floatLabel]="condensed ? 'never' : 'auto'"> <mat-label *ngIf="label">{{label}}</mat-label> <span *ngIf="prefix" matPrefix>{{prefix}}</span> <ng-content></ng-content> <span *ngIf="suffix" matSuffix>{{suffix}}</span> </mat-form-field> </ng-template> <!-- 使用模板 --> <ng-container *ngIf="!isMobile"> <ng-template [ngTemplateOutlet]="desktopFieldTemplate" [ngTemplateOutletContext]="{ label: label, prefix: prefix, suffix: suffix, condensed: condensed }"> <textarea-child *ngIf="type === 'textarea'" [placeholder]="placeholder" [control]="control" [isMobile]="false"> </textarea-child> </ng-template> </ng-container>
这样既避免了代码重复,又让mat-form-field直接包含了带matInput的控件,符合Material的要求。
方案3:使用ContentChild在包装组件中显式传递控件
在form-field-wrapper组件中,通过ContentChild获取内部的MatFormFieldControl,辅助Material识别控件,适合嵌套层级不深的场景。
修改form-field-wrapper.component.ts:
import { Component, Input, ContentChild, AfterContentInit } from '@angular/core'; import { MatFormFieldControl } from '@angular/material/form-field'; @Component({ selector: 'form-field-wrapper', templateUrl: './form-field-wrapper.component.html' }) export class FormFieldWrapperComponent implements AfterContentInit { @Input() label: string; @Input() prefix: string; @Input() suffix: string; @Input() condensed = false; @ContentChild(MatFormFieldControl) control: MatFormFieldControl<any>; ngAfterContentInit() { // 确保控件被Material正确检测 } }
这种方式可以辅助Material检测到内部控件,但如果是通过ngTemplateOutlet动态插入的控件,建议结合方案1的自定义控件实现彻底解决问题。
内容的提问来源于stack exchange,提问作者David

