【译】在Angular中自定义表单控件

原文连接:Custom Form Controls in Angularhtml

在建立表单时,Angular能够帮助咱们完成不少事情。咱们已经介绍了有关Angular中的Forms的几个主题,例如模型驱动的表单和模板驱动的表单。若是您尚未阅读这些文章,咱们强烈建议您先去阅读这些文章,由于这篇文章是基于它们的。Almero Steyn是咱们的培训学生之一,后来做为Angular的文档编写团队的一员为正式文档作出了贡献,他还为建立自定义控件撰写了很是不错的介绍typescript

他的文章启发了咱们,咱们想更进一步,探讨如何建立与Angular的 form API很好地集成的自定义表单控件。json

自定义表单控件注意事项

在开始并构建本身的自定义表单控件以前,咱们要确保咱们对建立自定义表单控件时所起的做用有所了解。bootstrap

首先,重要的是要认识到,若是有一个原生元素(如<input type="number">)能够完美地完成工做,那么咱们不该该当即建立自定义表单控件。彷佛原生表单元素的功能经常被低估了。尽管咱们常常看到能够输入的文本框,但它为咱们带来了更多工做。每一个原生表单元素都是可访问的,有些输入具备内置的验证,有些甚至在不一样平台(例如移动浏览器)上提供了改进的用户体验。api

所以,每当考虑建立自定义表单控件时,咱们都应该问本身:数组

  • 是否存在具备相同语义的原生元素?
  • 若是是,咱们是否能够仅依靠该元素并使用CSS和/或渐进式加强功能来更改其外观/行为以知足咱们的需求?
  • 若是不是,自定义控件将是什么样?
  • 咱们如何使其可访问?
  • 在不一样平台上的行为是否不一样?
  • 如何验证?

可能还有更多要考虑的事情,但这是最重要的。若是确实要建立一个自定义表单控件(在Angular中),则应确保:浏览器

  • 它将更改正确传播到DOM / View
  • 它将更改正确传播到Model
  • 若是须要,它带有自定义验证
  • 它将有效性状态添加到DOM,以即可以设置样式
  • 可访问
  • 它适用于模板驱动的表单
  • 它适用于响应式的表单
  • 它须要响应灵敏

在本文中,咱们将讨论不一样的场景,以演示如何实现这些功能。不过,本文将不涉及可访问性,由于将有后续文章对此进行深刻讨论。angular2

建立一个自定义计数器

让咱们从一个很是简单的计数器组件开始。这个想法是要有一个组件,让咱们能够对 model 值递增和递减。是的,若是咱们考虑要考虑的事情,咱们可能会意识到一个 <input type="number">能够解决问题。闭包

可是,在本文中,咱们要演示如何实现自定义表单控件,而自定义计数器组件彷佛微不足道,以致于使事情看起来不太复杂。此外,咱们的计数器组件将具备不一样的外观,该外观在全部浏览器中均应相同,不管如何咱们均可能会受到原生input元素的限制。app

咱们从原始组件开始。咱们须要的是一个能够更改的 model 值和两个触发更改的按钮。

import { Component, Input } from '@angular/core';

@Component({
  selector: 'counter-input',
  template: ` <button (click)="increment()">+</button> {{counterValue}} <button (click)="decrement()">-</button> `
})
class CounterInputComponent {

  @Input()
  counterValue = 0;

  increment() {
    this.counterValue++;
  }

  decrement() {
    this.counterValue--;
  }
}
复制代码

这里没什么特别的。CounterInputComponent有一个counterValue,它被插入到模板中,能够分别经过increment()decrement()方法对其进行递增或递减。这个组件工做得很好,一旦在应用程序模块上声明了这个组件,咱们就可使用它,好比像这样将它放入另外一个组件中:

app.module.ts

@NgModule({
  imports: [BrowserModule],
  declarations: [AppComponent, CounterInputComponent],
  bootstrap: [AppComponent]
})
export class AppModule {}
复制代码

app.component.ts

import { Component } from '@angular/core';

@Component({
  selector: 'app-component',
  template: ` <counter-input></counter-input> `,
})
class AppComponent {}
复制代码

很好,可是如今咱们想使其与Angular的 Form API一块儿使用。理想状况下,咱们最终获得的是一个自定义控件,该控件可与模板驱动的表单和响应式驱动的表单一块儿使用。例如,在最简单的状况下,咱们应该可以建立一个模板驱动的表单,以下所示:

