Angular 深入浅出三十天:表单与测试 Day11 - Reactive Forms 实作 - 动态表单初体验

Day11

今天要来用 Reactive Forms 的方式再来实作一次昨天的表单。

具体的规格需求跟昨天差不多,如下所示:

  • 被保险人的栏位:
    • 姓名(文字输入框)
      • 最少需要填写两个字,如验证有误则显示错误讯息姓名至少需两个字以上
      • 最多只能填写十个字,如验证有误则显示错误讯息姓名最多只能十个字
    • 性别(单选)
      • 选项:男性、女性
    • 年龄(下拉选单)
      • 选项: 18 岁、 20 岁、 70 岁、 75 岁
  • 以上栏位皆为必填,如验证有误则显示错误讯息此栏位为必填
  • 以上验证皆需在使用者输入时动态检查
  • 按下新增被保险人按钮可以新增被保险人
  • 按下删除被保险人按钮可以删除被保险人
  • 任一验证有误时,送出按钮皆呈现不可被点选之状态
  • 没有被保险人时,送出按钮皆呈现不可被点选之状态

规格需求看清楚之後,我们就来开始实作吧!

实作开始

首先我们一样先准备好基本的 HTML :

<form *ngIf="formGroup" [formGroup]="formGroup" (ngSubmit)="submit()">
  <fieldset>
    <legend>被保人</legend>
    <p>
      <label for="name">姓名:</label>
      <input type="text" id="name" formControlName="name" />
      <span class="error-message">{{ getErrorMessage("name") }}</span>
    </p>
    <p>
      性别:
      <input type="radio" id="male" value="male" formControlName="gender" />
      <label for="male">男</label>
      <input type="radio" id="female" value="female" formControlName="gender" />
      <label for="female">女</label>
    </p>
    <p>
      <label for="age">年龄:</label>
      <select id="age" formControlName="age">
        <option value="">请选择</option>
        <option value="18">18岁</option>
        <option value="20">20岁</option>
        <option value="70">70岁</option>
        <option value="75">75岁</option>
      </select>
      <span class="error-message">{{ getErrorMessage("age") }}</span>
    </p>
    <p><button type="button">删除</button></p>
  </fieldset>
  <p>
    <button type="button">新增被保险人</button>
    <button type="submit">送出</button>
  </p>
</form>

未经美化的画面跟昨天长得一样:

Template view

接着跟昨天一样先把它当成静态表单来准备相关的属性与方法:

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

@Component({
  selector: 'app-template-driven-forms-async-insured',
  templateUrl: './template-driven-forms-async-insured.component.html',
  styleUrls: ['./template-driven-forms-async-insured.component.scss']
})
export class TemplateDrivenFormsAsyncInsuredComponent {

  /**
   * 绑定在表单上
   *
   * @type {(FormGroup | undefined)}
   * @memberof ReactiveFormsAsyncInsuredComponent
   */
  formGroup: FormGroup | undefined;

  /**
   * 透过 DI 取得 FromBuilder 物件,用以建立表单
   *
   * @param {FormBuilder} formBuilder
   * @memberof ReactiveFormsAsyncInsuredComponent
   */
  constructor(private formBuilder: FormBuilder) {}

  /**
   * 当 Component 初始化的时候初始化表单
   *
   * @memberof ReactiveFormsAsyncInsuredComponent
   */
  ngOnInit(): void {
    this.formGroup = this.formBuilder.group({
      name: [
        '',
        [Validators.required, Validators.minLength(2), Validators.maxLength(10)]
      ],
      gender: ['', Validators.required],
      age: ['', Validators.required]
    });
  }

  /**
   * 透过栏位的 Errors 来取得对应的错误讯息
   *
   * @param {string} key
   * @param {number} index
   * @return {*}  {string}
   * @memberof ReactiveFormsAsyncInsuredComponent
   */
  getErrorMessage(key: string): string {
    const formControl = this.formGroup?.get(key);
    let errorMessage: string;
    if (!formControl || !formControl.errors || formControl.pristine) {
      errorMessage = '';
    } else if (formControl.errors.required) {
      errorMessage = '此栏位必填';
    } else if (formControl.errors.minlength) {
      errorMessage = '姓名至少需两个字以上';
    } else if (formControl.errors.maxlength) {
      errorMessage = '姓名至多只能输入十个字';
    }
    return errorMessage!;
  }

