今天要來用 Reactive Forms 的方式實作一個簡單的登入系統,撇開 UI 不談,具體的功能需求規格跟昨天差不多,如下所示:
/^\b[\w\.-]+@[\w\.-]+\.\w{2,4}\b$/gi
來檢驗,驗證有誤時需在欄位後方顯示錯誤訊息:格式有誤,請重新輸入
此欄位必填
密碼長度最短不得低於8碼
密碼長度最長不得超過16碼
此欄位必填
規格需求看清楚之後,我們就來開始實作吧!
實作時大家可以自己開一個專案來練習,抑或是用 Stackblitz 開一個 Angular 的專案來練習,我就不再贅述囉!
如果正在閱讀此篇文章的你還不知道要怎麼開始一個 Angular 專案的話,請先閱讀我的 Angular 深入淺出三十天後再來閱讀此系列文章會比較恰當噢!
首先我們先準備好基本的 HTML :
<form>
<p>
<label for="account">帳號:</label>
<input type="email" id="account">
</p>
<p>
<label for="password">密碼:</label>
<input type="password" id="password">
</p>
<p>
<button type="submit">登入</button>
</p>
</form>
未經美化的畫面應該會長這樣:
接著到 app.module.ts
裡 import FormsModule
與 ReactiveFormsModule
:
import { NgModule } from '@angular/core';
import { BrowserModule } from '@angular/platform-browser';
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
import { AppComponent } from './app.component';
@NgModule({
imports: [
BrowserModule,
FormsModule,
ReactiveFormsModule
],
declarations: [AppComponent],
bootstrap: [AppComponent]
})
export class AppModule { }
然後將要綁在 Template 的屬性跟方法都準備好:
export class LoginComponent implements OnInit {
// 綁定在表單上
formGroup: FormGroup;
/**
* 用以取得帳號欄位的表單控制項
*/
get accountControl(): FormControl {
return this.formGroup.get('account') as FormControl;
}
/**
* 用以取得密碼欄位的表單控制項
*/
get passwordControl(): FormControl {
return this.formGroup.get('password') as FormControl;
}
/**
* 透過 DI 取得 FromBuilder 物件,用以建立表單
*/
constructor(private formBuilder: FormBuilder) {}
/**
* 當 Component 初始化的時候初始化表單
*/
ngOnInit(): void {
this.formGroup = this.formBuilder.group({
account: [
'',
[
Validators.required,
Validators.pattern(/^\b[\w\.-]+@[\w\.-]+\.\w{2,4}\b$/gi)
]
],
password: [
'',
[
Validators.required,
Validators.minLength(8),
Validators.maxLength(16)
]
]
});
}
// 綁定在表單上,當使用者按下登入按鈕時會觸發此函式
login(): void {
// do login...
}
/**
* 透過該欄位的表單控制項來取得該欄位的錯誤訊息
*
* @param {FormControl} formControl 欲取得錯誤訊息的欄位的表單控制項 (by Angular)
*/
getErrorMessage(formControl: FormControl): string {
let errorMessage: string;
if (!formControl.errors || formControl.pristine) {
errorMessage = '';
} else if (formControl.errors.required) {
errorMessage = '此欄位必填';
} else if (formControl.errors.pattern) {
errorMessage = '格式有誤,請重新輸入';
} else if (formControl.errors.minlength) {
errorMessage = '密碼長度最短不得低於8碼';
} else if (formControl.errors.maxlength) {
errorMessage = '密碼長度最長不得超過16碼';
}
return errorMessage;
}
}
就可以將這些屬性和方法跟 Template 綁定在一起:
<form [formGroup]="formGroup" (ngSubmit)="login()">
<p>
<label for="account">帳號:</label>
<input
type="email"
id="account"
[formControl]="accountControl"
/>
<span class="error-message">{{ getErrorMessage(accountControl) }}</span>
</p>
<p>
<label for="password">密碼:</label>
<input
type="password"
id="password"
[formControl]="passwordControl"
/>
<span class="error-message">{{ getErrorMessage(passwordControl) }}</span>
</p>
<p>
<button type="submit" [disabled]="formGroup.invalid">登入</button>
</p>
</form>
到目前為止的程式碼你看懂了多少呢?對於剛接觸 Angular 的表單的朋友來說,今天的資訊量可能會比較大,容我稍微說明一下:
Reactive Forms 的概念是將表單用程式的方式產生。以這個需求來說,這個表單底下會有兩個欄位 account
與 password
,如果將其用 JSON
來表示的話,應該會長這樣:
{
"account": "",
"password": ""
}
從資料面來看, {}
代表表單, "account": ""
與 "password": ""
則是裡面的兩個欄位。
而再將其轉換成 Reactive Forms 的概念的話, {}
代表的是 FormGroup
,"account": ""
與 "password": ""
則代表的是 FormControl
。
所以在程式碼中我們可以看到我們宣告 formGroup: FromGroup;
並且在 template 中將其綁定在表單上:
<form [formGroup]="formGroup">
<!-- ... -->
</form>
並且把表單控制項綁定在對應的 input 欄位上:
<!-- 帳號欄位 -->
<input
type="email"
id="account"
[formControl]="accountControl"
/>
<!-- 密碼欄位 -->
<input
type="password"
id="password"
[formControl]="passwordControl"
/>
然後在 ngOnInit
裡透過 FormBuilder
來初始化表單:
ngOnInit(): void {
this.formGroup = this.formBuilder.group({
account: '我是該欄位的初始值',
password: '我是該欄位的初始值'
});
}
如此一來,就可以在初始化過後,跟我們的 template 正確綁定了。
而如果當該欄位需要驗證時,就要在初始化時將格式調整成:
ngOnInit(): void {
this.formGroup = this.formBuilder.group({
account: ['我是該欄位的初始值', /* 驗證器的擺放位置 */],
password: ['我是該欄位的初始值', /* 驗證器的擺放位置 */],
});
}
如果只有一個要驗證的項目則可以直接放入:
ngOnInit(): void {
this.formGroup = this.formBuilder.group({
account: ['我是該欄位的初始值', Validators.required],
password: ['我是該欄位的初始值', Validators.required],
});
}
如果有多個要驗證的項目,就用 []
將多個驗證項包起來再放入:
ngOnInit(): void {
this.formGroup = this.formBuilder.group({
account: [
'我是該欄位的初始值',
[
Validators.required,
Validators.pattern(/^\b[\w\.-]+@[\w\.-]+\.\w{2,4}\b$/gi)
]
],
password: [
'我是該欄位的初始值',
[
Validators.required,
Validators.minLength(8),
Validators.maxLength(16)
]
],
});
}
在這裡我們可以發現,上一篇使用 Template Driven Forms 實作時,是用 HTML 原生的屬性來驗證,而今天使用 Reactive Forms 實作時,則是用程式來驗證,如此一來,可以降低表單與 template 之間的依賴性,使得其更易於維護、重用與測試。
Validators 是 Angular 幫我們製作的驗證器,裡面有很多常用驗證器,詳細請參考官方文件
當然我們也可以自己客製驗證器,只要符合 ValidatorFn 的類型即可
關於錯誤訊息基本上可以沿用上一篇的程式,只不過原本是傳入 FormControl
的 errors
來判斷,但現在是傳入整個 FormControl
,為什麼呢?
因為如果只有傳入 FormControl
的 errors
的話,你會發現表單初始化完之後,就會有錯誤訊息顯示在畫面上:
這是因為當我們的表單初始化完之後,驗證器就會開始運作,所以的確那個兩個欄位是有那個錯誤沒錯,但其實這不是我們想要的行為,因為使用者根本就還沒有開始填表單,我們想要的是當使用者開始填表單之後,才會顯示對應的錯誤訊息,所以我們改傳入整個 FormControl
,它其中有幾個很好用的屬性可以使用:
pristine
─ 如果此屬性為 true
,代表該欄位是乾淨,沒有被輸入過值;反之則代表有被輸入過值,與 dirty
成反比。touched
─ 如果此屬性為 true
,代表該欄位曾經被碰(該欄位曾經被使用滑鼠 focus 過);反之則代表該欄位完全沒被碰過。dirty
─ 如果此屬性為 true
,代表該欄位曾經被輸入過值,已經髒掉了;反之則代表該欄位是乾淨,沒有被輸入過值,與 pristine
成反比。想知道更多可以參考官方文件: FormControl 與其抽象類別 AbstractControl
所以我們只要加上當該欄位是乾淨的,就不回傳錯誤訊息的判斷就可以了,像是這樣:
getErrorMessage(formControl: FormControl): string {
let errorMessage: string;
if (!formControl.errors || formControl.pristine) {
errorMessage = '';
}
// 其他省略...
}
最終結果:
對於第一次接觸 Reactive Forms 的朋友們,今天的資訊量會比較多,但重點大致上可歸納成以下四點:
此外,千萬記得要 import FormsModule
與 ReactiveFormsModule
才可以使用噢!
我一樣會將今日的實作程式碼放在 Stackblitz 上供大家參考,建議大家在看我的實作之前,先按照需求規格自己做一遍,之後再跟我的對照,看看自己的實作跟我的實作不同的地方在哪裡、有什麼好處與壞處,如此反覆咀嚼消化後,我相信你一定可以進步地非常快!
如果有任何的問題或是回饋,也都非常歡迎留言給我讓我知道噢!
在 template 裡面放 function,這個 function 是不是會一直被呼叫呢?
<span class="error-message">{{ getErrorMessage(accountControl) }}</span>
Hi TD
也不是一直,嚴格來說是渲染畫面時,詳細可以參考以下連結:https://medium.com/javarevisited/why-you-should-not-use-functions-in-angular-html-f445371a4b6b
就像連結裡建議的,此處最好的作法是做個 pipe
來處理
你說的沒錯,剛剛實驗了一下,的確不是一直呼叫,是在渲染的時候才呼叫。
不過好奇的是,為什麼我輸入一個字的時候,他會被呼叫四次?我以為只會有兩次 XD
Hi TD,
你如果現在不是很厲害的工程師,未來一定會是!
有這種實驗精神的你絕對不會平凡的!
這是因為 Angular 在 debug mode 下會呼叫兩次,如果是 production mode 就只會呼叫一次。
你可以使用
ng serve --prod
的方式來實驗
我發現一件神奇的事情,雖然說是跑 production mode,但實際上好像還是會根據 configurations 裡面的設定來決定最後的實現狀況
譬如同樣執行 ng serve --prod
的情況下,如果 configurations 如果沒有 optimization: true
,那麼就會被認為不是跑在 production mode
反之如果 optimization: true
那麼就會被認為是在 production mode
本來想查 optimization: true
到底做了什麼事情,導致不會出現重複的 functions calls,可惜到目前為止無功而返
對了,--prod
flag 在 Angular 最新版本就 deprecated,不過範例程式碼都在 v11 所以沒有問題!
Hi, Leo 大大, 文章中的以下程式碼內容,好像要改一下傳入的內容
Hi stealing610,
沒錯!!感謝勘誤!!
Leo 大大,
想請教一個本文中轉型 (type assertion) 的問題,
get accountControl(): FormControl {
return this.formGroup.get('account') as FormControl
}
透過 formGroup 的 get
方法,取回的資料型別為 AbstractControl,那它可以透過 typescript 的 as
轉型成 FormControl 型別的原因是因為 AbstractControl 是 FormControl 的父類別的關係嗎?
因為,我在這一篇文章 的以下內容讀到,可以使用 as
來達到轉型的原因為
Basically, the assertion from type S to T succeeds if either S is a subtype of T or T is a subtype of S.
我可以理解為因為 FormControl 是 AbstractControl 的 subtype ,所以,我們可以透過 as
來將 AbstractControl 轉型成 FormControl 嗎?
再麻煩您解惑,謝謝~~
Hi stealing,
是的沒錯,正是你理解的那樣噢!