欢迎各位兄弟 发布技术文章

这里的技术是共享的

You are here

Angular 4 自定义表单控件 有大用 有大大用 有大大大用

Angular 4 自定义表单控件

0.126            字数 2629阅读 867        

当我们打算自定义表单控件前,我们应该先考虑一下以下问题:

  • 是否已经有相同语义的 native (本机) 元素?如:<input type="number">

  • 如果有,我们就应该考虑能否依赖该元素,仅使用 CSS 或渐进增强的方式来改变其外观/行为就能满足我们的需求?

  • 如果没有,自定义控件会是什么样的?

  • 我们如何让它可以访问 (accessible)?

  • 在不同平台上自定义控件的行为是否有所不同?

  • 自定义控件如何实现数据验证功能?

可能还有很多事情需要考虑,但如果我们决定使用 Angular 创建自定义控件,就需要考虑以下问题:

  • 如何实现 model -> view 的数据绑定?

  • 如何实现 view -> model 的数据同步?

  • 若需要自定义验证,应该如何实现?

  • 如何向DOM元素添加有效性状态,便于设置不同样式?

  • 如何让控件可以访问 (accessible)?

  • 该控件能应用于 template-driven 表单?

  • 该控件能应用于 model-driven 表单?

(备注:主要浏览器上 HTML 5 当前辅助功能支持状态,可以参看 - HTML5 Accessibility)

Creating a custom counter

现在我们从最简单的 Counter 组件开始,具体代码如下:

counter.component.ts

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

@Component({
    selector: 'exe-counter',
    template: `
    <div>
      <p>当前值: {{ count }}</p>
      <button (click)="increment()"> + </button>
      <button (click)="decrement()"> - </button>
    </div>
    `
})
export class CounterComponent {
    @Input() count: number = 0;

    increment() {
        this.count++;
    }

    decrement() {
        this.count--;
    }
}
       

app.component.ts

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

@Component({
  selector: 'exe-app',
  template: `
    <exe-counter></exe-counter>
  `,
})
export class AppComponent { }
       

app.module.ts

import { NgModule, CUSTOM_ELEMENTS_SCHEMA } from '@angular/core';
import { BrowserModule } from '@angular/platform-browser';

import { CounterComponent } from './couter.component';
import { AppComponent } from './app.component';

@NgModule({
  imports: [BrowserModule],
  declarations: [AppComponent, CounterComponent],
  bootstrap: [AppComponent],
  schemas: [CUSTOM_ELEMENTS_SCHEMA]
})
export class AppModule { }
       

很好,CounterComponent 组件很快就实现了。但现在我们想在 Template-Driven 或 Reactive 表单中使用该组件,具体如下:

<!-- this doesn't work YET -->
<form #form="ngForm">
  <exe-counter name="counter" ngModel></exe-counter>
  <button type="submit">Submit</button>
</form>
       

现在我们还不能直接这么使用,要实现该功能。我们要先搞清楚 ControlValueAccessor,因为它是表单模型和DOM 元素之间的桥梁。

Understanding ControlValueAccessor

当我们运行上面示例时,浏览器控制台中将输出以下异常信息:

Uncaught (in promise): Error: No value accessor for form control with name: 'counter'
       

那么,ControlValueAccessor 是什么?那么你们还记得我们之前提到的实现自定义控件需要确认的事情么?其中一个要确认的事情就是,要实现 Model -> View,View -> Model 之间的数据绑定,而这就是我们 ControlValueAccessor 要处理的问题。

ControlValueAccessor 是一个接口,它的作用是:

  • 把 form 模型中值映射到视图中

  • 当视图发生变化时,通知 form directives 或 form controls

Angular 引入这个接口的原因是,不同的输入控件数据更新方式是不一样的。例如,对于我们常用的文本输入框来说,我们是设置它的 value 值,而对于复选框 (checkbox) 我们是设置它的 checked 属性。实际上,不同类型的输入控件都有一个 ControlValueAccessor,用来更新视图。

Angular 中常见的 ControlValueAccessor 有:

  • DefaultValueAccessor - 用于 text 和 textarea 类型的输入控件

  • SelectControlValueAccessor - 用于 select 选择控件

  • CheckboxControlValueAccessor - 用于 checkbox 复选控件

接下来我们的 CounterComponent 组件需要实现 ControlValueAccessor 接口,这样我们才能更新组件中 count 的值,并通知外界该值已发生改变。

Implementing ControlValueAccessor

首先我们先看一下 ControlValueAccessor 接口,具体如下:

// angular2/packages/forms/src/directives/control_value_accessor.ts 
export interface ControlValueAccessor {
  writeValue(obj: any): void;
  registerOnChange(fn: any): void;
  registerOnTouched(fn: any): void;
  setDisabledState?(isDisabled: boolean): void;
}
       
  • writeValue(obj: any):该方法用于将模型中的新值写入视图或 DOM 属性中。

  • registerOnChange(fn: any):设置当控件接收到 change 事件后,调用的函数

  • registerOnTouched(fn: any):设置当控件接收到 touched 事件后,调用的函数

  • setDisabledState?(isDisabled: boolean):当控件状态变成 DISABLED 或从 DISABLED 状态变化成 ENABLE 状态时,会调用该函数。该函数会根据参数值,启用或禁用指定的 DOM 元素。

接下来我们先来实现 writeValue() 方法:

@Component(...)
class CounterComponent implements ControlValueAccessor {
  ...
  writeValue(value: any) {
    this.counterValue = value;
  }
}
       

当表单初始化的时候,将会使用表单模型中对应的初始值作为参数,调用 writeValue() 方法。这意味着,它会覆盖默认值0,一切看来都没问题。但我们回想一下在表单中 CounterComponent 组件预期的使用方式:

<form #form="ngForm">
  <exe-counter name="counter" ngModel></exe-counter>
  <button type="submit">Submit</button>
</form>
       

你会发现,我们没有为 CounterComponent 组件设置初始值,因此我们要调整一下 writeValue() 中的代码,具体如下:

writeValue(value: any) {
  if (value) {
    this.count = value;
  }
}
       

现在,只有当合法值 (非 undefined、null、"") 写入控件时,它才会覆盖默认值。接下来,我们来实现 registerOnChange() 和 registerOnTouched() 方法。registerOnChange() 可以用来通知外部,组件已经发生变化。registerOnChange() 方法接收一个 fn 参数,用于设置当控件接收到 change 事件后,调用的函数。而对于 registerOnTouched() 方法,它也支持一个 fn 参数,用于设置当控件接收到 touched 事件后,调用的函数。示例中我们不打算处理 touched 事件,因此 registerOnTouched() 我们设置为一个空函数。具体如下:

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

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

  registerOnTouched(fn: any) {}
}
       

很好,我们的 CounterComponent 组件已经实现了ControlValueAccessor 接口。接下来我们需要做的是在每次count 的值改变时,需要调用 propagateChange() 方法。换句话说,当用户点击了 + 或 - 按钮时,我们希望将新值传递到外部。

@Component(...)
export class CounterComponent implements ControlValueAccessor {
    ...
    increment() {
        this.count++;
        this.propagateChange(this.count);
    }

    decrement() {
        this.count--;
        this.propagateChange(this.count);
    }
}
       

是不是感觉上面代码有点冗余,接下来我们来利用属性修改器,重构一下以上代码,具体如下:

counter.component.ts

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

@Component({
    selector: 'exe-counter',
    template: `
      <p>当前值: {{ count }}</p>
      <button (click)="increment()"> + </button>
      <button (click)="decrement()"> - </button>
    `
})
export class CounterComponent implements ControlValueAccessor {
    @Input() _count: number = 0;

    get count() {
        return this._count;
    }

    set count(value: number) {
        this._count = value;
        this.propagateChange(this._count);
    }

    propagateChange = (_: any) => { };

    writeValue(value: any) {
        if (value !== undefined) {
            this.count = value;
        }
    }

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

    registerOnTouched(fn: any) { }

    increment() {
        this.count++;
    }

    decrement() {
        this.count--;
    }
}
       

CounterComponent 组件已经基本开发好了,但要能正常使用的话,还需要执行注册操作。

Registering the ControlValueAccessor

对于我们开发的 CounterComponent 组件来说,实现 ControlValueAccessor 接口只完成了一半工作。要让 Angular 能够正常识别我们自定义的 ControlValueAccessor,我们还需要执行注册操作。具体方式如下:

  • 步骤一:创建 EXE_COUNTER_VALUE_ACCESSOR

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

export const EXE_COUNTER_VALUE_ACCESSOR: any = {
    provide: NG_VALUE_ACCESSOR,
    useExisting: forwardRef(() => CounterComponent),
    multi: true
};
       

友情提示:想了解 forwardRef 和 multi 的详细信息,请参考 Angular 2 Forward Reference 和 Angular 2 Multi Providers 这两篇文章。

  • 步骤二:设置组件的 providers 信息

@Component({
    selector: 'exe-counter',
    ...
    providers: [EXE_COUNTER_VALUE_ACCESSOR]
})
       

万事俱备只欠东风,我们马上进入实战环节,实际检验一下我们开发的 CounterComponent 组件。完整代码如下:

counter.component.ts

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

export const EXE_COUNTER_VALUE_ACCESSOR: any = {
    provide: NG_VALUE_ACCESSOR,
    useExisting: forwardRef(() => CounterComponent),
    multi: true
};

@Component({
    selector: 'exe-counter',
    template: `
    <div>
      <p>当前值: {{ count }}</p>
      <button (click)="increment()"> + </button>
      <button (click)="decrement()"> - </button>
    </div>
    `,
    providers: [EXE_COUNTER_VALUE_ACCESSOR]
})
export class CounterComponent implements ControlValueAccessor {
    @Input() _count: number = 0;

    get count() {
        return this._count;
    }

