[從 0 開始的 Angular 生活]No.47 Angular 範本驅動表單 (Template-Driven Forms)

前言

介紹完 Angular 內兩種表單的不同後,接著實作看看範本驅動表單吧。

需求

Angular 框架支援:

  • 雙向資料繫結
  • 變更檢測
  • 驗證和錯誤處理

而實作過程中,將學會:

  • 用元件和範本建構 Angular 表單
  • 用 ngModel 建立雙向資料繫結,以讀取和寫入輸入控制元件的值
  • 追蹤狀態的變化,並驗證表單控制元件
  • 使用特殊的 CSS 類來追蹤控制元件的狀態並給出視覺反饋
  • 向用戶顯示驗證錯誤提示,以及啟用/禁用表單控制元件
  • 使用範本參考變數在 HTML 元素之間共享資訊

這個範例將:

  • 以範本驅動表單的方式實作一個建立英雄的表單
  • 必填的欄位在左側有個綠色的豎條,代表這個欄位是必填的
    • 如果沒有填寫,表單就會用醒目的樣式把驗證錯誤顯示出來
  • 而如果有條件未達成,則無法按下 Submit 按鈕提交

環境準備

建立專案

建立專案的部分就不再贅述了。

建立 hero 的 class

因為每次輸入表單資料時,資料大致上都是固定的,因此可以建立一個 hero 的 class 來處理這些事情。
之後要使用時可以透過 new 將其實例化成物件後,方便我們取用。

hero.ts

1
2
3
4
5
6
7
8
export class Hero {
constructor(
public id: number,
public name: string,
public power: string,
public alterEgo?: string
) {}
}

其中 alterEgo 屬性後面接了 ? 號,代表 alterEgo 屬性不是必須的,呼叫建構函式時這個參數可以省略。

也就是說之後我們可以這樣來建立一個新英雄:

1
2
let myHero =  new Hero(1, '超級牛', '超級牛來拯救雷', '牛妹妹');
console.log('My hero is called ' + myHero.name);

建立表單元件

輸入 ng g c HeroForm 建立元件。

接著在 class 內寫一些東西:
hero-form.component

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import { Component, OnInit } from '@angular/core';
import { Hero } from '../hero';

@Component({
selector: 'app-hero-form',
templateUrl: './hero-form.component.html',
styleUrls: ['./hero-form.component.scss']
})
export class HeroFormComponent implements OnInit {
// 能力陣列
powers = ['噴火', '降雷', '結冰', '呼風'];
// 預設的 model 物件
model = new Hero(1, '火焰鳥', this.powers[0], '黑火焰鳥');
// 阻止提交
submitted = false;
constructor() { }

ngOnInit() {
}
onSubmit() {
this.submitted = true;
}
}

修改 app.module.ts

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import { BrowserModule } from '@angular/platform-browser';
import { NgModule } from '@angular/core';
import { FormsModule } from '@angular/forms';

import { AppComponent } from './app.component';
import { HeroFormComponent } from './hero-form/hero-form.component';

@NgModule({
declarations: [
AppComponent,
HeroFormComponent
],
imports: [
BrowserModule,
FormsModule
],
providers: [],
bootstrap: [AppComponent]
})
export class AppModule { }

在這裡要匯入:

  • FormsModule
  • 把 FormsModule 新增到 ngModule 裝飾器的 imports 陣列中,這樣應用就能訪問範本驅動表單的所有特性,包括 ngModel

修改 app.component.ts

1
<app-hero-form></app-hero-form>

別忘了在根元件內把這個子元件載入。

建立初始 HTML 表單範本

修改 hero-form 的元件範本 (Template)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<div class="container">
<h1>Hero Form</h1>
<form>
<div class="form-group">
<label for="name">姓名</label>
<input type="text" class="form-control" id="name" required>
</div>
<div class="form-group">
<label for="alterEgo">裏人格</label>
<input type="text" class="form-control" id="alterEgo">
</div>
<button type="submit" class="btn btn-success">提交</button>
</form>
</div>

在這裡添加了兩個欄位:

  • 姓名 - 必填
  • 裏人格 - 非必填

可以發現到這一小段 HTML5 的程式碼,裡面用了一些 Bootstrap4 的 className 。

但這不是必需的,這裡只是因為美觀所以想要使用。

可以透過修改 src/styles.css 引入 Bootstrap4。

1
2
/* You can add global styles to this file, and also import other style files */
@import url('https://stackpath.bootstrapcdn.com/bootstrap/4.2.1/css/bootstrap.min.css');

接著運行開發伺服器,觀察一下目前的樣子。

添加能力選單
還記得我們在 HeroFormComponent 的 class 內寫的 powers 陣列嗎?

接下來要使用 ngFor 建立一個下搭式選單。

1
2
3
4
5
6
<div class="form-group">
<label for="power">能力</label>
<select name="power" id="power" class="form-control">
<option [value]="item" *ngFor="let item of powers">{{item}}</option>
</select>
</div>

使用 ngModel 進行雙向資料繫結

基礎的表單已經成形,接著我們要將資料雙向繫結到 Input 上。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
<div class="container">
<h1>Hero Form</h1>
<form #heroForm="ngForm">
<div class="form-group">
<label for="name">姓名</label>
<input id="name" [(ngModel)]="model.name"
class="form-control" name="name" required>
目前的資料狀態[姓名]:{{model.name}}
</div>
<div class="form-group">
<label for="alterEgo">裏人格</label>
<input type="text" class="form-control"
[(ngModel)]="model.alterEgo" name="alterEgo" id="alterEgo">
目前的資料狀態[裏人格]:{{model.alterEgo}}
</div>
<div class="form-group">
<label for="power">能力</label>
<select name="power" id="power" class="form-control" [(ngModel)]="model.power">
<option [value]="item" *ngFor="let item of powers">{{item}}</option>
</select>
目前的資料狀態[能力]:{{model.power}}
</div>
<button type="submit" class="btn btn-success">提交</button>
</form>
</div>

做到這邊的時候,遇到一個小小的阻礙:

  • 當使用 ngModel 時,要記得替 input 補上 name 屬性

NgForm 指令

往 form 標籤中加入 #heroForm="ngForm"

heroForm 變數是一個到 NgForm 指令的參考,它代表該表單的整體。

NgForm 指令為 form 增補了一些額外特性:

  • 它會控制那些帶有 ngModel 指令和 name 屬性的元素,監聽他們的屬性(包括其有效性)。
  • 它還有自己的 valid 屬性,這個屬性只有在它包含的每個控制元件都有效時才是真。

透過 ngModel 追蹤修改狀態與有效性驗證

現在我們已經可以透過雙向繫結修改資料了,但是還可以透過 ngModel 知道更多資訊:

  • 使用者碰過此控制元件嗎?
  • 值變化了嗎?
  • 資料變得無效了嗎?

ngModel 指令不僅僅追蹤狀態。它還使用特定的 Angular CSS 類來更新控制元件,以反映當前狀態。 可以利用這些 CSS 類來修改控制元件的外觀,顯示或隱藏訊息。

在姓名的 input 標籤上新增名叫 spy 的臨時範本參考變數,然後用這個 spy 來顯示它上面的所有 className。

1
2
3
4
5
6
7
<div class="form-group">
<label for="name">姓名</label>
<input id="name" [(ngModel)]="model.name"
class="form-control" name="name" #spy required>
目前的資料狀態[姓名]:{{model.name}} <br>
input 上的 className 狀態 {{spy.className}}
</div>

尚未觸碰過

focus 後 blur

修改過資料後

必填欄位但資料完全刪除時

新增用於視覺反饋的自訂 CSS

既然可以追蹤 input 上的 className 狀態,我們就可以自訂一些視覺反饋效果。

hero-form.component.scss

1
2
3
4
5
6
7
.ng-valid[required], .ng-valid.required  {
border-left: 5px solid #42A948; /* green */
}

.ng-invalid:not(form) {
border-left: 5px solid #a94442; /* red */
}

驗證失敗

驗證成功

甚至可以透過控制 hidden 屬性,自訂一些錯誤訊息。

1
2
3
4
5
6
7
<div class="form-group">
<label for="name">姓名</label>
<input id="name" [(ngModel)]="model.name"
class="form-control" name="name" #name="ngModel" required>
<div [hidden]="name.valid || name.pristine" class="alert alert-danger">姓名是必填欄位!</div>
目前的資料狀態[姓名]:{{model.name}}
</div>

範本參考變數可以訪問 Template 中的 input ,建立 name 的變數並且賦值為 ngModel

當這個 input 的驗證是有效的 (valid) 或全新的 (pristine) 時,隱藏訊息。

全新的 (pristine) 意味著從它顯示在表單中開始,使用者還從未修改過它的值。

這樣就完成了視覺反饋效果。

裏人格因為是非必填,所以不需要做處理;下拉式選單則因為設計的關係一定會有值,所以也不需要處理。

新增英雄

在表單的底部放置新增英雄的按鈕,並把它的點選事件繫結到元件上的 newHero 方法。
hero-form.component.html

1
<button type="button" class="btn btn-primary" (click)="newHero()">新增英雄</button>

hero-form.component.ts

1
2
3
newHero() {
this.model = new Hero(2, '', '');
}

觀察看看結果~

尚未點擊新增英雄

點擊新增英雄後

使用瀏覽器工具審查這個元素就會發現,這個 name 輸入框並不是全新的。

所以跑出了錯誤提示訊息,但這樣不正確,因為我們並不希望按下新增英雄時跑出錯誤視窗。

發生預期之外的原因是:

  • 表單會記得點選新增英雄前輸入的名字,而更換了英雄物件並不會重置 input 的 全新(pristine) 狀態。

所以必須在 newHero() 後補上 heroForm.reset() 重置表單狀態。

因此目前 Template 內的程式碼是這樣的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
<div class="container">
<h1>Hero Form</h1>
<form #heroForm="ngForm">
<div class="form-group">
<label for="name">姓名</label>
<input id="name" [(ngModel)]="model.name"
class="form-control" name="name" #name="ngModel" required>
<div [hidden]="name.valid || name.pristine" class="alert alert-danger">姓名是必填欄位!</div>
目前的資料狀態[姓名]:{{model.name}}
</div>
<div class="form-group">
<label for="alterEgo">裏人格</label>
<input type="text" class="form-control"
[(ngModel)]="model.alterEgo" name="alterEgo" id="alterEgo">
目前的資料狀態[裏人格]:{{model.alterEgo}}
</div>
<div class="form-group">
<label for="power">能力</label>
<select name="power" id="power" class="form-control" [(ngModel)]="model.power">
<option [value]="item" *ngFor="let item of powers">{{item}}</option>
</select>
目前的資料狀態[能力]:{{model.power}}
</div>
<button type="button" class="btn btn-primary" (click)="newHero();heroForm.reset()">新增英雄</button>
<button type="submit" class="btn btn-success">提交</button>
</form>
</div>

使用 ngSubmit 提交該表單

填表完成之後,使用者應該要能提交這個表單。

目前這個表單的提交按鈕位於底部,並沒有在這顆按鈕上綁定任何的點擊事件,但因為有特殊的 type 值 (type=”submit”),所以會觸發表單提交。

但現在這樣僅僅觸發表單提交是沒用的。

要讓它有用,就要把該表單的 ngSubmit 事件屬性繫結到英雄表單元件的 onSubmit() 方法上:

1
<form (ngSubmit)="onSubmit()" #heroForm="ngForm">

這樣就可以在按下提交鈕後觸發寫在 class 內的 onSubmit() 方法了。

提升使用者體驗

可以進一步的把表單的總體有效性透過 heroForm 變數繫結到此按鈕的 disabled 屬性,這樣能讓使用者明白如果沒有填寫姓名是不能提交的。

1
<button type="submit" [disabled]="!heroForm.form.valid" class="btn btn-success">提交</button>

未填寫姓名,不得提交

切換兩個表單區域

在我們按下提交後,可以將表單利用先前設置好的屬性 submitted 來控制隱藏或顯示。

建立提交後的顯示區塊,並且利用 submitted 控制隱藏或顯示

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
<div class="container">
<h1>Hero Form</h1>
<form (ngSubmit)="onSubmit()" #heroForm="ngForm" [hidden]="submitted">
<div class="form-group">
<label for="name">姓名</label>
<input id="name" [(ngModel)]="model.name"
class="form-control" name="name" #name="ngModel" required>
<div [hidden]="name.valid || name.pristine" class="alert alert-danger">姓名是必填欄位!</div>
目前的資料狀態[姓名]:{{model.name}}
</div>
<div class="form-group">
<label for="alterEgo">裏人格</label>
<input type="text" class="form-control"
[(ngModel)]="model.alterEgo" name="alterEgo" id="alterEgo">
目前的資料狀態[裏人格]:{{model.alterEgo}}
</div>
<div class="form-group">
<label for="power">能力</label>
<select name="power" id="power" class="form-control" [(ngModel)]="model.power">
<option [value]="item" *ngFor="let item of powers">{{item}}</option>
</select>
目前的資料狀態[能力]:{{model.power}}
</div>
<button type="button" class="btn btn-primary" (click)="newHero();heroForm.reset()">新增英雄</button>
<button type="submit" [disabled]="!heroForm.form.valid" class="btn btn-success">提交</button>
</form>

<div [hidden]="!submitted">
<h2>英雄能力如下:</h2>
<div class="row">
<div class="col-md-3">姓名</div>
<div class="col-md-9">{{ model.name }}</div>
</div>
<div class="row">
<div class="col-md-3">裏人格</div>
<div class="col-md-9">{{ model.alterEgo }}</div>
</div>
<div class="row">
<div class="col-md-3">能力</div>
<div class="col-md-9">{{ model.power }}</div>
</div>
<br>
<button class="btn btn-primary" (click)="submitted=false">編輯</button>
</div>
</div>

一開始屬性 submittedfalse ,顯示輸入表單

  • 當按下提交後,觸發 onSubmit() 將 submitted 修改為 true
    • 輸入表單關閉,顯示提交後的區塊
  • 當按下編輯按鈕時,再度將 submitted 修改為 false,隱藏提交後的區塊並顯示輸入表單。

提交前

按下提交後

如此我們就完成了範本驅動表單 (Template-Driven Forms) 的簡單範例。

0%