  /**
   * 绑定在表单上,当按下送出按钮时会触发此函式
   *
   * @memberof TemplateDrivenFormsAsyncInsuredComponent
   */
  submit(): void {
    // do submit...
  }
}

准备好相关的属性和方法之後,我们直接把他们跟 Template 绑定:

<form *ngIf="formGroup" [formGroup]="formGroup" (ngSubmit)="submit()">
  <fieldset>
    <legend>被保人</legend>
    <p>
      <label for="name">姓名:</label>
      <input
        type="text"
        id="name"
        formControlName="name"
      />
      <span class="error-message">{{ getErrorMessage('name') }}</span>
    </p>
    <p>
      性别:
      <input type="radio" id="male" value="male" formControlName="gender">
      <label for="male">男</label>
      <input type="radio" id="female" value="female" formControlName="gender">
      <label for="female">女</label>
    </p>
    <p>
      <label for="age">年龄:</label>
      <select id="age" formControlName="age">
        <option value="">请选择</option>
        <option value="18">18岁</option>
        <option value="20">20岁</option>
        <option value="70">70岁</option>
        <option value="75">75岁</option>
      </select>
      <span class="error-message">{{ getErrorMessage('age') }}</span>
    </p>
    <p><button type="button">删除</button></p>
  </fieldset>
  <p>
    <button type="button">新增被保险人</button>
    <button type="submit">送出</button>
  </p>
</form>

目前为止,大体上跟我们上次的实作差不多,应该没有什麽难度。

不过这次绑定 FormControl 的方式,我改成用 formControlName="name" ,而不是上次的 [formControl]="nameControl" ,大家可以自行选用喜欢的方式。

如果大家在这边有遇到问题,可以检查看看自己有没有引入 FormsModuleReactiveFormsModule ,我就不再赘述罗。

目前的结果:

result

有了基本的互动效果之後,我们就可以开始来思考怎麽样把这个表单变成动态的。

跟昨天一样的是,既然我们要让被保人可以被新增或删除,表示我们应该是会用阵列来表达这些被保人的资料,也就是说,我们现在的 FormGroup 要从 1 个变成 N 个。

之前曾经提到,我们如果从资料面来看, {} 代表表单,也就是 FormGroup'' 代表表单里的子栏位,也就是 FormControl ;那 [] 呢?

答案是 ─ FormArray

不过 FormArray 不能直接跟 form 元素绑定,唯一可以跟 form 元素绑定的只有 FormGroup ,所以 FormArray 一定要在 FormGroup 里面,就像这样:

this.formGroup = this.formBuilder.group({
  insuredList: this.formBuilder.array([])
});

这边要注意的是, FormArray 一定要透过 FormBuilder 或是 FormArray 的建构式来建立,像上面示范的那样,或是这样:

this.formGroup = this.formBuilder.group({
  insuredList: new FormArray([])
});

绝对不能偷懒写成这样:

this.formGroup = this.formBuilder.group({
  insuredList: []
});

这样的话,就会变成普通的 FormControl 罗!切记切记!

接着我们就可以将原本的程序码修改成用阵列的方式,并把新增被保人、删除被保人与判断表单是否有效的函式都补上:

@Component({
  // 省略...
})
export class AppComponent implements OnInit {

  /**
   * 绑定在表单上
   *
   * @type {(FormGroup | undefined)}
   * @memberof ReactiveFormsAsyncInsuredComponent
   */
  formGroup: FormGroup | undefined;

  /**
   *  用以取得 FormArray
   *
   * @readonly
   * @type {FormArray}
   * @memberof ReactiveFormsAsyncInsuredComponent
   */
  get formArray(): FormArray {
    return this.formGroup?.get('insuredList')! as FormArray;
  }

  /**
   * 绑定在送出按钮上,判断表单是不是无效
   *
   * @readonly
   * @type {boolean}
   * @memberof ReactiveFormsAsyncInsuredComponent
   */
  get isFormInvalid(): boolean {
    return this.formArray.controls.length === 0 || this.formGroup!.invalid;
  }

