为了账号安全,请及时绑定邮箱和手机立即绑定

细说 Angular 的自定义表单控件

2017.07.21 11:05 4917浏览

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

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

  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/materialinputdatepickerbutton-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 的响应式表单为我们提供了非常便利的方法可以得到这些变化的事件流,FormControlvalueChanges 属性就是一个 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;
      }
    }
  }
}
点击查看更多内容
19人点赞

若觉得本文不错,就分享一下吧!

评论

相关文章推荐

正在加载中
意见反馈 邀请有奖 帮助中心 APP下载
官方微信

举报

0/150
提交
取消