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

Day10

今天要来用 Template Driven Forms 的方式实作一个很简易的动态表单,使用上有点像是保险业者的系统,可以新增多名被保人,也可以编辑与删除被保人。

具体的规格需求如下:

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

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

开始实作

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

<form>
  <fieldset>
    <legend>被保人</legend>
    <p>
      <label for="name">姓名:</label>
      <input 
        type="text" 
        name="name" 
        id="name"
        required
        maxlength="10" 
        minlength="2"
      />
      <span class="error-message"></span>
    </p>
    <p>
      性别:
      <input type="radio" name="gender" id="male" value="male">
      <label for="male">男</label>
      <input type="radio" name="gender" id="female" value="female">
      <label for="female">女</label>
    </p>
    <p>
      <label for="age">年龄:</label>
      <select name="age" id="age" required>
        <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"></span>
    </p>
    <p><button type="button">删除</button></p>
  </fieldset>
  <p>
    <button type="button">新增被保险人</button>
    <button type="submit">送出</button>
  </p>
</form>

未经美化的画面应该会长这样:

Template view

基本的 HTML 准备好之後,我建议对於 Angular 还没那麽熟悉的朋友先不要一口气就想要直接把它做成动态的,先把它当成静态表单来做会比较简单一些。

因此,我们先准备相关的属性与方法:

import { Component } from '@angular/core';
import { ValidationErrors } 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 {

  // 绑在姓名栏位上
  name = '';

  // 绑在性别栏位上
  gender = '';

  // 绑在年龄栏位上
  age = '';

  // 姓名栏位的错误讯息
  nameErrorMessage = '';

  // 年龄栏位的错误讯息
  ageErrorMessage = '';

  /**
   * 绑定在姓名栏位上,当使用者改变被保险人的姓名时,会触发此函式,并取得对应的错误讯息
   *
   * @param {string} name
   * @param {ValidationErrors | null} errors
   * @memberof TemplateDrivenFormsAsyncInsuredComponent
   */
  insuredNameChange(name: string, errors: ValidationErrors | null): void {
    this.name = name;
    this.nameErrorMessage = this.getErrorMessage(errors);
  }

  /**
   * 绑定在年龄栏位上,当使用者改变被保险人的年龄时,会触发此函式,并取得对应的错误讯息
   *
   * @param {string} age
   * @param {ValidationErrors | null} errors
   * @memberof TemplateDrivenFormsAsyncInsuredComponent
   */
   insuredAgeChange(age: string, errors: ValidationErrors | null): void {
    this.age = age;
    this.ageErrorMessage = this.getErrorMessage(errors);
  }

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

  /**
   * 根据 FormControl 的 errors 属性取得相应的错误讯息
   *
   * @private
   * @param {ValidationErrors | null} errors - FormControl 的 errors
   * @return {*}  {string}
   * @memberof TemplateDrivenFormsAsyncInsuredComponent
   */
  private getErrorMessage(errors: ValidationErrors | null): string {
    let errorMessage = '';
    if (errors?.required) {
      errorMessage = '此栏位必填';
    } else if (errors?.minlength) {
      errorMessage = '姓名至少需两个字以上';
    }
    return errorMessage;
  }
}

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

<form (ngSubmit)="submit()">
  <fieldset>
    <legend>被保人</legend>
    <p>
      <label for="name">姓名:</label>
      <input
        type="text"
        name="name"
        id="name"
        required
        maxlength="10"
        minlength="2"
        #nameNgModel="ngModel"
        [ngModel]="name"
        (ngModelChange)="insuredNameChange(nameNgModel.value, nameNgModel.errors)"
      />
      <span class="error-message">{{ nameErrorMessage }}</span>
    </p>
    <p>
      性别:
      <input
        type="radio"
        name="gender"
        id="male"
        value="male"
        required
        [(ngModel)]="gender"
      >
      <label for="male">男</label>
      <input
        type="radio"
        name="gender"
        id="female"
        value="female"
        required
        [(ngModel)]="gender"
      >
      <label for="female">女</label>
    </p>
    <p>
      <label for="age">年龄:</label>
      <select
        name="age"
        id="age"
        required
        #ageNgModel="ngModel"
        [ngModel]="age"
        (ngModelChange)="insuredAgeChange(ageNgModel.value, ageNgModel.errors)"
      >
        <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">{{ ageErrorMessage }}</span>
    </p>
    <p><button type="button">删除</button></p>
  </fieldset>
  <p>
    <button type="button">新增被保险人</button>
    <button type="submit">送出</button>
  </p>
</form>

从目前的程序码应该不难发现,大体上跟我们第二天的实作内容差不多、结构也差不多,应该没有什麽难度。

如果大家在这边有遇到问题,大致上可以检查看看自己有没有引入 FormsModule ,抑或者是表单栏位上是否有 name 属性,我就不再赘述罗。

目前的结果:

result

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

相信大家一定知道,既然我们要让被保人可以被新增或删除,表示我们应该是会用阵列来存放这些被保人的资料,所以我们可以先将这些我们需要的资料栏位定义一个型别以便後续使用。