  /**
   * 透过 DI 取得 FromBuilder 物件,用以建立表单
   *
   * @param {FormBuilder} formBuilder
   * @memberof ReactiveFormsAsyncInsuredComponent
   */
  constructor(private formBuilder: FormBuilder) {}

  /**
   * 当 Component 初始化的时候初始化表单
   *
   * @memberof ReactiveFormsAsyncInsuredComponent
   */
  ngOnInit(): void {
    this.formGroup = this.formBuilder.group({
      insuredList: this.formBuilder.array([])
    });
  }

  /**
   * 新增被保人
   *
   * @memberof ReactiveFormsAsyncInsuredComponent
   */
  addInsured(): void {
    const formGroup = this.createInsuredFormGroup();
    this.formArray.push(formGroup);
  }

  /**
   * 删除被保人
   *
   * @param {number} index
   * @memberof ReactiveFormsAsyncInsuredComponent
   */
  deleteInsured(index: number): void {
    this.formArray.controls.splice(index, 1);
    this.formArray.updateValueAndValidity();
  }

  /**
   * 送出表单
   *
   * @memberof ReactiveFormsAsyncInsuredComponent
   */
  submit(): void {
    // do login...
  }

  /**
   * 透过栏位的 Errors 来取得对应的错误讯息
   *
   * @param {string} key
   * @param {number} index
   * @return {*}  {string}
   * @memberof ReactiveFormsAsyncInsuredComponent
   */
  getErrorMessage(key: string, index: number): string {
    const formGroup = this.formArray.controls[index];
    const formControl = formGroup.get(key);
    let errorMessage: string;
    if (!formControl || !formControl.errors || formControl.pristine) {
      errorMessage = '';
    } else if (formControl.errors.required) {
      errorMessage = '此栏位必填';
    } else if (formControl.errors.minlength) {
      errorMessage = '姓名至少需两个字以上';
    } else if (formControl.errors.maxlength) {
      errorMessage = '姓名至多只能输入十个字';
    }
    return errorMessage!;
  }

  /**
   * 建立被保人的表单
   *
   * @private
   * @return {*}  {FormGroup}
   * @memberof ReactiveFormsAsyncInsuredComponent
   */
  private createInsuredFormGroup(): FormGroup {
    return this.formBuilder.group({
      name: [
        '',
        [Validators.required, Validators.minLength(2), Validators.maxLength(10)]
      ],
      gender: ['', Validators.required],
      age: ['', Validators.required]
    });
  }
}

接着我们到 Template 里,把原本绑定的方式调整一下:

<form *ngIf="formGroup" [formGroup]="formGroup" (submit)="submit()">
  <ng-container
    formArrayName="insuredList"
    *ngFor="let control of formArray.controls; let index = index"
  >
    <fieldset [formGroupName]="index">
      <legend>被保人</legend>
      <p>
        <label [for]="'name-' + index">姓名:</label>
        <input type="text" [id]="'name-' + index" formControlName="name" />
        <span class="error">{{ getErrorMessage("name", index) }}</span>
      </p>
      <p>
        性别:
        <input
          type="radio"
          [id]="'male-' + index"
          value="male"
          formControlName="gender"
        />
        <label [for]="'male-' + index">男</label>
        <input
          type="radio"
          [id]="'female-' + index"
          value="female"
          formControlName="gender"
        />
        <label [for]="'female-' + index">女</label>
      </p>
      <p>
        <label [for]="'age-' + index">年龄:</label>
        <select name="age" [id]="'age-' + index" formControlName="age">
          <option value="">请选择</option>
          <option value="18">18岁</option>
          <option value="20">20岁</option>
          <option value="70">70岁</option>
          <option value="75">75岁</option>
        </select>
        <span class="error">{{ getErrorMessage("age", index) }}</span>
      </p>
      <p><button type="button" (click)="deleteInsured(index)">删除</button></p>
    </fieldset>
  </ng-container>
  <p>
    <button type="button" (click)="addInsured()">新增被保险人</button>
    <button type="submit" [disabled]="isFormInvalid">送出</button>
  </p>