    set count(value: number) {
        this._count = value;
        this.propagateChange(this._count);
    }

    propagateChange = (_: any) => { };

    writeValue(value: any) {
        if (value) {
            this.count = value;
        }
    }

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

    registerOnTouched(fn: any) { }

    increment() {
        this.count++;
    }

    decrement() {
        this.count--;
    }
}
       

Using it inside template-driven forms

Angular 4.x 中有两种表单:

  • Template-Driven Forms - 模板驱动式表单 (类似于 Angular 1.x 中的表单 )

  • Reactive Forms - 响应式表单

了解 Angular 4.x Template-Driven Forms 详细信息,请参考 - Angular 4.x Template-Driven Forms。接下来我们来看一下具体如何使用:

1.导入 FormsModule 模块

app.module.ts

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

@NgModule({
  imports: [BrowserModule, FormsModule],
  ...
})
export class AppModule { }
       

2.更新 AppComponent

2.1 未设置 CounterComponent 组件初始值

app.component.ts

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

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

友情提示:上面示例代码中,form.value 用于获取表单中的值,json 是 Angular 内置管道,用于执行对象序列化操作 (内部实现 - JSON.stringify(value, null, 2))。若想了解 Angular 管道详细信息,请参考 - Angular 2 Pipe

2.2 设置 CounterComponent 组件初始值 - 使用 [ngModel] 语法

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

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

2.3 设置数据双向绑定 - 使用 [(ngModel)] 语法

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

@Component({
  selector: 'exe-app',
  template: `
    <form #form="ngForm">
      <p>outerCounterValue value: {{outerCounterValue}}</p>
      <exe-counter name="counter" [(ngModel)]="outerCounterValue"></exe-counter>
    </form>
    <pre>{{ form.value | json }}</pre>
  `,
})
export class AppComponent { 
  outerCounterValue: number = 5;  
}
       

Using it inside reactive forms

了解 Angular 4.x Reactive (Model-Driven) Forms 详细信息,请参考 - Angular 4.x Reactive Forms。接下来我们来看一下具体如何使用:

1.导入 ReactiveFormsModule

app.module.ts

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

@NgModule({
  imports: [BrowserModule, ReactiveFormsModule],
  ...
})
export class AppModule { }
       

2.更新 AppComponent

import { Component, OnInit } from '@angular/core';
import { FormBuilder, FormGroup } from '@angular/forms';

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

  constructor(private fb: FormBuilder) { }

  ngOnInit() {
    this.form = this.fb.group({
      counter: 5 // 设置初始值
    });
  }
}
       

友情提示:上面代码中我们移除了 Template-Driven 表单中的 ngModel 和 name 属性,取而代之是使用 formControlName 属性。此外我们通过 FormBuilder 对象提供的 group() 方法,创建 FromGroup 对象,然后在模板中通过 [formGroup]="form" 的方式实现模型与 DOM 元素的绑定。关于 Reactive Forms 的详细信息,请参考 Angular 4.x Reactive Forms 。

最后我们在来看一下,如何为我们的自定义控件,添加验证规则。

Adding custom validation

在 Angular 4.x 基于AbstractControl自定义表单验证 这篇文章中,我们介绍了如何自定义表单验证。而对于我们自定义控件来说,添加自定义验证功能 (限制控件值的有效范围:0 <= value <=10),也很方便。具体示例如下:

1.自定义 VALIDATOR

1.1 定义验证函数

export const validateCounterRange: ValidatorFn = (control: AbstractControl): 
  ValidationErrors => {
    return (control.value > 10 || control.value < 0) ?
        { 'rangeError': { current: control.value, max: 10, min: 0 } } : null;
};
       

1.2 注册自定义验证器

export const EXE_COUNTER_VALIDATOR = {
    provide: NG_VALIDATORS,
    useValue: validateCounterRange,
    multi: true
};
       

2.更新 AppComponent

接下来我们更新一下 AppComponent 组件,在组件模板中显示异常信息:

@Component({
  selector: 'exe-app',
  template: `
    <form [formGroup]="form">
      <exe-counter formControlName="counter"></exe-counter>
    </form>
    <p *ngIf="!form.valid">Counter is invalid!</p>
    <pre>{{ form.get('counter').errors | json }}</pre>
  `,
})
       

CounterComponent 组件的完整代码如下:

counter.component.ts

import { Component, Input, forwardRef } from '@angular/core';
import {
    ControlValueAccessor, NG_VALUE_ACCESSOR, NG_VALIDATORS,
    AbstractControl, ValidatorFn, ValidationErrors, FormControl
} from '@angular/forms';

export const EXE_COUNTER_VALUE_ACCESSOR: any = {
    provide: NG_VALUE_ACCESSOR,
    useExisting: forwardRef(() => CounterComponent),
    multi: true
};

export const validateCounterRange: ValidatorFn = (control: AbstractControl): 
  ValidationErrors => {
    return (control.value > 10 || control.value < 0) ?
        { 'rangeError': { current: control.value, max: 10, min: 0 } } : null;
};

export const EXE_COUNTER_VALIDATOR = {
    provide: NG_VALIDATORS,
    useValue: validateCounterRange,
    multi: true
};

@Component({
    selector: 'exe-counter',
    template: `
    <div>
      <p>当前值: {{ count }}</p>
      <button (click)="increment()"> + </button>
      <button (click)="decrement()"> - </button>
    </div>
    `,
    providers: [EXE_COUNTER_VALUE_ACCESSOR, EXE_COUNTER_VALIDATOR]
})
export class CounterComponent implements ControlValueAccessor {
    @Input() _count: number = 0;

    get count() {
        return this._count;
    }

    set count(value: number) {
        this._count = value;
        this.propagateChange(this._count);
    }

    propagateChange = (_: any) => { };

    writeValue(value: any) {
        if (value) {
            this.count = value;
        }
    }

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

    registerOnTouched(fn: any) { }

    increment() {
        this.count++;
    }

    decrement() {
        this.count--;
    }
}
       

除了在 CounterComponent 组件的 Metadata 配置自定义验证器之外,我们也可以在创建 FormGroup 对象时,设置每个控件 (FormControl) 对象的验证规则。需调整的代码如下:

counter.component.ts

@Component({
    selector: 'exe-counter',
    ...,
    providers: [EXE_COUNTER_VALUE_ACCESSOR] // 移除自定义EXE_COUNTER_VALIDATOR
})
       

app.component.ts

import { validateCounterRange } from './couter.component';
...

export class AppComponent {
  ...
  ngOnInit() {
    this.form = this.fb.group({
      counter: [5, validateCounterRange] // 设置validateCounterRange验证器
    });
  }
}
       

自定义验证功能我们已经实现了,但验证规则即数据的有效范围是固定 (0 <= value <=10),实际上更好的方式是让用户能够灵活地配置数据的有效范围。接下来我们就来优化一下现有的功能,使得我们开发的组件更为灵活。

Making the validation configurable

我们自定义 CounterComponent 组件的预期使用方式如下:

<exe-counter
  formControlName="counter"
  counterRangeMax="10"
  counterRangeMin="0">
</exe-counter>
       

首先我们需要更新一下 CounterComponent 组件,增量 counterRangeMax 和 counterRangeMin 输入属性:

@Component(...)
class CounterInputComponent implements ControlValueAccessor {
  ...
  @Input() counterRangeMin: number;

  @Input() counterRangeMax: number;
  ...
}
       

接着我们需要新增一个 createCounterRangeValidator() 工厂函数,用于根据设置的最大值 (maxValue) 和最小值 (minValue) 动态的创建 validateCounterRange() 函数。具体示例如下:

export function createCounterRangeValidator(maxValue: number, minValue: number) {
    return (control: AbstractControl): ValidationErrors => {
        return (control.value > +maxValue || control.value < +minValue) ?
          { 'rangeError': { current: control.value, max: maxValue, 
               min: minValue }} : null;
    }
}
       

在 Angular 4.x 自定义验证指令 文章中,我们介绍了如何自定义验证指令。要实现指令的自定义验证功能,我们需要实现 Validator 接口:

export interface Validator {
  validate(c: AbstractControl): ValidationErrors|null;
  registerOnValidatorChange?(fn: () => void): void;
}
       

另外我们应该在检测到 counterRangeMin 和 counterRangeMax 输入属性时,就需要调用 createCounterRangeValidator() 方法,动态创建 validateCounterRange() 函数,然后在 validate() 方法中调用验证函数,并返回函数调用后的返回值。是不是有点绕,我们马上看一下具体代码:

import { Component, Input, OnChanges, SimpleChanges, forwardRef } from '@angular/core';
import {
    ControlValueAccessor, NG_VALUE_ACCESSOR, NG_VALIDATORS, Validator,
    AbstractControl, ValidatorFn, ValidationErrors, FormControl
} from '@angular/forms';

...

export const EXE_COUNTER_VALIDATOR = {
    provide: NG_VALIDATORS,
    useExisting: forwardRef(() => CounterComponent),
    multi: true
};

export function createCounterRangeValidator(maxValue: number, minValue: number) {
    return (control: AbstractControl): ValidationErrors => {
        return (control.value > +maxValue || control.value < +minValue) ?
            { 'rangeError': { current: control.value, max: maxValue, min: minValue } } 
              : null;
    }
}