像是这样:

export type Insured = {
  name: string;
  gender: string;
  age: number;
  nameErrorMessage: string;
  ageErrorMessage: string;
};

或者是这样:

export interface Insured {
  name: string;
  gender: string;
  age: number;
  nameErrorMessage: string;
  ageErrorMessage: string;
};

甚至是这样:

export class Insured {
  name: string;
  gender: string;
  age: string;
  nameErrorMessage: string;
  ageErrorMessage: string;
};

这三种定义型别的方式基本上都可以,我就不多解释他们之间的差异了,我个人近期是满喜欢用第一种的。

接着我们就可以将原本那些单个的属性拿掉,改成用阵列的方式,像是这样:

// 以上省略...
import { Insured } from './insured.type';

@Component({
  // 省略...
})
export class TemplateDrivenFormsAsyncInsuredComponent {

  // 被保险人清单
  insuredList: Insured[] = [];

  // 以下这些都可以移除
  // name = '';
  // gender = '';
  // age = '';
  // nameErrorMessage = '';
  // ageErrorMessage = '';

  // 以下省略...
}

这些单个的属性移除掉之後,原本有使用到它们的部分就会坏掉,所以我们要将它们改为使用传进来的被保人的资料,像这样:

// 以上省略...
import { Insured } from './insured.type';

@Component({
  // 省略...
})
export class TemplateDrivenFormsAsyncInsuredComponent {

  // 被保险人清单
  insuredList: Insured[] = [];

  /**
   * 绑定在姓名栏位上,当使用者改变被保险人的姓名时,会触发此函式,并取得对应的错误讯息
   *
   * @param {string} name
   * @param {ValidationErrors | null} errors
   * @param {Insured} insured
   * @memberof TemplateDrivenFormsAsyncInsuredComponent
   */
  insuredNameChange(name: string, errors: ValidationErrors | null, insured: Insured): void {
    insured.name = name;
    insured.nameErrorMessage = this.getErrorMessage(errors);
  }

  /**
   * 绑定在年龄栏位上,当使用者改变被保险人的年龄时,会触发此函式,并取得对应的错误讯息
   *
   * @param {string} age
   * @param {ValidationErrors | null} errors
   * @param {Insured} insured
   * @memberof TemplateDrivenFormsAsyncInsuredComponent
   */
   insuredAgeChange(age: string, errors: ValidationErrors | null, insured: Insured): void {
    insured.age = age;
    insured.ageErrorMessage = this.getErrorMessage(errors);
  }

  // 以下省略...
}

接着我们就可以到 Template 里,将所有被保人的资料用 *ngFor 的方式回圈出来,并将原本用单个属性绑定的部份也改为绑定回圈出来的被保人资料:

<form (ngSubmit)="submit()">
  <!-- 将所有被保人的资料回圈出来 -->
  <fieldset *ngFor="let insured of insuredList">
    <legend>被保人</legend>
    <p>
      <label for="name">姓名:</label>
      <!-- 改为绑定被回圈出来的被保人资料,并将其传入函式内 -->
      <input
        type="text"
        name="name"
        id="name"
        required
        maxlength="10"
        minlength="2"
        #nameNgModel="ngModel"
        [ngModel]="insured.name" 
        (ngModelChange)="insuredNameChange(nameNgModel.value, nameNgModel.errors, insured)"
      />
      <span class="error-message">{{ insured.nameErrorMessage }}</span>
    </p>
    <p>
      性别:
      <!-- 改为绑定被回圈出来的被保人资料 -->
      <input
        type="radio"
        name="gender"
        id="male"
        value="male"
        required
        [(ngModel)]="insured.gender"
      >
      <label for="male">男</label>
      <input
        type="radio"
        name="gender"
        id="female"
        value="female"
        required
        [(ngModel)]="insured.gender"
      >
      <label for="female">女</label>
    </p>
    <p>
      <label for="age">年龄:</label>
      <!-- 改为绑定被回圈出来的被保人资料,并将其传入函式内 -->
      <select
        name="age"
        id="age"
        required
        #ageNgModel="ngModel"
        [ngModel]="insured.age"
        (ngModelChange)="insuredAgeChange(ageNgModel.value, ageNgModel.errors, insured)"
      >
        <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">{{ insured.ageErrorMessage }}</span>
    </p>
    <p><button type="button">删除</button></p>
  </fieldset>
  <p>
    <button type="button">新增被保险人</button>
    <button type="submit">送出</button>
  </p>
</form>

接着我们就可以储存以查看目前的结果:

result

咦?!怎麽表单栏位不见了?!

别紧张,这是因为 insuredList 现在是个空阵列呀!

接下来我们再加个新增被保险人与删除被保险人的函式:

/**
  * 新增被保险人
  *
  * @memberof TemplateDrivenFormsAsyncInsuredComponent
  */
addInsured(): void {
  const insured: Insured = {
    name: '',
    gender: '',
    age: '',
    nameErrorMessage: '',
    ageErrorMessage: ''
  };
  this.insuredList.push(insured);
}

/**
  * 删除被保险人
  *
  * @param {number} index
  * @memberof TemplateDrivenFormsAsyncInsuredComponent
  */