<!-- this doesn't work YET -->
<form #form="ngForm" (ngSubmit)="submit(form.value)">
  <counter-input name="counter" ngModel></counter-input>
  <button type="submit">Submit</button>
</form>
复制代码

若是您不熟悉该语法,请查看Angular中有关模板驱动表单的文章。好的,可是咱们怎么实现?咱们须要学习ControlValueAccessor是什么,由于Angular就是使用它来创建表单模型和DOM元素之间的联系。

了解ControlValueAccessor

虽然咱们的计数器组件有效,但目前尚没法将其链接到外部表单。实际上,若是咱们尝试将任何形式的表单模型绑定到咱们的自定义控件,则会收到错误消息,提示缺乏ControlValueAccessor。而这正是咱们实现与Angular中的表单进行正确集成所须要的。

那么,什么是ControlValueAccessor?好吧,还记得咱们以前谈到的实现自定义表单控件所需的内容吗?咱们须要确保的一件事是,更改从模型传播到视图/ DOM,也从视图传播回模型。这是ControlValueAccessor目的。

ControlValueAccessor是用于处理如下内容的接口:

  • 将表单模型中的值写入视图/ DOM
  • 当视图/ DOM更改时通知其余表单指令和控件

Angular之因此具备这样的界面,是由于DOM元素须要更新的方式可能因input类型而异。例如,普通文本输入框具备value属性,这个是一个须要被写入的属性,而复选框带有checked属性,这是一个须要更新的属性。若是咱们深刻了解,咱们意识到,每一个input类型都有一个ControlValueAccessor ,它知道如何更新其视图/ DOM。

DefaultValueAccessor用于处理文本输入和文本区域,SelectControlValueAccessor用于处理选择输入,CheckboxControlValueAccessor用于处理复选框等等。

咱们的计数组件须要一个ControlValueAccessor,它知道如何更新counterValue并告知外部变化的信息。一旦实现该接口,即可以与Angular表单进行对话。

实现ControlValueAccessor

ControlValueAccessor接口以下所示:

export interface ControlValueAccessor {
  writeValue(obj: any) : void
  registerOnChange(fn: any) : void
  registerOnTouched(fn: any) : void
}
复制代码

**writeValue(obj:any)**是将表单模型中的新值写入视图或DOM属性(若是须要)的方法。这是咱们要更新counterValue的地方,由于这就是视图中使用的东西。

**registerOnChange(fn:any)**是一种注册处理程序的方法,当视图中的某些内容发生更改时会调用该处理程序。它具备一个告诉其余表单指令和表单控件以更新其值的函数。换句话说,这就是咱们但愿counterValue在视图中进行更改时调用的处理程序函数。

registerOnChange()类似的**registerOnTouched(fn:any)**会注册一个专门用于当控件收到触摸事件时的处理程序。在咱们的自定义控件中不须要用到它。

ControlValueAccessor须要访问其控件的视图和模型,这意味着自定义表单控件自己必须实现该接口。让咱们从writeValue()开始。首先,咱们实现接口并更新类签名。

import { ControlValueAccessor } from '@angular/forms';

@Component(...)
class CounterInputComponent implements ControlValueAccessor {
  ...
}
复制代码

接下来,咱们实现writeValue()。如前所述,它从表单模型中获取一个新值并将其写入视图中。在咱们的例子中,咱们所须要作的只是更新的counterValue属性,由于它是自动插入的。

@Component(...)
class CounterInputComponent implements ControlValueAccessor {
  ...
  writeValue(value: any) {
    this.counterValue = value;
  }
}
复制代码

初始化表单时,将使用表单模型的初始值调用此方法。这意味着它将覆盖默认值0,这很好,可是若是咱们考虑前面提到的简单表单设置,咱们会意识到表单模型中没有初始值:

<counter-input name="counter" ngModel></counter-input>
复制代码

这将致使咱们的组件呈现一个空字符串。为了快速解决,咱们仅在不是undefined时设置该值:

writeValue(value: any) {
  if (value !== undefined) {
    this.counterValue = value;
  }
}
复制代码

如今,仅当有实际值写入控件时,它才会覆盖默认值。接下来,咱们实现registerOnChange()registerOnTouched()registerOnChange()能够通知外界组件内的变化。只要咱们愿意,每当在此处传播变动,就能够在这里作一些特殊的工做。registerOnTouched()注册了一个回调函数,只要表单控件是“touched”,该回调便会执行。例如,当 input 元素失去焦点时,它将触发 touch 事件。咱们不想在此事件上作任何事情,所以咱们可使用一个空函数来实现该接口。

@Component(...)
class CounterInputComponent implements ControlValueAccessor {
  ...
  propagateChange = (_: any) => {};

  registerOnChange(fn) {
    this.propagateChange = fn;
  }

  registerOnTouched() {}
}
复制代码

很好,咱们的计数器如今实现了该ControlValueAccessor接口。咱们须要作的下一件事是,只要counterValue在视图中进行更改,就调用propagateChange()。换句话说,若是单击increment()decrement()按钮,咱们但愿将新值传播到外界。

让咱们相应地更新这些方法。

@Component(...)
class CounterInputComponent implements ControlValueAccessor {
  ...
  increment() {
    this.counterValue++;
    this.propagateChange(this.counterValue);
  }

  decrement() {
    this.counterValue--;
    this.propagateChange(this.counterValue);
  }
}
复制代码

咱们可使用属性访问器使此代码更好一些。increment()decrement()这两种方法,每当counterValue变化时都会调用propagateChange()。让咱们使用 getter 和 setter 摆脱多余的代码:

@Component(...)
class CounterInputComponent implements ControlValueAccessor {
  ...
  @Input()
  _counterValue = 0; // 注意'_'

  get counterValue() {
    return this._counterValue;
  }

  set counterValue(val) {
    this._counterValue = val;
    this.propagateChange(this._counterValue);
  }

  increment() {
    this.counterValue++;
  }

  decrement() {
    this.counterValue--;
  }
}
复制代码

CounterInputComponent已经接近完成。即便它实现了ControlValueAccessor接口,也没有任何东西告诉Angular应该怎样作。咱们须要注册。

注册ControlValueAccessor

实现接口仅仅才完成了一半。众所周知,ES5中不存在接口,这意味着一旦代码被编译,该信息就消失了。所以,虽然咱们的组件实现了该接口,可是咱们仍然须要使 Angular 接受它。

在关于Angular中的多注册提供商的文章中,咱们了解到 Angular 使用了一些 DI 令牌来注入多个值,以便对它们进行某些处理。例如,有一个NG_VALIDATORS令牌为 Angular 提供了表单控件上全部已注册的验证器,咱们能够在其中添加本身的验证器。

为了让ControlValueAccessor控制表单控件,Angular内部注入了在NG_VALUE_ACCESSOR令牌上注册的全部值。所以,咱们须要作的就是扩展NG_VALUE_ACCESSOR的多注册提供商,让NG_VALUE_ACCESSOR使用咱们本身的值访问器实例(也就是咱们的组件)。

让咱们立刻试一下:

import { Component, Input, forwardRef } from '@angular/core';
import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';

@Component({
  ...
  providers: [
    { 
      provide: NG_VALUE_ACCESSOR,
      useExisting: forwardRef(() => CounterInputComponent),
      multi: true
    }
  ]
})
class CounterInputComponent {
  ...
}
复制代码

若是这段代码对您没有任何意义,您绝对应该查看那篇Angular中的多注册提供商的文章,但最重要的是,咱们正在将自定义的值访问器添加到 DI 系统,以便 Angular 能够拿到该值访问器的实例。咱们还必须使用useExisting,由于CounterInputComponent将在使用它的组件中,做为指令依赖建立。若是不这样作,则会获得一个新实例,由于这是 Angular 中 DI 的工做方式。forwardRef()回调函数将在这篇文章中进行解释。

太棒了,咱们的自定义表单控件如今可使用了!

在模板驱动的表单中使用它

咱们已经看到计数器组件能够按预期工做,可是如今咱们但愿将其放入实际表单中,并确保它在全部常见状况下均可以工做。

激活 Form API

正如咱们在Angular中模板驱动的表单文章中所讨论的那样,咱们须要像这样激活 Form API:

import { FormsModule} from '@angular/forms';

@NgModule({
  imports: [BrowserModule, FormsModule], // 在这里添加 FormsModule
  ...
})
export class AppModule {}
复制代码

没有模型初始化

差很少了!还记得咱们以前的AppComponent吗?让咱们在其中建立一个模板驱动的表单,看看它是否有效。这是一个使用计数器控件而不用值初始化的示例(它将使用本身的内部默认值:0):

@Component({
  selector: 'app-component',
  template: ` <form #form="ngForm"> <counter-input name="counter" ngModel></counter-input> </form> <pre>{{ form.value | json }}</pre> `
})
class AppComponent {}
复制代码

特别提示:使用json管道是调试表单值的好技巧。

form.value返回以JSON结构映射到其名称的全部表单控件的值。这就是为何JsonPipe会输出一个带有counter计数器值的对象字面量。

具备属性绑定的模型初始化

这是另外一个使用属性绑定将值绑定到自定义控件的示例:

@Component({
  selector: 'app-component',
  template: ` <form #form="ngForm"> <counter-input name="counter" [ngModel]="outerCounterValue"></counter-input> </form> <pre>{{ form.value | json }}</pre> `
})
class AppComponent {
  outerCounterValue = 5;  
}
复制代码

使用ngModel进行双向数据绑定

固然,咱们能够利用ngModel的双向数据绑定便可实现,只需将语法更改成此:

<p>ngModel value: {{outerCounterValue}}</p>
<counter-input name="counter" [(ngModel)]="outerCounterValue"></counter-input>
复制代码

多么酷啊?咱们的自定义表单控件可与模板驱动的表单API无缝配合!让咱们看看使用响应式表单时的表现。

在响应式表单中使用它

下面的示例使用 Angular 的响应式表单指令,因此不要忘记添加ReactiveFormsModuleAppModule,就像这篇文章中讨论的。

经过formControlName绑定值

一旦设置了表明表单模型的FormGroup,就能够将其绑定到表单元素,并使用formControlName关联每一个控件。此示例将值绑定到表单模型中的自定义表单控件:

@Component({
  selector: 'app-component',
  template: ` <form [formGroup]="form"> <counter-input formControlName="counter"></counter-input> </form> <pre>{{ form.value | json }}</pre> `
})
class AppComponent implements OnInit {

  form: FormGroup;

  constructor(private fb: FormBuilder) {}

  ngOnInit() {
    this.form = this.fb.group({
      counter: 5
    });
  }
}
复制代码

添加自定义验证

咱们要看的最后一件事是如何向咱们的自定义控件添加验证。实际上,咱们已经写了一篇关于Angular 中的自定义验证器的文章,全部须要了解的内容都写在这里。可是,为了使事情更清楚,咱们将经过示例向自定义表单控件中添加一个自定义验证器。

假设咱们要让控件在counterValue大于10或小于0时变为无效。以下所示:

import { NG_VALIDATORS, FormControl } from '@angular/forms';

@Component({
  ...
  providers: [
    { 
      provide: NG_VALIDATORS,
      useValue: (c: FormControl) => {
        let err = {
          rangeError: {
            given: c.value,
            max: 10,
            min: 0
          }
        };

        return (c.value > 10 || c.value < 0) ? err : null;
      },
      multi: true
    }
  ]
})
class CounterInputComponent implements ControlValueAccessor {
  ...
}
复制代码

咱们注册了一个验证器函数,若是控制值有效返回null,则返回该函数;不然,返回一个错误对象。这已经很好用了,咱们能够像这样显示错误消息:

<form [formGroup]="form">
  <counter-input formControlName="counter" ></counter-input>
</form>

<p *ngIf="!form.valid">Counter is invalid!</p>
<pre>{{ form.value | json }}</pre>
复制代码

使验证器可测试

不过,咱们能够作得更好。使用响应式表单时,咱们可能要在具备该表单功能但没有DOM的状况下测试组件。在这种状况下,验证器将不存在,由于它是由计数器组件提供的。经过将验证器函数提取到其本身的声明中并将其导出,能够轻松解决此问题,以便其余模块能够在须要时导入它。

让咱们将代码更改成:

export function validateCounterRange(c: FormControl) {
  let err = {
    rangeError: {
      given: c.value,
      max: 10,
      min: 0
    }
  };

  return (c.value > 10 || c.value < 0) ? err : null;
}

@Component({
  ...
  providers: [
    { 
      provide: NG_VALIDATORS,
      useValue: validateCounterRange,
      multi: true
    }
  ]
})
class CounterInputComponent implements ControlValueAccessor {
  ...
}
复制代码

特别提示:在构建响应式表单时,为了使验证器功能可用于其余模块,优良做法是先声明它们并在注册提供商的配置中引用它们。

如今,能够将验证器导入并添加到咱们的表单模型中,以下所示:

import { validateCounterRange } from './counter-input';

@Component(...)
class AppComponent implements OnInit {
  ...
  ngOnInit() {
    this.form = this.fb.group({
      counter: [5, validateCounterRange]
    });
  }
}
复制代码

这个自定义控件愈来愈好了,可是若是验证器是可配置的,那不是真的很酷吗!这样自定义表单控件的使用者能够决定最大和最小值是什么。

使验证可配置

理想状况下,咱们的自定义控件的使用者应该可以执行如下操做:

<counter-input formControlName="counter" counterRangeMax="10" counterRangeMin="0" ></counter-input>
复制代码

因为Angular的依赖项注入和属性绑定系统,这很是容易实现。基本上,咱们想要作的是让咱们的验证器具备依赖项

让咱们从添加输入属性开始。

import { Input } from '@angular/core';
...

@Component(...)
class CounterInputComponent implements ControlValueAccessor {
  ...
  @Input()
  counterRangeMax;

  @Input()
  counterRangeMin;
  ...
}
复制代码

接下来,咱们必须以某种方式将这些值传递给咱们的validateCounterRange(c: FormControl),可是对于每一个API,它们须要共用一个FormControl。这意味着咱们须要使用工厂模式来建立该验证器函数,该工厂建立一个以下所示的闭包:

export function createCounterRangeValidator(maxValue, minValue) {
  return function validateCounterRange(c: FormControl) {
    let err = {
      rangeError: {
        given: c.value,
        max: maxValue,
        min: minValue
      }
    };

    return (c.value > +maxValue || c.value < +minValue) ? err: null;
  }
}
复制代码

太好了,咱们如今可使用从组件内部的输入属性得到的动态值来建立验证器函数,并实现 Angular 中用于执行验证的validate()方法:

import { Input, OnInit } from '@angular/core';
...

@Component(...)
class CounterInputComponent implements ControlValueAccessor, OnInit {
  ...

  validateFn:Function;

  ngOnInit() {
    this.validateFn = createCounterRangeValidator(this.counterRangeMax, this.counterRangeMin);
  }

  validate(c: FormControl) {
    return this.validateFn(c);
  }
}
复制代码

这可行,但引入了一个新问题:validateFn仅在ngOnInit()中设置。若是counterRangeMaxcounterRangeMin经过绑定更改,该怎么办?咱们须要根据这些更改建立一个新的验证器函数。幸运的是,有一个ngOnChanges()生命周期挂钩可使咱们作到这一点。咱们要作的就是检查输入属性之一是否发生更改,而后从新建立咱们的验证函数。咱们甚至能够摆脱ngOnInit(),由于不管如何ngOnChanges()都会在ngOnInit()以前被调用:

import { Input, OnChanges } from '@angular/core';
...

@Component(...)
class CounterInputComponent implements ControlValueAccessor, OnChanges {
  ...

  validateFn:Function;

  ngOnChanges(changes) {
    if (changes.counterRangeMin || changes.counterRangeMax) {
      this.validateFn = createCounterRangeValidator(this.counterRangeMax, this.counterRangeMin);
    }
  }
  ...
}
复制代码

最后一点是,咱们须要更新验证器的提供商,由于它再也不只是一个函数,而是执行验证的组件自己:

@Component({
  ...
  providers: [
    ...
    { 
      provide: NG_VALIDATORS,
      useExisting: forwardRef(() => CounterInputComponent),
      multi: true
    }
  ]
})
class CounterInputComponent implements ControlValueAccessor, OnInit {
  ...
}
复制代码

信不信由你,咱们如今能够为自定义表单控件配置最大值和最小值!若是咱们要构建模板驱动的表单,则看起来就像这样:

<counter-input ngModel name="counter" counterRangeMax="10" counterRangeMin="0" ></counter-input>
复制代码

这也适用于表达式:

<counter-input ngModel name="counter" [counterRangeMax]="maxValue" [counterRangeMin]="minValue" ></counter-input>
复制代码

若是要构建响应式表单,则能够简单地使用验证器工厂将验证器添加到表单控件中,以下所示:

import { createCounterRangeValidator } from './counter-input';

@Component(...)
class AppComponent implements OnInit {
  ...
  ngOnInit() {
    this.form = this.fb.group({
      counter: [5, createCounterRangeValidator(10, 0)]
    });
  }
}
复制代码
相关文章
相关标签/搜索