</form>

初次看到这种绑定方式的 Angular 初学者可能会傻眼,不过静下心来看之後你会发现,其实这只是我们所建立的 FormGroup 里的阶层关系,这样绑定 Angular 才能从一层层的表单之中开始往下找。

如果我们把其他的 HTML 都拿掉的话其实会清楚很多:

<form *ngIf="formGroup" [formGroup]="formGroup" (submit)="submit()">
  <!-- 其他省略 -->
</form>

最外层的这个大家应该都知道,就是我们在 .ts 里的 formGroup

<form *ngIf="formGroup" [formGroup]="formGroup" (submit)="submit()">
  <ng-container
    formArrayName="insuredList"
    *ngFor="let control of formArray.controls; let index = index"
  >
    <!-- 其他省略 -->
  </ng-container>
</form>

而这里呢,就像我们写静态表单的时候,会从 FormGroup 里根据对应的 key 值找到对应的 FormControl 一样,这里则是把对应的 FormArray 找出来。

然後再用 *ngFor 的方式,把 FormArray 底下的 AbstractControl 都回圈出来。

关於 AbstractControl ,它其实是一个抽象类别,而 FormGroupFormArrayFormControl 这三种类型其实都继承於这个类别,所以大家不知道有没有注意到,一般我们在 .ts 里使用的时候,我们会特别用 as FormControl 或是 as FormArray 的方式来让编译器知道现在取得的物件实体是什麽型别,以便後续使用。

想知道更多 AbstractControl 的资讯的话,请参考官方 API 文件: https://angular.io/api/forms/AbstractControl

<form *ngIf="formGroup" [formGroup]="formGroup" (submit)="submit()">
  <ng-container
    formArrayName="insuredList"
    *ngFor="let control of formArray.controls; let index = index"
  >
    <fieldset [formGroupName]="index">
    </fieldset>
  </ng-container>
</form>

最後再用索引值 index 找出对应的 FormGroup

而要做这件事情其实要有相对应的阶层关系的 HTML 来帮忙,但因为我的 HTML 的阶层关系少一层,所以我才会用 ng-container 多做一层阶层,好让我的表单可以顺利绑上去。

如果今天你做的 HTML 的阶层数是足够的,就可以不用用 ng-container 多做一层阶层,例如把上面的 HTML 改成这样其实也可以:

<form *ngIf="formGroup" [formGroup]="formGroup" (submit)="submit()">
  <div
    formArrayName="insuredList"
    *ngFor="let control of formArray.controls; let index = index"
  >
    <fieldset [formGroupName]="index">
    </fieldset>
  </div>
</form>

不过用 ng-container 的好处是这个元素并不会真的出现在画面上,大家可以视情况斟酌使用。

改完之後就大功告成罗!来看看最後的结果:

result

本日小结

今天的学习重点主要是在围绕在 FormArray 上,因为多了这个阶层的关系,所以在与 Template 的绑定上看起来会较为复杂一点点。

话虽如此,大家可以拿今天的 template 与昨天的 template 互相比较一下,除了 forid 这两个属性因为天生局限的关系真的没办法之外,但 name 的部份就不用再去处理了,还是很方便的。

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

如果有任何的问题或是回馈,也都非常欢迎留言给我让我知道噢!


<<:  D20 Email认证信 SMTP - Gmail

>>:  第11天~

Day 27 Explore monitoring and reporting

Monitor applications and services Azure Monitor An...

Ceph and OpenStack - Best Practices Part I

Ceph and OpenStack 现在已经是 IaaS 中成双成对的一个组合。根据 2017 年...

从零开始学3D游戏开发:入门程序实作 Part.6 用脚本计算分数

这是 Roblox 从零开始系列,入门章节的第十二个单元,今天你将学会如何把分数显示在右上角的玩家仪...

TailwindCSS 从零开始 - 完赛心得

完赛的内心小剧场 心得 感谢坚持到最後的自己,本次铁人赛完赛了,最後一篇就来点软性的心得文吧! 因...

Android Studio初学笔记-Day7-Button和Toast

Button和Toast 今天要介绍的是Button这个常在程序中能看到的元件,在Button的属性...