在Angular(2+)中封装一个FormControl

33

我正在尝试在Angular(v5)中创建自定义表单控件。该自定义控件本质上是包装了一个Angular Material组件,但还有一些额外的内容。

我已经阅读了各种实现ControlValueAccessor的教程,但没有找到任何关于编写包装现有组件的组件的内容。

理想情况下,我希望有一个自定义组件来显示Angular Material组件(其中有一些额外的绑定和其他东西),但是能够从父表单传递验证(例如required),并让Angular Material组件处理它。

示例:

外部组件,包含一个表单,并使用自定义组件

<form [formGroup]="myForm">
    <div formArrayName="things">
        <div *ngFor="let thing of things; let i = index;">
            <app-my-custom-control [formControlName]="i"></app-my-custom-control>
        </div>
    </div>
</form>

自定义组件模板

本质上,我的自定义表单组件只是将 Angular Material 下拉框与自动完成组合在一起。我可以不创建自定义组件来完成此操作,但以这种方式进行操作似乎更有意义,因为处理过滤等所有代码都可以存在于该组件类中,而不必存在于容器类中(后者不需要关心其实现)。

<mat-form-field>
  <input matInput placeholder="Thing" aria-label="Thing" [matAutocomplete]="thingInput">
  <mat-autocomplete #thingInput="matAutocomplete">
    <mat-option *ngFor="let option of filteredOptions | async" [value]="option">
      {{ option }}
    </mat-option>
  </mat-autocomplete>
</mat-form-field>

因此,当 input 更改时,该值应作为表单值使用。

我尝试过的事情

我尝试了几种方法来做到这一点,都有自己的缺陷:

简单的事件绑定

input 上绑定 keyupblur 事件,然后通知父项发生更改(即调用 Angular 传递给实现 ControlValueAccessorregisterOnChange 中的函数)。

这种方法基本可行,但在从下拉列表中选择一个值时,似乎不会触发更改事件,导致处于不一致状态。

它也没有考虑验证(例如,如果是 "必填",当值未设置时,表单控件将正确无效,但 Angular Material 组件不会显示为无效)。

嵌套表单

这是更接近的方法。我在自定义组件类中创建了一个新表单,其中只有一个控件。在组件模板中,我将该表单控件传递给 Angular Material 组件。在类中,我订阅该控件的 valueChanges,然后将更改传播回父组件(通过传递给 registerOnChange 的函数)。

这种方法也基本可行,但感觉有点凌乱,应该有更好的方法。

这也意味着,任何应用于我的自定义表单控件(由容器组件)的验证都会被忽略,因为我创建了一个缺少原始验证的新 "内部表单"。

根本不使用 ControlValueAccessor,而是直接传递表单

就像标题所说...我尝试不按照“正确”的方式进行操作,而是向父表单添加了一个绑定。然后,在自定义组件中创建一个表单控件作为该父表单的一部分。

这对于处理值更新以及在一定程度上进行验证(但必须作为组件的一部分而不是父表单创建)有效,但这感觉不对。

摘要

什么是正确处理此问题的方法?感觉我只是在不同的反模式中摸索,但我找不到任何文档表明甚至支持这样做。


5
您好,我已经阅读了这篇文章,虽然它很有价值,但我不确定它与此相关。文章讨论的是如何包装第三方非Angular组件,而我正在尝试包装一个已经实现ControlValueAccessor的Angular组件(它是围绕Angular Material的包装器)。因此,需要将其包装起来,并将任何验证处理传递给内部的Angular Material组件。 - Tom Seldon
1
如果我直接在表单中使用Angular Material组件并将表单控件附加到它上面,那么它会处理验证的UI方面(即当无效时显示为红色等)。我想包装Angular Material组件,因为还有其他一些东西可以很好地封装起来,但仍然希望Angular Material组件根据需要显示为有效/无效。所以:Angular Material <- 自定义控件 <- 包装器组件(包含表单) - Tom Seldon
我最终放弃了所有的ControlValueAccessor内容,而是只将FormGroup和FormControl从容器组件传递到自定义控件组件,并将表单控件传递给Angular Material组件。这感觉不对,但它确实有效。 - Tom Seldon
2
Tom,你能否把你的解决方案作为一个答案添加吗?我正在遇到非常相似的问题。 - Phil Degenhardt
显示剩余2条评论
5个回答

17

编辑:

我已经在我开始的一个Angular实用库 s-ng-utils 中添加了一个帮助程序,可以轻松完成此操作。使用该程序,您可以扩展 WrappedFormControlSuperclass 并编写:

@Component({
  selector: 'my-wrapper',
  template: '<input [formControl]="formControl">',
  providers: [provideValueAccessor(MyWrapper)],
})
export class MyWrapper extends WrappedFormControlSuperclass<string> {
  // ...
}

在您自己的组件中,一种解决方案是获取与内部表单组件的ControlValueAccessor对应的@ViewChild(),并将其委派给它。例如:


查看更多文档,请在此处

@Component({
  selector: 'my-wrapper',
  template: '<input ngDefaultControl>',
  providers: [
    {
      provide: NG_VALUE_ACCESSOR,
      useExisting: forwardRef(() => NumberInputComponent),
      multi: true,
    },
  ],
})
export class MyWrapper implements ControlValueAccessor {
  @ViewChild(DefaultValueAccessor) private valueAccessor: DefaultValueAccessor;

  writeValue(obj: any) {
    this.valueAccessor.writeValue(obj);
  }

  registerOnChange(fn: any) {
    this.valueAccessor.registerOnChange(fn);
  }

  registerOnTouched(fn: any) {
    this.valueAccessor.registerOnTouched(fn);
  }

  setDisabledState(isDisabled: boolean) {
    this.valueAccessor.setDisabledState(isDisabled);
  }
}

上面模板中的ngDefaultControl是为了手动触发Angular将其正常的DefaultValueAccessor附加到输入框。如果您使用<input ngModel>,则会自动发生这种情况,但我们这里不需要ngModel,只需要值访问器。您需要将上面的DefaultValueAccessor更改为材料下拉菜单的值访问器 - 我本人不熟悉Material。


这听起来很理想,但是 OP 在页面上使用了 select。 DefaultValueAccessor 有一个 [ngDefaultControl] 选择器,但 SelectControlValueAccessor 没有类似的选择器。不幸的是,在这种情况下我找不到任何让这个解决方案工作的方法(我自己也几乎和 OP 做了完全相同的事情)。 - pbristow
3
我相信使用s-ng-utils中的WrappedFormControlSuperclass更新我的答案后,这应该不成问题。 - Eric Simonton

14

虽然我来晚了,但这是我用于包装组件的方法,可以接受formControlNameformControlngModel

@Component({
  selector: 'app-input',
  template: '<input [formControl]="control">',
  styleUrls: ['./app-input.component.scss']
})
export class AppInputComponent implements OnInit, ControlValueAccessor {
  constructor(@Optional() @Self() public ngControl: NgControl) {
    if (this.ngControl != null) {
      // Setting the value accessor directly (instead of using the providers) to avoid running into a circular import.
      this.ngControl.valueAccessor = this;
    }
  }

  control: FormControl;

  // These are just to make Angular happy. Not needed since the control is passed to the child input
  writeValue(obj: any): void { }
  registerOnChange(fn: (_: any) => void): void { }
  registerOnTouched(fn: any): void { }

  ngOnInit() {
    if (this.ngControl instanceof FormControlName) {
      const formGroupDirective = this.ngControl.formDirective as FormGroupDirective;
      if (formGroupDirective) {
        this.control = formGroupDirective.form.controls[this.ngControl.name] as FormControl;
      }
    } else if (this.ngControl instanceof FormControlDirective) {
      this.control = this.ngControl.control;
    } else if (this.ngControl instanceof NgModel) {
      this.control = this.ngControl.control;
      this.control.valueChanges.subscribe(x => this.ngControl.viewToModelUpdate(this.control.value));
    } else if (!this.ngControl) {
      this.control = new FormControl();
    }
  }
}

显然,不要忘记取消订阅this.control.valueChanges


谢谢你分享这个技巧...但是这不会有点太hacky了吗?我感觉我们在使用一些非官方的“API”和属性,如果我错了,请纠正我。 - Georgi
1
这都是公开的API,您只是将任务委托给已经实例化的类。 - Maxim Balaganskiy
只是提醒一下:你不会忘记管理那些订阅吧? 看起来有很多泄漏的情况发生了... - slimbofat
也许吧。我会更新答案来指出这一点。 - Maxim Balaganskiy
也许有点晚了,但是进场方式真是惊艳呀 :-) - Dries Van Hansewijck

6
我已经思考了一段时间,找到了一个很好的解决方案,与Eric的解决方案非常相似(或者是相同的)。他忘记考虑的事情是,在视图实际加载之前,不能使用@ViewChild valueAccessor(请参阅@ViewChild docs)。
以下是解决方案:(我提供了一个示例,将核心angular选择指令与NgModel包装在一起,因为您正在使用自定义formControl,所以需要针对该formControl的valueAccessor类进行定位)。
@Component({
  selector: 'my-country-select',
  templateUrl: './country-select.component.html',
  styleUrls: ['./country-select.component.scss'],
  providers: [{
    provide: NG_VALUE_ACCESSOR,
    useExisting:  CountrySelectComponent,
    multi: true
  }]
})
export class CountrySelectComponent implements ControlValueAccessor, OnInit, AfterViewInit, OnChanges {

  @ViewChild(SelectControlValueAccessor) private valueAccessor: SelectControlValueAccessor;

  private country: number;
  private formControlChanged: any;
  private formControlTouched: any;

  public ngAfterViewInit(): void {
    this.valueAccessor.registerOnChange(this.formControlChanged);
    this.valueAccessor.registerOnTouched(this.formControlTouched);
  }

