Angular 深入浅出三十天:表单与测试 Day28 - 自订表单元件

Day28

经过了这段时间的练习与学习,相信大家应该越来越能体会 Angular 表单的强大与便利。

不过既然 Angular 表单这麽好用,如果能让自己做的 Component 也像 Angular 表单那样一般使用该有多好?

因此,今天想要跟大家分享的是 ─ 如何自订表单元件

应用场景

大家跟我一起想像一下,假设我们今天需要做一个管理平台,在这个管理平台里,会有很多地方都会需要用到我们昨天做的 DateRangeComponent ,但不一定会是在同一个表单里,只是刚好也需要 startDateendDate 这两个栏位,而且画面与栏位验证的规则也都是一样。

例如:

A 页面是一个查询订单系统, B 页面是查询会员系统,虽然这两个页面的查询条件可能都不太一样,但恰好都可以根据起迄日来查询相应的资料。

这时,我们很有可能就会将我们做好的 DateRangeComponent 做成表单元件,让 A 跟 B 在使用它的时候,就像使用一般的表单元件一样轻松、自然。

那究竟要怎麽做呢?

ControlValueAccessor

首先要介绍给大家认识的是 ControlValueAccessor ,它是个 Interface ,而它定义了以下四个函式:

interface ControlValueAccessor {
  writeValue(obj: any): void
  registerOnChange(fn: any): void
  registerOnTouched(fn: any): void
  setDisabledState(isDisabled: boolean)?: void
}
  • writeValue(obj: any): void表单控件想要将值写入时,会呼叫此函式
  • registerOnChange(fn: any): void表单控件初始化时会呼叫此函式,并传入一个回呼函式,让实作此介面的类别在其值有变动时,使用该回呼函式并传入欲变动的值
  • registerOnTouched(fn: any): void表单控件初始化时会呼叫此函式,并传入一个回呼函式,让实作此介面的类别在失去焦点时,使用该回呼函式以通知表单控件
  • setDisabledState(isDisabled: boolean)?: void ─ 当表单控件的状态变成 DISABLED 抑或是从 DISABLED 改变成其他状态时,会呼叫此函式以通知实作此介面的类别

虽然我觉得我说的满清楚的,但大家应该还是觉得很模糊,对吧?

不要紧,我只是先让大家有个印象,待会实作时大家就会更加理解了。

实作开始

首先,我们需要另一个 Component 来用我们昨天做好的 DateRangeComponent ,像这样:

<form *ngIf="formGroup" [formGroup]="formGroup">
  <app-date-range formControlName="dateRange"></app-date-range>
</form>

然後在 Component 的 .ts 里准备好 FormGroup ,像这样:

export class ReactiveFormsDateRangeComponent implements OnInit {

  formGroup: FormGroup | undefined;

  constructor(private formBuilder: FormBuilder) { }

  ngOnInit(): void {
    this.formGroup = this.formBuilder.group({ dateRange: '' });
  }

}

接着打开昨天做的 DateRangeComponent ,并在 implements 的後方加上 ControlValueAccessor ,像这样:

export class DateRangeComponent implements OnInit, ControlValueAccessor {
  // ...
}

这时你应该会发现 DateRangeComponent 出现了一条红色毛毛虫,当你把滑鼠游标移到上面的时候,它说:

The Tooltip

这是因为我们为 DateRangeComponent 加上实作 ControlValueAccessor 的宣告後,编辑器提醒我们要记得实作 ControlValueAccessor 的四个函式,才符合该介面的定义。

这就像是我们如果想要 Cosplay 钢铁人,但我什麽盔甲都没穿就说自己是钢铁人,别人只会觉得满脸问号。

但只要我们戴上了头盔,别人就会知道你在扮演钢铁人。

所以我们就在 DateRangeComponent 里加上以下四个函式:

export class DateRangeComponent implements OnInit, ControlValueAccessor {

  // ...

  writeValue(obj: any): void {
    console.log('writeValue', obj);
  }