@Component({
    selector: 'exe-counter',
    template: `
    <div>
      <p>当前值: {{ count }}</p>
      <button (click)="increment()"> + </button>
      <button (click)="decrement()"> - </button>
    </div>
    `,
    providers: [EXE_COUNTER_VALUE_ACCESSOR, EXE_COUNTER_VALIDATOR]
})
export class CounterComponent implements ControlValueAccessor, Validator,
    OnChanges {
    ...
    private _validator: ValidatorFn;
    private _onChange: () => void;

    @Input() counterRangeMin: number; // 设置数据有效范围的最大值

    @Input() counterRangeMax: number; // 设置数据有效范围的最小值

    // 监听输入属性变化,调用内部的_createValidator()方法,创建RangeValidator
    ngOnChanges(changes: SimpleChanges): void {
        if ('counterRangeMin' in changes || 'counterRangeMax' in changes) {
            this._createValidator();
        }
    }

    // 动态创建RangeValidator
    private _createValidator(): void {
        this._validator = createCounterRangeValidator(this.counterRangeMax,
           this.counterRangeMin);
    }

    // 执行控件验证
    validate(c: AbstractControl): ValidationErrors | null {
        return this.counterRangeMin == null || this.counterRangeMax == null ? 
            null : this._validator(c);
    }
      
  ...
}
       

上面的代码很长,我们来分解一下:

注册 Validator

export const EXE_COUNTER_VALIDATOR = {
    provide: NG_VALIDATORS,
    useExisting: forwardRef(() => CounterComponent),
    multi: true
};

@Component({
    selector: 'exe-counter',
    ...,
    providers: [EXE_COUNTER_VALUE_ACCESSOR, EXE_COUNTER_VALIDATOR]
})
       

创建 createCounterRangeValidator() 工厂函数

export function createCounterRangeValidator(maxValue: number, minValue: number) {
    return (control: AbstractControl): ValidationErrors => {
        return (control.value > +maxValue || control.value < +minValue) ?
            { 'rangeError': { current: control.value, max: maxValue, min: minValue } } 
              : null;
    }
}
       

实现 OnChanges 接口,监听输入属性变化创建RangeValidator

export class CounterComponent implements ControlValueAccessor, Validator,
    OnChanges {
    ...
    @Input() counterRangeMin: number; // 设置数据有效范围的最大值
    @Input() counterRangeMax: number; // 设置数据有效范围的最小值
    
    // 监听输入属性变化,调用内部的_createValidator()方法,创建RangeValidator
    ngOnChanges(changes: SimpleChanges): void {
        if ('counterRangeMin' in changes || 'counterRangeMax' in changes) {
            this._createValidator();
        }
    }
  ...
}
       

调用 _createValidator() 方法创建RangeValidator

export class CounterComponent implements ControlValueAccessor, Validator,
    OnChanges {
    ...
    // 动态创建RangeValidator
    private _createValidator(): void {
        this._validator = createCounterRangeValidator(this.counterRangeMax,
           this.counterRangeMin);
    }
  ...
}
       

实现 Validator 接口,实现控件验证功能

export class CounterComponent implements ControlValueAccessor, Validator,
    OnChanges {
    ...
    // 执行控件验证
    validate(c: AbstractControl): ValidationErrors | null {
        return this.counterRangeMin == null || this.counterRangeMax == null ? 
            null : this._validator(c);
    }
   ...
}
       

此时我们自定义 CounterComponent 组件终于开发完成了,就差功能验证了。具体的使用示例如下:

import { Component, OnInit } from '@angular/core';
import { FormBuilder, FormGroup } from '@angular/forms';

@Component({
  selector: 'exe-app',
  template: `
    <form [formGroup]="form">
      <exe-counter formControlName="counter" 
        counterRangeMin="5" 
        counterRangeMax="8">
      </exe-counter>
    </form>
    <p *ngIf="!form.valid">Counter is invalid!</p>
    <pre>{{ form.get('counter').errors | json }}</pre>
  `,
})
export class AppComponent {
  form: FormGroup;

  constructor(private fb: FormBuilder) { }

  ngOnInit() {
    this.form = this.fb.group({
      counter: 5
    });
  }
}
       

以上代码成功运行后,浏览器页面的显示结果如下:

               
angular-custom-form-control

参考资源

5人点赞            
       
Angular表单        
                 
semlinker                    
拥有6钻 (约0.92元)                    
"小礼物走一走,来简书关注我"


广告    


全部评论1只看作者            
按时间倒序
按时间正序

               
爱洗冷水澡的撒哈拉                    
2楼                         
说了这么多,有没有 github 仓库啊
 赞 回复                    


被以下专题收入,发现更多相似内容        