  public registerOnChange(fn: any): void {
    this.formControlChanged = fn;
  }

  public registerOnTouched(fn: any): void {
    this.formControlTouched = fn;
  }

  public writeValue(newCountryId: number): void {
    this.country = newCountryId;
  }

  public setDisabledState(isDisabled: boolean): void {
    this.valueAccessor.setDisabledState(isDisabled);
  }
}

1
这个例子中的模板部分怎么样? - ZecKa

0

基于@Maxim Balaganskiy的答案,这里是一个你可以在ng>14中使用的基础组件。

你只需要扩展这个组件,将formControl暴露给你的子包装输入即可:

示例:

@Component({
  imports: [ReactiveFormsModule],
  selector: 'med-input',
  standalone: true,
  template: '<input #testInput [formControl]="formControl" >',
})
export class TestInputComponent extends BaseFormControlComponent<string> {}

base-form-control.component.ts

import { Component, inject, OnInit } from '@angular/core';
import { ControlValueAccessor, FormControl, FormControlDirective, FormControlName, NgControl, NgModel } from '@angular/forms';

@Component({
  template: '',
})
export class BaseFormControlComponent<T> implements ControlValueAccessor, OnInit, OnDestroy {
  public formControl: FormControl<T>;
  private onDestroy$ = new Subject<void>();
  private ngControl = inject(NgControl, { optional: true, self: true });

  constructor() {
    if (!!this.ngControl) {
      this.ngControl.valueAccessor = this;
    }
  }

  // These are just to make Angular happy. Not needed since the control is passed to the child input
  writeValue(obj: any): void {}

  registerOnChange(fn: (_: any) => void): void {}

  registerOnTouched(fn: any): void {}

  ngOnDestroy(): void {
    this.onDestroy$.next();
    this.onDestroy$.complete();
  }

  ngOnInit(): void {
    this.formControl = this.buildFormControl();
  }

  private buildFormControl() {
    if (this.ngControl instanceof FormControlDirective) {
      return this.ngControl.control;
    }

    if (this.ngControl instanceof FormControlName) {
      return this.ngControl.formDirective.form.controls[this.ngControl.name];
    }

    if (this.ngControl instanceof NgModel) {
      const control = this.ngControl.control;
      control.valueChanges.pipe(takeUntil(this.onDestroy$)).subscribe((val) => this.ngControl.viewToModelUpdate(control.value));
      return control;
    }

    return new FormControl<T>(null);
  }
}

奖励:

base-form-control.component.spec.ts

import { BaseFormControlComponent } from './base-form-control.component';
import { Component } from '@angular/core';
import { MockBuilder, MockRender, ngMocks } from 'ng-mocks';
import { FormControl, FormGroup, FormsModule, ReactiveFormsModule } from '@angular/forms';

@Component({
  imports: [ReactiveFormsModule],
  selector: 'med-input',
  standalone: true,
  template: '<input #testInput [formControl]="formControl" >',
})
export class TestInputComponent extends BaseFormControlComponent<string> {}

describe('BaseFormControlComponentComponent', () => {
  beforeEach(() => MockBuilder(TestInputComponent).keep(ReactiveFormsModule).keep(FormsModule));

  it('should create', () => {
const fixture = MockRender(TestInputComponent);
fixture.detectChanges();
expect(fixture.componentInstance).toBeTruthy();
  });

  it(`should set the input's value from ngModel`, async () => {
const fixture = MockRender(`<med-input [ngModel]='foo'></med-input>`, { foo: 'bar' });
await fixture.whenStable();
const input = ngMocks.find('input');
expect(input.nativeElement.value).toContain('bar');
  });

  it(`should set the input's value from a formController`, async () => {
const fixture = MockRender(`<med-input [formControl]='formControl'></med-input>`, { formControl: new FormControl('bar') });
await fixture.whenStable();
const input = ngMocks.find('input');
expect(input.nativeElement.value).toContain('bar');
  });

  it('should get the input', async () => {
const fixture = MockRender(`<form [formGroup]='formGroup' ><med-input formControlName='foo'></med-input></form>`, {
  formGroup: new FormGroup({ foo: new FormControl('bar') }),
});
fixture.detectChanges();

await fixture.whenStable();
const input = ngMocks.find('input');
expect(input.nativeElement.value).toContain('bar');
  });

  it(`should disable the input value from ngModel`, async () => {
const fixture = MockRender(`<med-input [disabled]='true' [ngModel]='foo'></med-input>`, { foo: 'bar' });
await fixture.whenStable();
const input = ngMocks.find('input');
expect(input.nativeElement.disabled).toBeTruthy();
  });
});


-3

NgForm提供了一种简单的方式来管理您的表单,而无需在HTML表单中注入任何数据。输入数据必须在组件级别注入,而不是在经典的HTML标签中。

<form #myForm="ngForm" (ngSubmit)="onSubmit(myForm)>...</form>

另一种方法是创建一个表单组件,使用ngModel绑定所有数据模型 ;)

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接