  registerOnChange(fn: any): void {
    console.log('registerOnChange', fn);
  }
  registerOnTouched(fn: any): void {
    console.log('registerOnTouched', fn);
  }

  setDisabledState(isDisabled: boolean): void {
    console.log('setDisabledState', isDisabled);
  }

}

接下来,我们需要在 DateRangeComponentMetaData 里的 providers 里加入一些设定,像这样:

@Component({
  selector: 'app-date-range',
  templateUrl: './date-range.component.html',
  styleUrls: ['./date-range.component.scss'],
  providers: [
    {
      provide: NG_VALUE_ACCESSOR,
      useExisting: forwardRef(() => DateRangeComponent),
      multi: true
    }
  ]
})
export class DateRangeComponent implements OnInit, ControlValueAccessor {
  // ...
}

我们之前其实也曾经在第二十五天的文章 ─ 测试进阶技巧 - DI 抽换里用过类似的技巧。

简单来说,这个设定是为了让表单可以透过 NG_VALUE_ACCESSOR 这个 InjectionToken 取得我们这个实作了 ControlValueAccessor 介面的 DateRangeComponent 实体。

想知道什麽是 InjectionToken 的朋友,可以参考 Mike 的 [Angular 大师之路] Day 23 - 认识 InjectionToken

想知道 useExistinguseValueuseClassuseFactory 有哪里不一样的,也可以参考 Mike 的 [Angular 大师之路] Day 20 - 在 @NgModule 的 providers: [] 自由更换注入内容 (1)[Angular 大师之路] Day 21 - 在 @NgModule 的 providers: [] 自由更换注入内容 (2)

forwardRef() 的部份,我觉得官网的 Dependency injection in action - Break circularities with a forward class reference 讲得比较清楚。

最後的 multi: true ,可以参考林颖平 EP 的 [Day 8] 所以我说那个 multi 是? ,如果想要更深入的了解其原理,他也写了一篇 [Day 10] 深度看一下 Angular 建立 multi provider 的机制(真的很深入)

至此,我们就可以储存档案来看一下初始化完後会印出的 Log :

Log

接着我们在使用 DateRangeComponent 的 Component 里加上以下程序码以观察其运作结果:

ngOnInit(): void {
  this.formGroup = this.formBuilder.group({
    dateRange: ''
  });
  setTimeout(() => {
    console.log('---- 3秒後 ----');
    this.formGroup?.setValue({ dateRange: 'Leo' });
    this.formGroup?.disable();
  }, 3000);
}

然後我们会发现:

Log

这样大家有比较了解一开始关於 ControlValueAccessor 各函式的说明了吗?

如果用图示的话,现在的结构大概像这样:

Image 1

如果我们设值给 FormControl 时,则会触发 ControlValueAccessor 的函式 writeValue

Image 2

如果我们 disableenable 了该 FormControl ,则会触发 ControlValueAccessor 的函式 setDisabledState

Image 3

而如果使用者改动了自订的表单元件的值,则我们自订的表单元件应该要呼叫透过初始化时所触发的 registerOnChange 所传入的 fn 去通知 FormControl

Image 4

读万卷书不如行万里路。接下来,我们把剩下的实作做完就会更了解这其中的运作流程了!

首先,先加工一下使用 DateRangeComponent 的 Component :

export class ReactiveFormsDateRangeComponent implements OnInit {

  formGroup: FormGroup | undefined;

  constructor(private formBuilder: FormBuilder) { }

  ngOnInit(): void {
    const date = new Date();
    this.formGroup = this.formBuilder.group({
      dateRange: `${date.getFullYear()}-${date.getMonth() + 1}-${date.getDate()}`
    });
  }

  enable(): void {
    this.formGroup?.enable();
  }

  disable(): void {
    this.formGroup?.disable();
  }

}

Template 的部份也加工一下:

<form *ngIf="formGroup" [formGroup]="formGroup">
  <app-date-range formControlName="dateRange"></app-date-range>
  <p>
    <button type="button" [disabled]="formGroup.disabled" (click)="disable()">DISABLE</button>
    <button type="button" [disabled]="formGroup.enabled" (click)="enable()">ENABLE</button>
  </p>
</form>
<pre>{{ formGroup?.getRawValue() | json }}</pre>

然後把 DateRangeComponent 改成这样:

export class DateRangeComponent implements OnInit, ControlValueAccessor {

  formGroup: FormGroup | undefined;

  fnFormRegisterOnChange: ((dateString: string) => void) | undefined;
  fnFormRegisterOnTouched: (() => void) | undefined;

  constructor(private formBuilder: FormBuilder) { }

  ngOnInit(): void {
    this.formGroup = this.formBuilder.group({
      startDate: ['', [Validators.required, Validators.pattern(/^\d{4}-\d{2}-\d{2}$/)]],
      endDate: ['', Validators.pattern(/^\d{4}-\d{2}-\d{2}$/)]
    }, { validators: dateRangeValidator });

    this.formGroup.valueChanges.subscribe(({ startDate, endDate }) => {
      let dateString = startDate;
      if (endDate) {
        dateString += `, ${endDate}`;
      }
      if (this.formGroup?.errors) {
        dateString = '';
      }
      if (this.fnFormRegisterOnChange) {
        this.fnFormRegisterOnChange(dateString);
      }
    });
  }

  writeValue(dateRangeString: string): void {
    const [startDate, endDate] = dateRangeString.split(', ');
    this.formGroup?.patchValue({ startDate, endDate }, {
      emitEvent: false
    });
  }

  registerOnChange(fn: (dateRangeString: string) => void): void {
    this.fnFormRegisterOnChange = fn;
  }
  registerOnTouched(fn: () => void): void {
    this.fnFormRegisterOnTouched = fn;
  }

  setDisabledState(isDisabled: boolean): void {
    if (isDisabled) {
      this.formGroup?.disable();
    } else {
      this.formGroup?.enable();
    }
  }

}

结果:

Result

对了,这样的作法不仅仅只适用於 Reactive Forms 噢!大家可以在使用 DateRangeComponent 的时候用 Template Driven Forms 的方式试试看,也是行得通的唷!

本日小结

今天的实作练习应该满好玩的吧?

我能理解大家第一次碰到的时候都会比较难以理解,记得我第一次碰到的时候,也只是复制人家的程序码然後贴上而已,根本就不是了解其运作原理。

因此,希望我今天的文章能让大家可以不仅仅只是复制贴上,而是对於其流程与原理有所掌握与理解。

今天的程序码会放在 Github - Branch: day28 上供大家参考,建议大家在看我的实作之前,先按照需求规格自己做一遍,之後再跟我的对照,看看自己的实作跟我的实作不同的地方在哪里、有什麽好处与坏处,如此反覆咀嚼消化後,我相信你一定可以进步地非常快!

如果有任何的问题或是回馈,还请麻烦留言给我让我知道!


<<:  DAY28 - 工程师一定会用到的GIT懒人包

>>:  Day 29 : C语言 - 河内塔的程序递回执行顺序为何?

#8 - Reading & Writing Files (fs)

今天要学习的依然是 node.js 的core modules (就是内建的模组啦),主角是:fs ...

Day_08 有线网路应用(一)

延续昨天Day_07 有线网路应用(一)进度 讨论一些我遇到的问题与细节补充。 Troublesho...

感谢此时此刻的自己 - 30天完赛

我喜欢艺术,也喜欢程序码 一开始因为工作关系强迫自己要学会程序码,原本不能接受,甚至觉得我没有天份在...

组织专案支持过程(Organizational project-enabling processes)

-NIST SP800-160 V1 和 ISO 15288 工程 是一种方法,它涉及一系列应用知...

Day5 - 找出适合自己的案子

当花了很多时间整理经营作品集後,得到主动来信询问真的会非常感动,可以感受到自己是被社会所需要的,在肯...