deleteInsured(index: number): void {
  this.insuredList.splice(index, 1);
}

然後把它们绑定到按钮上,并且在 *ngFor 里新增索引的宣告,以供删除时使用 :

<form (ngSubmit)="submit()">
  <fieldset *ngFor="let insured of insuredList; let index = index">
    <!-- 中间省略... -->
    <p><button type="button" (click)="deleteInsured(index)">删除</button></p>
  </fieldset>
  <p>
    <button type="button" (click)="addInsured()">新增被保险人</button>
    <button type="submit">送出</button>
  </p>
</form>

结果:

result

虽然我们的表单就差不多快完成了,但其实我们的表单目前有两个问题,不晓得大家有没有发现?

问题一

thinking

专业的前端工程师来说,我们做出来的表单一定要让人家有良好的使用者体验。

为此,我们通常会使用一些 HTML 的属性来让我们的表单更为人性化,像是在 label 上加 for

但问题来了, for 要跟 id 搭配使用,但 id 一整页只会有一个,而我们可能会有 N 个被保险人,怎办?

这时候我们可以善用阵列的索引值来帮我们达成这个目的,像是这样:

<label [for]="'name-' + index">姓名:</label>
<input
  type="text"
  [name]="'name-' + index"
  [id]="'name-' + index"
  required
  maxlength="10"
  minlength="2"
  #nameNgModel="ngModel"
  [ngModel]="insured.name"
  (ngModelChange)="insuredNameChange(nameNgModel.value, nameNgModel.errors, insured)"
/>

我知道很丑,但没办法,这是天生的局限。

对了, name 属性也要噢!因为表单里的 name 也是唯一性的。

问题二

thinking

这个问题是因为在画面重新渲染完之後, NgForm 里面 Key 值为 xxx-0NgModel 们就不见了,只留下 xxx-1NgModel 们。在这之後如果再按新增被保人时,由於新增的那一笔的索引是 1 ,就又会把原本留下的 Key 值为 xxx-1NgModel 们盖掉,导致大家现在所看到的情况。

thinking

解决方式其实说难不难,因为其实 *ngFor 有个 trackBy 的参数,只要传入这个参数就可以解决这个问题。但说简单也不简单,不知道原因跟解法的人就会卡上一段时间。

其实我一开始也卡住,还跟社群的人求救,进而引出一大串的讨论(笑)。

方式是先在 .ts 里加一个函式:

/**
  * 根据索引来重新渲染有更改的节点
  *
  * @param {string} index
  * @return {*}  {number}
  * @memberof AppComponent
  */
trackByIndex(index: number): number {
  return index;
}

然後在 *ngFor 的後面加上:

<fieldset *ngFor="let insured of insuredList; let index = index; trackBy: trackByIndex">

这样就可以解决我们的问题了!

最後,我们就剩以下两项事情还没做:

  • 任一验证有误时,送出按钮皆呈现不可被点选之状态
  • 没有被保险人时,送出按钮皆呈现不可被点选之状态

这两件事情基本上可以看成同一件事情 ─ 判断表单是否无效。

怎麽判断呢?

大家记不记得上次有用到一个类别叫做 NgForm ,当表单内的验证有误时, NgForm 的属性 invalid 就会为 true

所以我们一样可以利用它来帮我们判断,像这样:

<form #form="ngForm" (ngSubmit)="submit()">
  <fieldset *ngFor="let insured of insuredList; let index = index">
    <!-- 中间省略... -->
  </fieldset>
  <p>
    <button type="button" (click)="addInsured()">新增被保险人</button>
    <button type="submit" [disabled]="insuredList.length === 0 || form.invalid">送出</button>
  </p>
</form>

结果:

result

本日小结

今天的学习重点主要是在练习如何让静态的表单变成动态,虽然没有多复杂,但可能也是会难倒大部分的初学者。

其实大体上的逻辑跟实作登入时是差不多的,大家之所以会卡住主要可能会是因为不知道如何让静态表单变成动态,而以 Template Driven Forms 的方式来说,满多程序码都会绑在 Template 上,大家在实作时要看清楚才不会出错。

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

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


<<:  Day10-流量限制(五)

>>:  Day11-React 表单验证篇-不使用 hook 或第三方函式库

学校的白色咖啡屋(一):继承的根基与扩展的自由 Inheritance Implement Any

为了加强记忆,诗忆总会整理前一晚的笔记。 「可是,要怎麽确保每个类别都有toString()函式呢?...

#1. Hidden Search Component搜寻框弹出效果(CSS)

今天的任务: 部署至GitHub Page(後续部署在vercel) 搜寻框弹出效果 Demo Li...

Day18 - 语音辨识神级工具-Kaldi part3

今天我们进入kaldi训练神经网路模型的部分,程序的部分是在 local/chain/tuning/...

30天学会C语言: Day 7-switch ON!!!

如果一段 if-else if 中的条件都在判断 同一笔资料 是不是 等於某值,这段 if-else...

meownaori1630

http://yhn777.com https://baccarat-know-how.blogsp...