推荐阅读更多精彩内容        

  • Angular 4 动态表单                    
    本文将介绍如何动态创建表单组件,我们最终实现的效果如下: 在阅读本文之前,请确保你已经掌握 Angular 响应式...
    semlinker                    
  • 强大的 Angular 表单验证                    
    Angular 支持非常强大的内置表单验证,maxlength、minlength、required 以及 pat...
    sunny_lvy                    
  • Angular 4 Template-Driven Forms                    
    Angular 4.x 中有两种表单: Template-Driven Forms - 模板驱动式表单 (类似于...
    semlinker                    
  • Angular 4 自定义验证指令                    
    表单是几乎每个 Web 应用程序的一部分。虽然 Angular 为我们提供了几个内置 validators (验证...
    semlinker                    
  • 动态表单(React Forms)是一种动态构建表单的技术,用于解决有时候手动编写和维护表单所需工作量和时间会过大...
    阿狸不歌                    



来自  https://www.jianshu.com/p/fe71614f1c72




angular自定义表单控件(转)

2018年05月23日 15:49:39 aaalsh 阅读数 944更多                
分类专栏: 前端 angular                


当我们打算自定义表单控件前,我们应该先考虑一下以下问题:

  • 是否已经有相同语义的 native (本机) 元素?如:<input type="number">

  • 如果有,我们就应该考虑能否依赖该元素,仅使用 CSS 或渐进增强的方式来改变其外观/行为就能满足我们的需求?

  • 如果没有,自定义控件会是什么样的?

  • 我们如何让它可以访问 (accessible)?

  • 在不同平台上自定义控件的行为是否有所不同?

  • 自定义控件如何实现数据验证功能?

可能还有很多事情需要考虑,但如果我们决定使用 Angular 创建自定义控件,就需要考虑以下问题:

  • 如何实现 model -> view 的数据绑定?

  • 如何实现 view -> model 的数据同步?

  • 若需要自定义验证,应该如何实现?

  • 如何向DOM元素添加有效性状态,便于设置不同样式?

  • 如何让控件可以访问 (accessible)?

  • 该控件能应用于 template-driven 表单?

  • 该控件能应用于 model-driven 表单?

(备注:主要浏览器上 HTML 5 当前辅助功能支持状态,可以参看 - HTML5 Accessibility)

Creating a custom counter

现在我们从最简单的 Counter 组件开始,具体代码如下:

counter.component.ts

  1. import { Component, Input } from '@angular/core';
  2. @Component({                                
  3.    selector: 'exe-counter',                                
  4.    template: `                                
  5.    <div>                                
  6.      <p>当前值: {{ count }}</p>                                
  7.      <button (click)="increment()"> + </button>
  8.      <button (click)="decrement()"> - </button>                                
  9.    </div>                                
  10.    `                                
  11. })
  12. export class CounterComponent {
  13. @Input() count: number = 0;
  14. increment() {
  15. this.count++;
  16. }
  17. decrement() {
  18. this.count--;
  19. }
  20. }
                   

app.component.ts

  1. import { Component, OnInit } from '@angular/core';
  2. @Component({
  3. selector: 'exe-app',
  4. template: `                                
  5.    <exe-counter></exe-counter>                                
  6.  `,
  7. })
  8. export class AppComponent { }
                   

app.module.ts

  1. import { NgModule, CUSTOM_ELEMENTS_SCHEMA } from '@angular/core';
  2. import { BrowserModule } from '@angular/platform-browser';
  3. import { CounterComponent } from './couter.component';
  4. import { AppComponent } from './app.component';
  5. @NgModule({
  6. imports: [BrowserModule],
  7. declarations: [AppComponent, CounterComponent],
  8. bootstrap: [AppComponent],
  9. schemas: [CUSTOM_ELEMENTS_SCHEMA]
  10. })
  11. export class AppModule { }
                   

很好,CounterComponent 组件很快就实现了。但现在我们想在 Template-Driven 或 Reactive 表单中使用该组件,具体如下:

  1. <!-- this doesn't work YET -->                                
  2. <form #form="ngForm">                                
  3. <exe-counter name="counter" ngModel></exe-counter>                                
  4. <button type="submit">Submit</button>                                
  5. </form>                                
                   

现在我们还不能直接这么使用,要实现该功能。我们要先搞清楚 ControlValueAccessor,因为它是表单模型和DOM 元素之间的桥梁。

Understanding ControlValueAccessor

当我们运行上面示例时,浏览器控制台中将输出以下异常信息:

Uncaught (in promise): Error: No value accessor for form control with name: 'counter'
                   

那么,ControlValueAccessor 是什么?那么你们还记得我们之前提到的实现自定义控件需要确认的事情么?其中一个要确认的事情就是,要实现 Model -> View,View -> Model 之间的数据绑定,而这就是我们 ControlValueAccessor 要处理的问题。

ControlValueAccessor 是一个接口,它的作用是:

  • 把 form 模型中值映射到视图中

  • 当视图发生变化时,通知 form directives 或 form controls

Angular 引入这个接口的原因是,不同的输入控件数据更新方式是不一样的。例如,对于我们常用的文本输入框来说,我们是设置它的 value 值,而对于复选框 (checkbox) 我们是设置它的 checked 属性。实际上,不同类型的输入控件都有一个 ControlValueAccessor,用来更新视图。

Angular 中常见的 ControlValueAccessor 有:

  • DefaultValueAccessor - 用于 text 和 textarea 类型的输入控件

  • SelectControlValueAccessor - 用于 select 选择控件

  • CheckboxControlValueAccessor - 用于 checkbox 复选控件

接下来我们的 CounterComponent 组件需要实现 ControlValueAccessor 接口,这样我们才能更新组件中 count 的值,并通知外界该值已发生改变。

Implementing ControlValueAccessor

首先我们先看一下 ControlValueAccessor 接口,具体如下:

  1. // angular2/packages/forms/src/directives/control_value_accessor.ts                                
  2. export interface ControlValueAccessor {
  3. writeValue(obj: any): void;
  4. registerOnChange(fn: any): void;
  5. registerOnTouched(fn: any): void;
  6. setDisabledState?(isDisabled: boolean): void;
  7. }
                   
  • writeValue(obj: any):该方法用于将模型中的新值写入视图或 DOM 属性中。

  • registerOnChange(fn: any):设置当控件接收到 change 事件后,调用的函数

  • registerOnTouched(fn: any):设置当控件接收到 touched 事件后,调用的函数

  • setDisabledState?(isDisabled: boolean):当控件状态变成 DISABLED 或从 DISABLED 状态变化成 ENABLE 状态时,会调用该函数。该函数会根据参数值,启用或禁用指定的 DOM 元素。

接下来我们先来实现 writeValue() 方法:

  1. @Component(...)
  2. class CounterComponent implements ControlValueAccessor {
  3. ...
  4. writeValue(value: any) {
  5. this.counterValue = value;
  6. }
  7. }
                   

当表单初始化的时候,将会使用表单模型中对应的初始值作为参数,调用 writeValue() 方法。这意味着,它会覆盖默认值0,一切看来都没问题。但我们回想一下在表单中 CounterComponent 组件预期的使用方式:

  1. <form #form="ngForm">                                
  2. <exe-counter name="counter" ngModel></exe-counter>                                
  3. <button type="submit">Submit</button>                                
  4. </form>                                
                   

你会发现,我们没有为 CounterComponent 组件设置初始值,因此我们要调整一下 writeValue() 中的代码,具体如下:

  1. writeValue(value: any) {
  2. if (value) {
  3. this.count = value;
  4. }
  5. }
                   

现在,只有当合法值 (非 undefined、null、"") 写入控件时,它才会覆盖默认值。接下来,我们来实现 registerOnChange() 和 registerOnTouched() 方法。registerOnChange() 可以用来通知外部,组件已经发生变化。registerOnChange() 方法接收一个 fn 参数,用于设置当控件接收到 change 事件后,调用的函数。而对于 registerOnTouched() 方法,它也支持一个 fn 参数,用于设置当控件接收到 touched 事件后,调用的函数。示例中我们不打算处理 touched 事件,因此 registerOnTouched() 我们设置为一个空函数。具体如下:

  1. @Component(...)
  2. class CounterComponent implements ControlValueAccessor {
  3. ...
  4. propagateChange = (_: any) => {};
  5. registerOnChange(fn: any) {
  6. this.propagateChange = fn;
  7. }
  8. registerOnTouched(fn: any) {}
  9. }
                   

很好,我们的 CounterComponent 组件已经实现了ControlValueAccessor 接口。接下来我们需要做的是在每次count 的值改变时,需要调用 propagateChange() 方法。换句话说,当用户点击了 + 或 - 按钮时,我们希望将新值传递到外部。

  1. @Component(...)                                
  2. export class CounterComponent implements ControlValueAccessor {
  3. ...
  4. increment() {
  5. this.count++;
  6. this.propagateChange(this.count);
  7. }
  8. decrement() {
  9. this.count--;
  10. this.propagateChange(this.count);
  11. }
  12. }
                   

是不是感觉上面代码有点冗余,接下来我们来利用属性修改器,重构一下以上代码,具体如下:

counter.component.ts

  1. import { Component, Input } from '@angular/core';
  2. import { ControlValueAccessor } from '@angular/forms';
  3. @Component({                                
  4.    selector: 'exe-counter',                                
  5.    template: `                                
  6.      <p>当前值: {{ count }}</p>                                
  7.      <button (click)="increment()"> + </button>
  8.      <button (click)="decrement()"> - </button>                                
  9.    `                                
  10. })
  11. export class CounterComponent implements ControlValueAccessor {
  12. @Input() _count: number = 0;
  13. get count() {
  14. return this._count;
  15. }
  16. set count(value: number) {
  17. this._count = value;
  18. this.propagateChange(this._count);
  19. }
  20. propagateChange = (_: any) => { };
  21. writeValue(value: any) {
  22. if (value !== undefined) {
  23. this.count = value;
  24. }
  25. }
  26. registerOnChange(fn: any) {
  27. this.propagateChange = fn;
  28. }
  29. registerOnTouched(fn: any) { }
  30. increment() {
  31. this.count++;
  32. }
  33. decrement() {
  34. this.count--;
  35. }
  36. }
                   

CounterComponent 组件已经基本开发好了,但要能正常使用的话,还需要执行注册操作。

Registering the ControlValueAccessor

对于我们开发的 CounterComponent 组件来说,实现 ControlValueAccessor 接口只完成了一半工作。要让 Angular 能够正常识别我们自定义的 ControlValueAccessor,我们还需要执行注册操作。具体方式如下:

  • 步骤一:创建 EXE_COUNTER_VALUE_ACCESSOR

  1. import { Component, Input, forwardRef } from '@angular/core';
  2. import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';
  3. export const EXE_COUNTER_VALUE_ACCESSOR: any = {
  4. provide: NG_VALUE_ACCESSOR,
  5. useExisting: forwardRef(() => CounterComponent),
  6. multi: true                                
  7. };
                   

友情提示:想了解 forwardRef 和 multi 的详细信息,请参考 Angular 2 Forward Reference 和 Angular 2 Multi Providers 这两篇文章。

  • 步骤二:设置组件的 providers 信息

  1. @Component({                                
  2.    selector: 'exe-counter',                                
  3.    ...                                
  4.    providers: [EXE_COUNTER_VALUE_ACCESSOR]                                
  5. })                                
                   

万事俱备只欠东风,我们马上进入实战环节,实际检验一下我们开发的 CounterComponent 组件。完整代码如下:

counter.component.ts

  1. import { Component, Input, forwardRef } from '@angular/core';
  2. import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';
  3. export const EXE_COUNTER_VALUE_ACCESSOR: any = {
  4. provide: NG_VALUE_ACCESSOR,
  5. useExisting: forwardRef(() => CounterComponent),
  6. multi: true                                
  7. };
  8. @Component({                                
  9.    selector: 'exe-counter',                                
  10.    template: `                                
  11.    <div>                                
  12.      <p>当前值: {{ count }}</p>                                
  13.      <button (click)="increment()"> + </button>
  14.      <button (click)="decrement()"> - </button>                                
  15.    </div>                                
  16.    `,
  17. providers: [EXE_COUNTER_VALUE_ACCESSOR]
  18. })
  19. export class CounterComponent implements ControlValueAccessor {
  20. @Input() _count: number = 0;
  21. get count() {
  22. return this._count;
  23. }
  24. set count(value: number) {
  25. this._count = value;
  26. this.propagateChange(this._count);
  27. }
  28. propagateChange = (_: any) => { };
  29. writeValue(value: any) {
  30. if (value) {
  31. this.count = value;
  32. }
  33. }
  34. registerOnChange(fn: any) {
  35. this.propagateChange = fn;
  36. }
  37. registerOnTouched(fn: any) { }
  38. increment() {
  39. this.count++;
  40. }
  41. decrement() {
  42. this.count--;
  43. }
  44. }
                   

Using it inside template-driven forms

Angular 4.x 中有两种表单:

  • Template-Driven Forms - 模板驱动式表单 (类似于 Angular 1.x 中的表单 )

  • Reactive Forms - 响应式表单

了解 Angular 4.x Template-Driven Forms 详细信息,请参考 - Angular 4.x Template-Driven Forms。接下来我们来看一下具体如何使用:

1.导入 FormsModule 模块

app.module.ts

  1. import { FormsModule } from '@angular/forms';
  2. @NgModule({
  3. imports: [BrowserModule, FormsModule],
  4. ...
  5. })
  6. export class AppModule { }
                   

2.更新 AppComponent

2.1 未设置 CounterComponent 组件初始值

app.component.ts

  1. import { Component, OnInit } from '@angular/core';
  2. @Component({                                
  3.  selector: 'exe-app',                                
  4.  template: `                                
  5.    <form #form="ngForm">                                
  6.      <exe-counter name="counter" ngModel></exe-counter>                                
  7.    </form>                                
  8.    <pre>{{ form.value | json }}</pre>                                
  9.  `,                                
  10. })                                
  11. export class AppComponent { }
                   

友情提示:上面示例代码中,form.value 用于获取表单中的值,json 是 Angular 内置管道,用于执行对象序列化操作 (内部实现 - JSON.stringify(value, null, 2))。若想了解 Angular 管道详细信息,请参考 - Angular 2 Pipe

2.2 设置 CounterComponent 组件初始值 - 使用 [ngModel] 语法

  1. import { Component, OnInit } from '@angular/core';
  2. @Component({                                
  3.  selector: 'exe-app',                                
  4.  template: `                                
  5.    <form #form="ngForm">                                
  6.      <exe-counter name="counter" [ngModel]="outerCounterValue"></exe-counter>                                
  7.    </form>                                
  8.    <pre>{{ form.value | json }}</pre>                                
  9.  `,                                
  10. })                                
  11. export class AppComponent {
  12. outerCounterValue: number = 5;
  13. }
                   

2.3 设置数据双向绑定 - 使用 [(ngModel)] 语法

  1. import { Component, OnInit } from '@angular/core';
  2. @Component({
  3. selector: 'exe-app',
  4. template: `                                
  5. <form #form="ngForm">                                
  6. <p>outerCounterValue value: {{outerCounterValue}}</p>                                
  7. <exe-counter name="counter" [(ngModel)]="outerCounterValue"></exe-counter>                                
  8. </form>                                
  9. <pre>{{ form.value | json }}</pre>                                
  10. `,
  11. })
  12. export class AppComponent {
  13. outerCounterValue: number = 5;
  14. }
                   

Using it inside reactive forms

了解 Angular 4.x Reactive (Model-Driven) Forms 详细信息,请参考 - Angular 4.x Reactive Forms。接下来我们来看一下具体如何使用:

1.导入 ReactiveFormsModule

app.module.ts

  1. import { ReactiveFormsModule } from '@angular/forms';
  2. @NgModule({
  3. imports: [BrowserModule, ReactiveFormsModule],
  4. ...
  5. })
  6. export class AppModule { }
                   

2.更新 AppComponent

  1. import { Component, OnInit } from '@angular/core';
  2. import { FormBuilder, FormGroup } from '@angular/forms';
  3. @Component({                                
  4.  selector: 'exe-app',                                
  5.  template: `                                
  6.    <form [formGroup]="form">                                
  7.      <exe-counter formControlName="counter"></exe-counter>                                
  8.    </form>                                
  9.    <pre>{{ form.value | json }}</pre>                                
  10.  `,                                
  11. })                                
  12. export class AppComponent {
  13. form: FormGroup;
  14. constructor(private fb: FormBuilder) { }
  15. ngOnInit() {
  16. this.form = this.fb.group({
  17. counter: 5 // 设置初始值                                
  18. });
  19. }
  20. }
                   

友情提示:上面代码中我们移除了 Template-Driven 表单中的 ngModel 和 name 属性,取而代之是使用 formControlName 属性。此外我们通过 FormBuilder 对象提供的 group() 方法,创建 FromGroup 对象,然后在模板中通过 [formGroup]="form" 的方式实现模型与 DOM 元素的绑定。关于 Reactive Forms 的详细信息,请参考 Angular 4.x Reactive Forms 。

最后我们在来看一下,如何为我们的自定义控件,添加验证规则。

Adding custom validation

在 Angular 4.x 基于AbstractControl自定义表单验证 这篇文章中,我们介绍了如何自定义表单验证。而对于我们自定义控件来说,添加自定义验证功能 (限制控件值的有效范围:0 <= value <=10),也很方便。具体示例如下:

1.自定义 VALIDATOR

1.1 定义验证函数

  1. export const validateCounterRange: ValidatorFn = (control: AbstractControl):
  2. ValidationErrors => {
  3. return (control.value > 10 || control.value < 0) ?
  4. { 'rangeError': { current: control.value, max: 10, min: 0 } } : null;
  5. };
                   

1.2 注册自定义验证器

  1. export const EXE_COUNTER_VALIDATOR = {
  2. provide: NG_VALIDATORS,
  3. useValue: validateCounterRange,
  4. multi: true                                
  5. };
                   

2.更新 AppComponent

接下来我们更新一下 AppComponent 组件,在组件模板中显示异常信息:

  1. @Component({
  2. selector: 'exe-app',
  3. template: `                                
  4. <form [formGroup]="form">                                
  5. <exe-counter formControlName="counter"></exe-counter>                                
  6. </form>                                
  7. <p *ngIf="!form.valid">Counter is invalid!</p>                                
  8. <pre>{{ form.get('counter').errors | json }}</pre>                                
  9. `,
  10. })
                   

CounterComponent 组件的完整代码如下:

counter.component.ts

  1. import { Component, Input, forwardRef } from '@angular/core';
  2. import {
  3. ControlValueAccessor, NG_VALUE_ACCESSOR, NG_VALIDATORS,
  4. AbstractControl, ValidatorFn, ValidationErrors, FormControl
  5. } from '@angular/forms';
  6. export const EXE_COUNTER_VALUE_ACCESSOR: any = {
  7. provide: NG_VALUE_ACCESSOR,
  8. useExisting: forwardRef(() => CounterComponent),
  9. multi: true                                
  10. };
  11. export const validateCounterRange: ValidatorFn = (control: AbstractControl):
  12. ValidationErrors => {
  13. return (control.value > 10 || control.value < 0) ?
  14. { 'rangeError': { current: control.value, max: 10, min: 0 } } : null;
  15. };
  16. export const EXE_COUNTER_VALIDATOR = {
  17. provide: NG_VALIDATORS,
  18. useValue: validateCounterRange,
  19. multi: true                                
  20. };
  21. @Component({                                
  22.    selector: 'exe-counter',                                
  23.    template: `                                
  24.    <div>                                
  25.      <p>当前值: {{ count }}</p>                                
  26.      <button (click)="increment()"> + </button>
  27.      <button (click)="decrement()"> - </button>                                
  28.    </div>                                
  29.    `,
  30. providers: [EXE_COUNTER_VALUE_ACCESSOR, EXE_COUNTER_VALIDATOR]
  31. })
  32. export class CounterComponent implements ControlValueAccessor {
  33. @Input() _count: number = 0;
  34. get count() {
  35. return this._count;
  36. }
  37. set count(value: number) {
  38. this._count = value;
  39. this.propagateChange(this._count);
  40. }
  41. propagateChange = (_: any) => { };
  42. writeValue(value: any) {
  43. if (value) {
  44. this.count = value;
  45. }
  46. }
  47. registerOnChange(fn: any) {
  48. this.propagateChange = fn;
  49. }
  50. registerOnTouched(fn: any) { }
  51. increment() {
  52. this.count++;
  53. }
  54. decrement() {
  55. this.count--;
  56. }
  57. }
                   

除了在 CounterComponent 组件的 Metadata 配置自定义验证器之外,我们也可以在创建 FormGroup 对象时,设置每个控件 (FormControl) 对象的验证规则。需调整的代码如下:

counter.component.ts

  1. @Component({
  2. selector: 'exe-counter',
  3. ...,
  4. providers: [EXE_COUNTER_VALUE_ACCESSOR] // 移除自定义EXE_COUNTER_VALIDATOR                                
  5. })
                   

app.component.ts

  1. import { validateCounterRange } from './couter.component';
  2. ...
  3. export class AppComponent {
  4. ...
  5. ngOnInit() {
  6. this.form = this.fb.group({
  7. counter: [5, validateCounterRange] // 设置validateCounterRange验证器                                
  8. });
  9. }
  10. }
                   

自定义验证功能我们已经实现了,但验证规则即数据的有效范围是固定 (0 <= value <=10),实际上更好的方式是让用户能够灵活地配置数据的有效范围。接下来我们就来优化一下现有的功能,使得我们开发的组件更为灵活。

Making the validation configurable

我们自定义 CounterComponent 组件的预期使用方式如下:

  1. <exe-counter                                
  2. formControlName="counter"                                
  3. counterRangeMax="10"                                
  4. counterRangeMin="0">                                
  5. </exe-counter>                                
                   

首先我们需要更新一下 CounterComponent 组件,增量 counterRangeMax 和 counterRangeMin 输入属性:

  1. @Component(...)                                
  2. class CounterInputComponent implements ControlValueAccessor {
  3. ...
  4. @Input() counterRangeMin: number;
  5. @Input() counterRangeMax: number;
  6. ...
  7. }
                   

接着我们需要新增一个 createCounterRangeValidator() 工厂函数,用于根据设置的最大值 (maxValue) 和最小值 (minValue) 动态的创建 validateCounterRange() 函数。具体示例如下:

  1. export function createCounterRangeValidator(maxValue: number, minValue: number) {
  2. return (control: AbstractControl): ValidationErrors => {
  3. return (control.value > +maxValue || control.value < +minValue) ?
  4. { 'rangeError': { current: control.value, max: maxValue,
  5. min: minValue }} : null;
  6. }
  7. }
                   

在 Angular 4.x 自定义验证指令 文章中,我们介绍了如何自定义验证指令。要实现指令的自定义验证功能,我们需要实现 Validator 接口:

  1. export interface Validator {
  2. validate(c: AbstractControl): ValidationErrors|null;
  3. registerOnValidatorChange?(fn: () => void): void;                                
  4. }                                
                   

另外我们应该在检测到 counterRangeMin 和 counterRangeMax 输入属性时,就需要调用 createCounterRangeValidator() 方法,动态创建 validateCounterRange() 函数,然后在 validate() 方法中调用验证函数,并返回函数调用后的返回值。是不是有点绕,我们马上看一下具体代码:

  1. import { Component, Input, OnChanges, SimpleChanges, forwardRef } from '@angular/core';
  2. import {
  3. ControlValueAccessor, NG_VALUE_ACCESSOR, NG_VALIDATORS, Validator,
  4. AbstractControl, ValidatorFn, ValidationErrors, FormControl
  5. } from '@angular/forms';
  6. ...
  7. export const EXE_COUNTER_VALIDATOR = {
  8. provide: NG_VALIDATORS,
  9. useExisting: forwardRef(() => CounterComponent),
  10. multi: true                                
  11. };
  12. export function createCounterRangeValidator(maxValue: number, minValue: number) {
  13. return (control: AbstractControl): ValidationErrors => {
  14. return (control.value > +maxValue || control.value < +minValue) ?
  15. { 'rangeError': { current: control.value, max: maxValue, min: minValue } }
  16. : null;
  17. }
  18. }
  19. @Component({                                
  20.    selector: 'exe-counter',                                
  21.    template: `                                
  22.    <div>                                
  23.      <p>当前值: {{ count }}</p>                                
  24.      <button (click)="increment()"> + </button>
  25.      <button (click)="decrement()"> - </button>                                
  26.    </div>                                
  27.    `,
  28. providers: [EXE_COUNTER_VALUE_ACCESSOR, EXE_COUNTER_VALIDATOR]
  29. })
  30. export class CounterComponent implements ControlValueAccessor, Validator,                                
  31. OnChanges {                                
  32. ...
  33. private _validator: ValidatorFn;
  34. private _onChange: () => void;
  35. @Input() counterRangeMin: number; // 设置数据有效范围的最大值                                
  36. @Input() counterRangeMax: number; // 设置数据有效范围的最小值                                
  37. // 监听输入属性变化,调用内部的_createValidator()方法,创建RangeValidator                                
  38. ngOnChanges(changes: SimpleChanges): void {
  39. if ('counterRangeMin' in changes || 'counterRangeMax' in changes) {
  40. this._createValidator();
  41. }
  42. }
  43. // 动态创建RangeValidator                                
  44. private _createValidator(): void {
  45. this._validator = createCounterRangeValidator(this.counterRangeMax,
  46. this.counterRangeMin);
  47. }
  48. // 执行控件验证                                
  49. validate(c: AbstractControl): ValidationErrors | null {
  50. return this.counterRangeMin == null || this.counterRangeMax == null ?
  51. null : this._validator(c);
  52. }
  53. ...
  54. }
                   

上面的代码很长,我们来分解一下:

注册 Validator

  1. export const EXE_COUNTER_VALIDATOR = {
  2. provide: NG_VALIDATORS,
  3. useExisting: forwardRef(() => CounterComponent),
  4. multi: true                                
  5. };
  6. @Component({
  7. selector: 'exe-counter',
  8. ...,
  9. providers: [EXE_COUNTER_VALUE_ACCESSOR, EXE_COUNTER_VALIDATOR]
  10. })
                   

创建 createCounterRangeValidator() 工厂函数

  1. export function createCounterRangeValidator(maxValue: number, minValue: number) {
  2. return (control: AbstractControl): ValidationErrors => {
  3. return (control.value > +maxValue || control.value < +minValue) ?
  4. { 'rangeError': { current: control.value, max: maxValue, min: minValue } }
  5. : null;
  6. }
  7. }
                   

实现 OnChanges 接口,监听输入属性变化创建RangeValidator

  1. export class CounterComponent implements ControlValueAccessor, Validator,                                
  2. OnChanges {                                
  3. ...
  4. @Input() counterRangeMin: number; // 设置数据有效范围的最大值                                
  5. @Input() counterRangeMax: number; // 设置数据有效范围的最小值                                
  6. // 监听输入属性变化,调用内部的_createValidator()方法,创建RangeValidator                                
  7. ngOnChanges(changes: SimpleChanges): void {
  8. if ('counterRangeMin' in changes || 'counterRangeMax' in changes) {
  9. this._createValidator();
  10. }
  11. }
  12. ...
  13. }
                   

调用 _createValidator() 方法创建RangeValidator

  1. export class CounterComponent implements ControlValueAccessor, Validator,                                
  2. OnChanges {                                
  3. ...
  4. // 动态创建RangeValidator                                
  5. private _createValidator(): void {
  6. this._validator = createCounterRangeValidator(this.counterRangeMax,
  7. this.counterRangeMin);
  8. }
  9. ...
  10. }
                   

实现 Validator 接口,实现控件验证功能

  1. export class CounterComponent implements ControlValueAccessor, Validator,                                
  2. OnChanges {                                
  3. ...
  4. // 执行控件验证                                
  5. validate(c: AbstractControl): ValidationErrors | null {
  6. return this.counterRangeMin == null || this.counterRangeMax == null ?
  7. null : this._validator(c);
  8. }
  9. ...
  10. }
                   

此时我们自定义 CounterComponent 组件终于开发完成了,就差功能验证了。具体的使用示例如下:

  1. import { Component, OnInit } from '@angular/core';
  2. import { FormBuilder, FormGroup } from '@angular/forms';
  3. @Component({                                
  4.  selector: 'exe-app',                                
  5.  template: `                                
  6.    <form [formGroup]="form">                                
  7.      <exe-counter formControlName="counter"                                
  8.        counterRangeMin="5"                                
  9.        counterRangeMax="8">                                
  10.      </exe-counter>                                
  11.    </form>                                
  12.    <p *ngIf="!form.valid">Counter is invalid!</p>                                
  13.    <pre>{{ form.get('counter').errors | json }}</pre>
  14.  `,
  15. })
  16. export class AppComponent {
  17. form: FormGroup;
  18. constructor(private fb: FormBuilder) { }
  19. ngOnInit() {
  20. this.form = this.fb.group({
  21. counter: 5                                
  22. });
  23. }
  24. }
                   

以上代码成功运行后,浏览器页面的显示结果如下:

图片描述                    

参考资源



来自  https://blog.csdn.net/u012033169/article/details/80421048




细说 Angular 的自定义表单控件        

2017.07.21 11:05 4932浏览            

我们在构建企业级应用时,通常会遇到各种各样的定制化功能,因为每个企业都有自己独特的流程、思维方式和行为习惯。有很多时候,软件企业是不太理解这种情况,习惯性的会给出一个诊断,『你这么做不对,按逻辑应该这样这样』。但企业往往不会接受这种说法,习惯的力量是强大的,我们一定要尊重这种事实。所以在构建企业应用的时候,我们不仅仅要了解对方的基本需求,也要了解他们习惯于怎么处理流程,在设计的时候需要予以充分重视。当然这也不是说客户说怎么改我们就怎么改,而是要了解到对方真正的诉求和背后的原因,在产品规划设计的时候,将这种因素考虑进去,才能在维持产品统一的框架下满足不同用户的需求。

那么这里我们举一个例子,比如我们正在开发一个医疗卫生领域的企业软件,客户要求提供一个出生日期的控件,但这个控件不光可以输入年月日,而且可以输入年龄数值以及选择年龄单位。客户的希望是:

  1. 填写日期时,年龄和年龄单位随之变化

  2. 填写年龄和选择年龄单位时出生日期也随之变化

看起来好像很无用的一个需求,这个在面向互联网的应用中确实如此。但在特定领域,其实有其背景原因,比如客户提出这个需求是由于很多人,尤其是小城镇的,是不记公历生日的,这样会导致出生日期不是很准确,另外还会有一些人的身份证日期和真实年龄是不一致的。这种情况对于成人来说还好,但对于儿童来说就偏差很大,但一般人会记得孩子现在是多少天或多少个月大。这样的话是不是觉得这个需求还有些道理?

那么我们就接着来看一下这个需求应该怎样实现,首先分析一下:

  1. 无论是输入出生日期还是年龄,其实最终要得到一个日期,也就是说年龄只是得到日期的一个辅助手段。

  2. 年龄单位的转换我们需要有一个界定,否则切换起来没有规则的话会导致逻辑的混乱。那这里我们定义一下:以天为单位时的上限为:90,下限为 0,也就是只有小于等于 90 天的婴儿我们会使用天作为年龄单位。类似的,以月为单位的上限为 24,下限为 1;以年为单位的上限为 150,下限为 1。

  3. 同样的出生日期的验证规则为:这个日期不能是未来的时间,一定是小于等于当前时间的,再有就是年龄的上限既然是 150,那么出生日期也不能比当前日期减去 150 年更早,对吗?

  4. 联动的规则应该是调整出生日期时,会将日期按上面规则转换成年龄和单位,改变控件中的值;而调整年龄或者单位的时候,我们会根据年龄推算出出生日期,当然这里是估算,以当前日期减去年龄得出,然后更新出生日期输入框中的值。

一个定制化的日期选择控件                

但这里面有几个值得注意的地方:

  1. 可能存在反复联动的问题,比如改变出生日期后,年龄和单位随之改变,这又引发了由年龄和单位的变化而导致的出生日期的重算。

  2. 如果输入非法的值,可能导致计算出现异常,因而控件状态出现不正确的状态值,进一步影响未来的计算。

  3. 如果每次输入改动都会引发重新计算,会带来大量的过程中无用计算,耗费资源,因此需要进行对输入事件的『整流』控制。

搭建自定义表单控件的框架                

首先为什么要实现一个自定义表单控件?我们当然可以直接把这个逻辑放在表单中,但问题是表单真的需要关心这几个框的联动吗?

其实从表单的角度看,它只要一个值:那就是经过计算的出生日期。至于你是手动输入的还是按年龄和单位计算的,表单根本就不应该关心。另外一点是随着表单的复杂化,如果我们不把这些逻辑剥离出去的话,我们的表单本身的逻辑就会越来越复杂。最后是,封装成表单控件意味着我们以后可以复用这个控件了。

知道了 why,我们看看 how。在 Angular 中实现一个自定义的表单控件还是比较简单的,下面是一个表单控件的骨架。

import {ChangeDetectionStrategy, Component, forwardRef, OnInit, OnDestroy, Input} from '@angular/core';
import { ControlValueAccessor, FormBuilder, FormControl, FormGroup, NG_VALIDATORS, NG_VALUE_ACCESSOR, Validators } from '@angular/forms';

@Component({
  selector: 'app-age-input',
  template: `
    // 省略
    `,
  styles: [`
    // 省略
  `],
  providers: [
    {
      provide: NG_VALUE_ACCESSOR,
      useExisting: forwardRef(() => AgeInputComponent),
      multi: true,
    },
    {
      provide: NG_VALIDATORS,
      useExisting: forwardRef(() => AgeInputComponent),
      multi: true,
    }
  ],
  changeDetection: ChangeDetectionStrategy.OnPush,
})
export class AgeInputComponent implements ControlValueAccessor {

  private propagateChange = (_: any) => {};

  constructor() { }

  // 提供值的写入方法
  public writeValue(obj: Date) 
  }

  // 当表单控件值改变时,函数 fn 会被调用
  // 这也是我们把变化 emit 回表单的机制
  public registerOnChange(fn: any) {
    this.propagateChange = fn;
  }

  // 这里没有使用,用于注册 touched 时的回调函数
  public registerOnTouched() {
  }

  // 验证表单,验证结果正确返回 null 否则返回一个验证结果对象
  validate(c: FormControl): {[key: string]: any} {
    // 省略
  }
}
               

我们可以看到要实现一个表单控件的话,要实现 ControlValueAccessor 这样一个接口。这个接口顾名思义是用于写入控件值的,它是一个控件和原生 DOM 元素之间的桥梁,通过实现这个接口,我们可以对原生 DOM 元素写入值。而这个接口需要实现三个必选方法: writeValue(obj: any) 、 registerOnChange(fn: any) 和 registerOnTouched(fn: any)

  • writeValue(obj: any):用于向元素中写入值

  • registerOnChange(fn: any):设置一个当控件接受到改变的事件时所要调用的函数。

  • registerOnTouched(fn: any):设置一个当控件接受到 touch 事件时所要调用的函数。

另外的一个 validate(c: FormControl): {[key: string]: any} 是控件的验证器函数。除了这些函数,你应该也注意到,我们注册了两个 provider,一个的 token 是 NG_VALUE_ACCESSOR 这是将控件本身注册到 DI 框架成为一个可以让表单访问其值的控件。但问题来了,如果在元数据中注册了控件本身,而此时控件仍为创建,这怎么破?这就得用到 forwardRef 了,这个函数允许我们引用一个尚未定义的对象。另外一个 NG_VALIDATORS 是让控件注册成为一个可以让表单得到其验证状态的控件
。当然这里还有一个奇怪的东西,就是那个 multi: true,,这是声明这个 token 对应的类很多,分散在各处。

控件的界面                

我们这里使用了 @angular/material 的 input、 datepicker和 button-toggle 控件来分别实现日期输入、年龄输入和年龄单位的选择。注意到我们在里面使用了响应式表单,这感觉好像有点怪,我们本身不是一个表单控件吗?怎么自己的模板还是一个表单?这个其实没啥问题,因为 Angular 中的组件是和外界隔离的,所以组件自身的模板其实想怎么使用都可以。

<div [formGroup]="form" class="age-input">
  <div>
    <md-input-container>
      <input mdInput [mdDatepicker]="birthPicker" type="text" placeholder="出生日期" formControlName="birthday" >
      <button mdSuffix [mdDatepickerToggle]="birthPicker" type="button"></button>
      <md-error>日期不正确</md-error>
    </md-input-container>
    <md-datepicker touchUi="true" #birthPicker></md-datepicker>
  </div>
  <ng-container formGroupName="age">
    <div class="age-num">
      <md-input-container>
        <input mdInput type="number" placeholder="年龄" formControlName="ageNum">
      </md-input-container>
    </div>
    <div>
      <md-button-toggle-group formControlName="ageUnit" [(ngModel)]="selectedUnit">
        <md-button-toggle *ngFor="let unit of ageUnits" [value]="unit.value">
          {{ unit.label }}
        </md-button-toggle>
      </md-button-toggle-group>
    </div>
    <md-error class="mat-body-2" *ngIf="form.get('age').hasError('ageInvalid')">年龄或单位不正确</md-error>
  </ng-container>
</div>
               

上面这个模板中值得注意的一点是,我们把年龄的数值和单位放在了一个 FormGroup 里面,这是由于这两个值组合在一起才有意义,而且后面的表单验证也是这两个值在一起组合后验证。

使用 Rx 的事件流来重新梳理逻辑                

私以为 Rx 的两大优点:

  1. 由于在 Rx 世界里,一切都是事件流,所以这『逼迫』开发者将时间维度纳入设计的考量

  2. 提供的各种强大的操作符可以将逻辑非常轻松的组合

那么从 Rx 的角度看的话,这个控件会产生三个事件流:出生日期、年龄数值和年龄单位:

出生日期:-------d----------d---------------d--------------
年龄数值:----------num----------num----------------num----
年龄单位:----unit-------------unit-------------unit-------
               

写成代码的话就是下面的样子,Angular 的响应式表单为我们提供了非常便利的方法可以得到这些变化的事件流,FormControl 的 valueChanges 属性就是一个 Observable 。

// 得到出生日期的值的变化流
const birthday$ = this.form.get('birthday').valueChanges;
// 得到年龄数值的变化流
const ageNum$ = this.form.get('age').get('ageNum').valueChanges;
// 得到年龄单位的变化流
const ageUnit$ = this.form.get('age').get('ageUnit').valueChanges;
               

由于年龄数值和年龄单位需要合并在一起才有意义,所以这两个流需要做一个合并操作,而且不管是数值变化还是单位变化,我们都要在新的合并流中有一个反映:

年龄数值:----------n1----------------n2------------------n3-------
年龄单位:----u1-------------u2------------------u3----------------
合并后:  ------(n1,u1)--(n1,u2)--(n2,u2)----(n2,u3)---(n3,u3)---
               

仔细观察一下,你可能会发现这个合并流还有一个特点就是只有在参与合并的两个流都有事件产生后才会有合并的事件发生,在这之后就是任何一个参与合并的流有新的事件,合并流就会产生一个事件,这个合并的值会取刚刚发生的那个事件和另一个参与合并的流中的『最新』事件。这种合并方法在 Rx 中叫做 combineLatest

const age$ = Observable
      .combineLatest(ageNum$, ageUnit$, (_num, _unit) => this.toDate({age: _num, unit: _unit}));
               

上面的代码中,我们将年龄数值的事件流(ageNum$)以及年龄单位的事件流(ageUnit$)做了合并,而且通过一个 this.toDate 的工具函数将年龄和单位计算出了一个估算的出生日期。

出生日期:-------d----------d---------------d--------------
年龄合并:---d^----d^----d^---d^--------d^------d^---------
// 年龄合并后产生的出生日期用 d^ 来标识
               

现在看起来这两个流都产生日期,只不过是不同的控件变化引起的。那么我们应该可以把它们也做一个合并,这个合并就比较简单,可以想象成按照各自流中的位置把两个流做投影。

最终合并:---d^--d--d^----d^--d-d^-------d^--d----d^-------
               

而这种合并在 Rx 中叫做 merge                

const merge$ = Observable.merge(birthday$, age$);
               

但为了要能区分这个日期是来自于出生日期那个输入框还是来自于年龄和单位的输入变化,我们得标识出这个日期的来源。所以我们需要对 birthday$ 和 age$ 做一个变换处理,不在单纯的发射日期,而是将日期和来源组合成一个新的对象 {date: string; from: string} 发射。

const birthday$ = this.form.get('birthday').valueChanges
      .map(d => ({date: d, from: 'birthday'}));
const age$ = Observable
      .combineLatest(ageNum$, ageUnit$, (_num, _unit) => this.toDate({age: _num, unit: _unit}))
      .map(d => ({date: d, from: 'age'}));
               

这样处理之后,我们就可以根据不同情况,根据日期设置年龄和单位,或者反之,由年龄和单位的变化设置出生日期。

this.subBirth = merged$.subscribe(date => {
  const age = this.toAge(date.date);
  const ageNum = this.form.get('age').get('ageNum');
  const ageUnit = this.form.get('age').get('ageUnit');
  if(date.from === 'birthday') {
    if(age.age === ageNum.value && age.unit === ageUnit.value) {
      return;
    }
    ageUnit.patchValue(age.unit, {emitEvent: false, emitModelToViewChange: true, emitViewToModelChange: true});
    ageNum.patchValue(age.age, {emitEvent: false});
    this.selectedUnit = age.unit;
    this.propagateChange(date.date);

  } else {
    const ageToCompare = this.toAge(this.form.get('birthday').value);
    // 如果要设置的日期换算成年龄和单位,如果这两个值和现有控件的值是一样的,那就没有必要更新日期的值了
    if(age.age !== ageToCompare.age || age.unit !== ageToCompare.unit) {
      this.form.get('birthday').patchValue(date.date, {emitEvent: false});
      this.propagateChange(date.date);
    }
  }
});
               

大致的逻辑就是这样了,但我们还有几个问题需要解决

  1. 现在的情况是不管你以多快的速度输入日期,或者输错了按 backspace 都会产生新的事件,也因此会有计算。但显然这样做一方面浪费了性能,另一方面会导致一些不合法的值大量出现(比如本来要输入 2000-12-11 , 但事实上现在当你刚刚敲了 2 ,事件就已经产生了,但显然年份 2 不是一个合理的出生年份,我们毕竟不是在做一个考古信息系统)。

  2. 当你和上一次输入相同的值时,现在的系统仍然会发射事件,但这其实是在做无用功。

  3. 我们现在的事件流没有经过一个验证就会把数据发射出来,但一个没有验证成功的值其实对我们来说是没有意义的。

  4. 年龄和单位的合并流只有在年龄和单位都产生变化的时候才开始发射,但一开始的初始状态,这两个控件并没有值,这显然不是我们希望的(比如你可能不想填完年龄,例如 30,然后还得点一下『天』,再点回『岁』来得到合并计算的值)。

const birthday$ = this.form.get('birthday').valueChanges
  .map(d => ({date: d, from: 'birthday'}))
  .debounceTime(300)
  .distinctUntilChanged()
  .filter(date => this.form.get('birthday').valid);
const ageNum$ = this.form.get('age').get('ageNum').valueChanges
  .startWith(this.form.get('age').get('ageNum').value)
  .debounceTime(300)
  .distinctUntilChanged();
const ageUnit$ = this.form.get('age').get('ageUnit').valueChanges
  .startWith(this.form.get('age').get('ageUnit').value)
  .debounceTime(300)
  .distinctUntilChanged();
const age$ = Observable
  .combineLatest(ageNum$, ageUnit$, (_num, _unit) => this.toDate({age: _num, unit: _unit}))
  .map(d => ({date: d, from: 'age'}))
  .filter(_ => this.form.get('age').valid);
const merged$ = Observable
  .merge(birthday$, age$)
  .filter(_ => this.form.valid);
               

上面的代码中,我们使用 debounceTime 过滤掉了短时间内的输入,等待用户略有停顿或输入完成时才发射新的事件。我们还使用了 distinctUntilChanged 来过滤掉和之前一样的输入。而 startWith 其实是在帮事件流拼接一个初始值,使得合并流按我们想像中那样运行。那么 filter 则是屏蔽掉验证未通过的数据。

这样简单的通过几个 Rx 的操作符我们就完成了核心逻辑,而且在核心逻辑不变的前提下对数据验证、事件的『整流』、筛选等进行了调整。

总结和思考                

针对复杂的表单,我们通常应该使用『复杂问题简单化』的方法将一个复杂问题拆分成多个简单问题。对于较复杂的表单来讲,自定义表单控件是一个很有用的可以简单化表单逻辑,封装局部逻辑的一种方法。

而使用 Rx 进行逻辑的组装、转换、拼接以及合并是非常容易的事情,而且 Rx 的事件流特点会让你把逻辑梳理的非常清晰,以时间维度把业务逻辑的先后和组装的次序考虑周全。

源码                
import {ChangeDetectionStrategy, Component, forwardRef, OnInit, OnDestroy, Input} from '@angular/core';
import { ControlValueAccessor, FormBuilder, FormControl, FormGroup, NG_VALIDATORS, NG_VALUE_ACCESSOR, Validators } from '@angular/forms';
import {
  subYears,
  subMonths,
  subDays,
  isBefore,
  differenceInDays,
  differenceInMonths,
  differenceInYears,
  parse
} from 'date-fns';
import {Observable} from 'rxjs/Observable';
import { Subscription } from 'rxjs/Subscription';
import { toDate, isValidDate } from '../../utils/date.util';
export enum AgeUnit {
  Year = 0,
  Month,
  Day
}

export interface Age {
  age: number;
  unit: AgeUnit;
}

@Component({
  selector: 'app-age-input',
  template: `
    <div [formGroup]="form" class="age-input">
      <div>
        <md-input-container>
          <input mdInput [mdDatepicker]="birthPicker" type="text" placeholder="出生日期" formControlName="birthday" >
          <button mdSuffix [mdDatepickerToggle]="birthPicker" type="button"></button>
          <md-error>日期不正确</md-error>
        </md-input-container>
        <md-datepicker touchUi="true" #birthPicker></md-datepicker>
      </div>
      <ng-container formGroupName="age">
        <div class="age-num">
          <md-input-container>
            <input mdInput type="number" placeholder="年龄" formControlName="ageNum">
          </md-input-container>
        </div>
        <div>
          <md-button-toggle-group formControlName="ageUnit" [(ngModel)]="selectedUnit">
            <md-button-toggle *ngFor="let unit of ageUnits" [value]="unit.value">
              {{ unit.label }}
            </md-button-toggle>
          </md-button-toggle-group>
        </div>
        <md-error class="mat-body-2" *ngIf="form.get('age').hasError('ageInvalid')">年龄或单位不正确</md-error>
      </ng-container>
    </div>
    `,
  styles: [`
    .age-num{
      width: 50px;
    }
    .age-input{
      display: flex;
      flex-wrap: nowrap;
      flex-direction: row;
      align-items: baseline;
    }
  `],
  providers: [
    {
      provide: NG_VALUE_ACCESSOR,
      useExisting: forwardRef(() => AgeInputComponent),
      multi: true,
    },
    {
      provide: NG_VALIDATORS,
      useExisting: forwardRef(() => AgeInputComponent),
      multi: true,
    }
  ],
  changeDetection: ChangeDetectionStrategy.OnPush,
})
export class AgeInputComponent implements ControlValueAccessor, OnInit, OnDestroy {

  selectedUnit = AgeUnit.Year;
  form: FormGroup;
  ageUnits = [
    {value: AgeUnit.Year, label: '岁'},
    {value: AgeUnit.Month, label: '月'},
    {value: AgeUnit.Day, label: '天'}
  ];
  dateOfBirth;
  @Input() daysTop = 90;
  @Input() daysBottom = 0;
  @Input() monthsTop = 24;
  @Input() monthsBottom = 1;
  @Input() yearsBottom = 1;
  @Input() yearsTop = 150;
  @Input() debounceTime = 300;
  private subBirth: Subscription;
  private propagateChange = (_: any) => {};

  constructor(private fb: FormBuilder) { }

  ngOnInit() {
    const initDate = this.dateOfBirth ? this.dateOfBirth : toDate(subYears(Date.now(), 30));
    const initAge = this.toAge(initDate);
    this.form = this.fb.group({
      birthday: [initDate, this.validateDate],
      age:  this.fb.group({
        ageNum: [initAge.age],
        ageUnit: [initAge.unit]
      }, {validator: this.validateAge('ageNum', 'ageUnit')})
    });
    const birthday = this.form.get('birthday');
    const ageNum = this.form.get('age').get('ageNum');
    const ageUnit = this.form.get('age').get('ageUnit');

    const birthday$ = birthday.valueChanges
      .map(d => ({date: d, from: 'birthday'}))
      .debounceTime(this.debounceTime)
      .distinctUntilChanged()
      .filter(date => birthday.valid);
    const ageNum$ = ageNum.valueChanges
      .startWith(ageNum.value)
      .debounceTime(this.debounceTime)
      .distinctUntilChanged();
    const ageUnit$ = ageUnit.valueChanges
      .startWith(ageUnit.value)
      .debounceTime(this.debounceTime)
      .distinctUntilChanged();
    const age$ = Observable
      .combineLatest(ageNum$, ageUnit$, (_num, _unit) => this.toDate({age: _num, unit: _unit}))
      .map(d => ({date: d, from: 'age'}))
      .filter(_ => this.form.get('age').valid);
    const merged$ = Observable
      .merge(birthday$, age$)
      .filter(_ => this.form.valid)
      .debug('[Age-Input][Merged]:');
    this.subBirth = merged$.subscribe(date => {
      const age = this.toAge(date.date);
      if(date.from === 'birthday') {
        if(age.age === ageNum.value && age.unit === ageUnit.value) {
          return;
        }
        ageUnit.patchValue(age.unit, {emitEvent: false, emitModelToViewChange: true, emitViewToModelChange: true});
        ageNum.patchValue(age.age, {emitEvent: false});
        this.selectedUnit = age.unit;
        this.propagateChange(date.date);

      } else {
        const ageToCompare = this.toAge(this.form.get('birthday').value);
        if(age.age !== ageToCompare.age || age.unit !== ageToCompare.unit) {
          this.form.get('birthday').patchValue(date.date, {emitEvent: false});
          this.propagateChange(date.date);
        }
      }
    });
  }

  ngOnDestroy() {
    if(this.subBirth) {
      this.subBirth.unsubscribe();
    }
  }

  public writeValue(obj: Date) {
    if (obj) {
      const date = toDate(obj);
      this.form.get('birthday').patchValue(date, {emitEvent: false});
    }
  }

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

  public registerOnTouched() {
  }

  validate(c: FormControl): {[key: string]: any} {
    const val = c.value;
    if (!val) {
      return null;
    }
    if (isValidDate(val)) {
      return null;
    }
    return {
      ageInvalid: true
    };
  }

  validateDate(c: FormControl): {[key: string]: any} {
    const val = c.value;
    return isValidDate(val) ? null : {
      birthdayInvalid: true
    }
  }

  validateAge(ageNumKey: string, ageUnitKey:string): {[key: string]: any} {
    return (group: FormGroup): {[key: string]: any} => {
      const ageNum = group.controls[ageNumKey];
      const ageUnit = group.controls[ageUnitKey];
      let result = false;
      const ageNumVal = ageNum.value;

      switch (ageUnit.value) {
        case AgeUnit.Year: {
          result = ageNumVal >= this.yearsBottom && ageNumVal <= this.yearsTop
          break;
        }
        case AgeUnit.Month: {
          result = ageNumVal >= this.monthsBottom && ageNumVal <= this.monthsTop
          break;
        }
        case AgeUnit.Day: {
          result = ageNumVal >= this.daysBottom && ageNumVal <= this.daysTop
          break;
        }
        default:
          result = false;
      }
      return result ? null : {
        ageInvalid: true
      }
    }
  }

  private toAge(dateStr: string): Age {
    const date = parse(dateStr);
    const now = new Date();
    if (isBefore(subDays(now, this.daysTop), date)) {
      return {
        age: differenceInDays(now, date),
        unit: AgeUnit.Day
      };
    } else if (isBefore(subMonths(now, this.monthsTop), date)) {
      return {
        age: differenceInMonths(now, date),
        unit: AgeUnit.Month
      };
    } else {
      return {
        age: differenceInYears(now, date),
        unit: AgeUnit.Year
      };
    }
  }

  private toDate(age: Age): string {
    const now = new Date();
    switch (age.unit) {
      case AgeUnit.Year: {
        return toDate(subYears(now, age.age));
      }
      case AgeUnit.Month: {
        return toDate(subMonths(now, age.age));
      }
      case AgeUnit.Day: {
        return toDate(subDays(now, age.age));
      }
      default: {
        return this.dateOfBirth;
      }
    }
  }
}
           
JavaScriptAngularJS            
19人点赞
2 评论
评论

共同学习,写下你的评论

2楼                            
帅气的程序员                                


                               

老师希望你可以多开一些课程,
我觉得你讲课讲得特别好,来到现在新的公司,花了2周的时间看完你的Angula基础r课程后,我立马就可以胜任公司Angular的开发工作,感谢!


                               

0回复2019.08.10                                
1楼                            
闷骚程序源                                


                               

1
了解一点还是好的......                                                    


                               


来自   https://www.imooc.com/article/19369?block_id=tuijian_wz

普通分类: