iT邦幫忙

2021 iThome 鐵人賽

DAY 3
0
Modern Web

Angular 深入淺出三十天:表單與測試系列 第 3

Angular 深入淺出三十天:表單與測試 Day03 - Reactive Forms 實作 - 以登入為例

  • 分享至 

  • xImage
  •  

Day3

今天要來用 Reactive Forms 的方式實作一個簡單的登入系統,撇開 UI 不談,具體的功能需求規格跟昨天差不多,如下所示:

  • 帳號
    • 格式為 Email Address,相關規則請參考維基百科,此處則直接使用正規表示法 /^\b[\w\.-]+@[\w\.-]+\.\w{2,4}\b$/gi 來檢驗,驗證有誤時需在欄位後方顯示錯誤訊息:格式有誤,請重新輸入
    • 此欄位必填,驗證有誤時需需在欄位後方顯示錯誤訊息:此欄位必填
  • 密碼
    • 長度最短不得低於 8 碼,驗證有誤時需需在欄位後方顯示錯誤訊息:密碼長度最短不得低於8碼
    • 長度最長不得超過 16碼,驗證有誤時需需在欄位後方顯示錯誤訊息:密碼長度最長不得超過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>

未經美化的畫面應該會長這樣:

Template view

接著到 app.module.ts 裡 import FormsModuleReactiveFormsModule

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 的概念是將表單程式的方式產生。以這個需求來說,這個表單底下會有兩個欄位 accountpassword ,如果將其用 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 的類型即可

關於錯誤訊息基本上可以沿用上一篇的程式,只不過原本是傳入 FormControlerrors 來判斷,但現在是傳入整個 FormControl ,為什麼呢?

因為如果只有傳入 FormControlerrors 的話,你會發現表單初始化完之後,就會有錯誤訊息顯示在畫面上:

img

這是因為當我們的表單初始化完之後,驗證器就會開始運作,所以的確那個兩個欄位是有那個錯誤沒錯,但其實這不是我們想要的行為,因為使用者根本就還沒有開始填表單,我們想要的是當使用者開始填表單之後,才會顯示對應的錯誤訊息,所以我們改傳入整個 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 = '';
  }
  // 其他省略...
}

最終結果:

complete gif

本日小結

對於第一次接觸 Reactive Forms 的朋友們,今天的資訊量會比較多,但重點大致上可歸納成以下四點:

  1. 學習如何將表單程式的方式寫出來,心法:「資料即表單,表單即資料」
  2. 學習如何使用表單物件 FormBuilderFormGroupFormControl
  3. 學習如何使用 Validators 來驗證使用者所輸入的值。
  4. 學習如何將表單物件與 Template 綁定。

此外,千萬記得要 import FormsModuleReactiveFormsModule 才可以使用噢!

我一樣會將今日的實作程式碼放在 Stackblitz 上供大家參考,建議大家在看我的實作之前,先按照需求規格自己做一遍,之後再跟我的對照,看看自己的實作跟我的實作不同的地方在哪裡、有什麼好處與壞處,如此反覆咀嚼消化後,我相信你一定可以進步地非常快!

如果有任何的問題或是回饋,也都非常歡迎留言給我讓我知道噢!


上一篇
Angular 深入淺出三十天:表單與測試 Day02 - Template Driven Forms 實作 - 以登入為例
下一篇
Angular 深入淺出三十天:表單與測試 Day04 - 開始撰寫測試之前必須要知道的二三事
系列文
Angular 深入淺出三十天:表單與測試30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

2 則留言

1
TD
iT邦新手 4 級 ‧ 2021-09-18 10:51:37

在 template 裡面放 function,這個 function 是不是會一直被呼叫呢?

<span class="error-message">{{ getErrorMessage(accountControl) }}</span>
看更多先前的回應...收起先前的回應...
Leo iT邦新手 3 級 ‧ 2021-09-18 10:54:59 檢舉

Hi TD

也不是一直,嚴格來說是渲染畫面時,詳細可以參考以下連結:https://medium.com/javarevisited/why-you-should-not-use-functions-in-angular-html-f445371a4b6b

就像連結裡建議的,此處最好的作法是做個 pipe 來處理

TD iT邦新手 4 級 ‧ 2021-09-18 11:01:44 檢舉

你說的沒錯,剛剛實驗了一下,的確不是一直呼叫,是在渲染的時候才呼叫。

不過好奇的是,為什麼我輸入一個字的時候,他會被呼叫四次?我以為只會有兩次 XD
https://ithelp.ithome.com.tw/upload/images/20210918/201160037CS2yDOneR.png

Leo iT邦新手 3 級 ‧ 2021-09-18 14:41:20 檢舉

Hi TD,

你如果現在不是很厲害的工程師,未來一定會是!

有這種實驗精神的你絕對不會平凡的!

這是因為 Angular 在 debug mode 下會呼叫兩次,如果是 production mode 就只會呼叫一次。

你可以使用 ng serve --prod 的方式來實驗

TD iT邦新手 4 級 ‧ 2021-09-18 23:52:59 檢舉

我發現一件神奇的事情,雖然說是跑 production mode,但實際上好像還是會根據 configurations 裡面的設定來決定最後的實現狀況

譬如同樣執行 ng serve --prod 的情況下,如果 configurations 如果沒有 optimization: true ,那麼就會被認為不是跑在 production mode

https://ithelp.ithome.com.tw/upload/images/20210918/201160031CFX7VtxaT.png

反之如果 optimization: true 那麼就會被認為是在 production mode

https://ithelp.ithome.com.tw/upload/images/20210918/20116003RsG3ry9Iva.png

本來想查 optimization: true 到底做了什麼事情,導致不會出現重複的 functions calls,可惜到目前為止無功而返


對了,--prod flag 在 Angular 最新版本就 deprecated,不過範例程式碼都在 v11 所以沒有問題!

Leo iT邦新手 3 級 ‧ 2021-09-19 08:12:39 檢舉

Hi TD

感謝你的 feedback! 你真的很棒呢!

TD iT邦新手 4 級 ‧ 2021-09-19 08:51:19 檢舉

<(_ _)> 沒有沒有,有機會就多多交流,不懂的東西真的太多了

Leo iT邦新手 3 級 ‧ 2021-09-19 09:33:04 檢舉

能跟優秀的工程師交流是我的榮幸呢:)

0
stealing610
iT邦新手 4 級 ‧ 2021-10-09 17:49:11

Hi, Leo 大大, 文章中的以下程式碼內容,好像要改一下傳入的內容

https://ithelp.ithome.com.tw/upload/images/20211009/20140093lNf8vMtaIc.png

Leo iT邦新手 3 級 ‧ 2021-10-09 18:38:47 檢舉

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 嗎?

再麻煩您解惑,謝謝~~

Leo iT邦新手 3 級 ‧ 2021-10-10 09:47:49 檢舉

Hi stealing,

是的沒錯,正是你理解的那樣噢!

/images/emoticon/emoticon12.gif

我要留言

立即登入留言