Angular 17+ 高級教程 – Reactive Forms

前言

上一篇的 Ajax 和這一篇的表單 (Form) 都是前端最最最常見的需求。

爲此,Angular 分別提供了兩個小型庫來幫助開發者實現這些需求:

  1. Ajax – HttpClient
  2. Form – Reactive Forms (注:還有一個庫叫 Template-driven Forms,它能做的 Reactive Forms 都能做到,所以我只教 Reactive Forms 而已)

雖然都是針對特定需求的庫,但是 HttpClient 和 Reactive Forms 的難度不可相提並論。

HttpClient 只是基於 BOM XMLHttpRequest 和 Fetch 的上層封裝,它屬於 1 - n 的實現。

Reactive Forms 則完全沒有利用遊覽器 Native Form 的功能 (因爲體驗太爛了),它屬於從 0 - 1 的實現。

雖然 Reactive Forms 沒有使用 Native Form 的功能,但 Angular Team 依然參考了 Native Form 的設計 (接口調用,管理等等)。

所以,我們要想掌握好 Reactive Forms,最好先掌握遊覽器 Native Form,對 Native Form 不熟悉的朋友,請看這篇 HTML – Native Form 原生表單功能集

 

Native Form 的強大於弱小

讓我們通過一個例子來感受一下表單需求,以及使用 Native Form 與不使用 Native Form 去實現相同需求的難度。

下圖是一個簡單到爆的表單

Get formValue

第一個需求是:當用戶 submit 的時候,獲取表單裏 3 個 input 的 value,然後放入一個 formValue 對象中。

效果

without Native Form

首先給 input 添加一個 name 屬性,它用作於 input 的 unique 識別。

<form autocomplete="off">
  <input name="name" placeholder="Name">
  <input name="email" placeholder="Email">
  <input name="phone" placeholder="Phone">
  <button>submit</button>
</form>

然後是 JavaScript

// 1. select all related elements
const form = document.querySelector('form')!;
const nameInput = form.querySelector<HTMLInputElement>('[name="name"]')!; // 2. select by attribute name
const emailInput = form.querySelector<HTMLInputElement>('[name="email"]')!;
const phoneInput = form.querySelector<HTMLInputElement>('[name="phone"]')!;

// 3. listen to form submit
form.addEventListener('submit', e => {
  e.preventDefault(); // 4. 阻止遊覽器發請求到服務端

  // 5. get each input value put into formValue object
  console.log('formValue', {
    name: nameInput.value,
    email: emailInput.value,
    phone: phoneInput.value,
  });
});

with Native Form

如果我們利用 Native Form 的功能,代碼可以優化成這樣

// 1. select form element
const form = document.querySelector('form')!;

// 2. listen to form submit
form.addEventListener('submit', e => {
  e.preventDefault(); // 3. 阻止遊覽器發請求到服務端

  // 4. use FormData to collect all input value
  const formData = new FormData(form);

  const formValue: Record<string, unknown> = {};

  // 5. for loop FormData key and put into formValue object
  for (const [name, value] of formData.entries()) {
    formValue[name] = value;
  }

  console.log('formValue', formValue);
});

FormData 可以直接從 form 收集帶有 name attribute 的 accessor (比如 input, textarea, select 等等),然後把 name 和 value 收集起來,省去了我們自己 query。

這樣就搞定了!好,下一個需求。

Required & Email Format Validation

第二需求是:所有 input 必須要填,email 格式也要正確,不然就 submit 不到,報錯提醒用戶。

效果

without Native Form

在用戶 submit 後,我們需要驗證 input value,如果發現 value 不合格,那就要中斷 submit,然後顯示 error message (這個實現方式有很多,比如 add class, append element 都可以)

另外,這個 error message 什麼時候消失也需要考慮到,比如當用戶修改 input 時就 clear message,或者 3 秒後 clear message。這些都取決於用戶體驗要做到什麼層度。

with Native Form

好,我們再來看看使用 Native Form 如何實現這個需求。

在 input 添加 required attribute,在 email input 添加 type="email" attribute。

搞定!JavaScript 不需要增加任何相關代碼。這就是 Native Form 的強大之處!好,下一個需求。

Input Disabled

第三個需要是:如果 input 被 disable 了,最終的 formValue 不要包含這個 key。

without Native Form

首先在特定的 input 添加 disabled attribute 作爲識別

<input name="phone" placeholder="Phone" required disabled>

接着在 submit 後,跳過 disabled input 的 validation (不需要檢查它的 value,因爲它最終不需要出現在 formValue),

在 collect formValue 時跳過 disabled input。JS 代碼我就不寫了,大家自己腦部。

with Native Form

我們只要在 input 添加 disabled attribute 就可以了,FormData 和 Validation 會自動 skip 掉 disabled 的 input。

這就是 Native Form 的強大之處!好,下一個需求。

Custom Validation

第四個需要是:value 不可以只是空格,required 雖然可以驗證 empty string,但是純空格也不是 empty string,這就導致純可控可以 by pass required validation,這不是我們要的。

without Native Form

實現手法和第二個需求一樣。

with Native Form

當 Native Form 無法完全滿足需求的時候就很糾結了。因爲我們不願意完全放棄 Native Form 改爲使用 without Native Form 方案 (因爲我們將失去很多 Native Form 強大之處),

但是 Native Form 是否有提供擴展接口,擴展的維護成本高不高,這些都是不確定的。

我們每次遇到新需求就只能碰碰運氣,比如

通過 setCustomValidity 和 reportValidity 底層接口,我們可以自定義 input 的 validation。

效果

雖然這個需求是過關了,但下一個呢?誰能保證下一次擴展會順順利利呢?這就是 Native Form 弱小之處。

總結

Native Form 的目標是讓使用者在幾乎不需要編寫 CSS 和 JS 代碼的情況下,實現出還不錯的表單體驗。

這個目標顯然不是 Angular 期望達到的,所有 Angular 沒有選擇基於 Native Form,而是從起爐竈做出了 Reative Forms。

儘管如此,由於表單需求千差萬別,Reactive Forms 有時也會遇到無法滿足和難以擴展的情況。

 

Reactive Forms Quick View

我們先來感受一下 Reactive Forms 大體長什麼樣,大家不需要太在意代碼,感受一下它的管理方式就好,下一 part 我會再一個一個細節講解的。

同樣是上一個例子,一樣的 4 個需求,我們用 Reactive Forms 實現一遍。

create Angular project

ng new reactive-forms --ssr=false --routing=false --skip-tests --style=scss

Get formValue

第一個需求是:當用戶 submit 的時候,獲取表單裏 3 個 input 的 value,然後放入一個 formValue 對象中。

App 組件

@Component({
  selector: 'app-root',
  standalone: true,
  // 1. import Reactive Forms Module,我們需要用到一些指令
  imports: [ReactiveFormsModule],
  templateUrl: './app.component.html',
  styleUrl: './app.component.scss',
})
export class AppComponent {
  // 2. 創建一個 FormGroup,它有點像是 Native Form 的 FormData
  form = new FormGroup({
    name: new FormControl(''),
    email: new FormControl(''),
    phone: new FormControl(''),
  });

  submit() {
    // 3. submit 的時候 console formValue
    console.log('formValue', this.form.value);
  }
}

App Template

<form [formGroup]="form" (ngSubmit)="submit()" autocomplete="off" >
  <input formControlName="name" placeholder="Name">
  <input formControlName="email" placeholder="Email" type="email">
  <input formControlName="phone" placeholder="Phone">
  <button>submit</button>
</form>

[formGroup], [formControlName] 是指令,(ngSubmit) 是 [formGroup] 指令的 @Output。

幾個知識點:

  1. Reactive Forms 的指令都是用 NgModule 管理的。它們都不是 Standalone Component。
    當我們 import ReactiveFormsModule 後,有一些看不見的指令其實偷偷在工作。

    除非有在 <form> element 上添加 ngNoForm 或者 ngNativeValidate attribute,否則 <form> 會自動加上 novalidate attribute,
    意思是關閉 Native Form Validation。
  2. FormControl 是一個綜合管理器,下面我們會看到它可以操控非常多的東西,這裏我們先把它當成一個簡單的 value 管理器就好。
    name, email, phone 有 3 個 value,那就有 3 個 FormControl 咯。
    這 3 個 FormControl 被一個 FormGroup 包裹着,每一個 FormControl 都有專屬名字。
  3. [formGroup] 指令把 <form> element 和 FormGroup 對象關聯起來。
    [formControlName] 指令把 accessor (例子中是 <input> element) 和 FormControl 對象關聯起來。(注:accessor 是讀寫器,意指所有可以讀寫 value 的 element,比如:input, textarea, select 等等)
  4. (ngSubmit) 是 [formGroup] 指令的 @Output,它會在 <form> element submit 時發佈,此時我們通過 FormGroup.value 就可以拿到整個 form 的 value 了。
    FormGroup 實現了 Native FormData 獲取 formValue 的功能。

這樣就搞定了!好,下一個需求。

Required & Email Format Validation

第二需求是:所有 input 必須要填,email 格式也要正確,不然就 submit 不到,報錯提醒用戶。

預期效果

App 組件

@Component({
  selector: 'app-root',
  standalone: true,
  imports: [ReactiveFormsModule],
  templateUrl: './app.component.html',
  styleUrl: './app.component.scss',
})
export class AppComponent {
  form = new FormGroup({
    name: new FormControl('', {
      // 1. 在 FormControl 添加 Validation 邏輯
      validators: [Validators.required],
    }),
    email: new FormControl('', {
      // 2. required and email format
      validators: [Validators.required, Validators.email],
    }),
    phone: new FormControl('', {
      validators: [Validators.required],
    }),
  });

  submit() {
    // 3. skip when form invalid
    if (this.form.invalid) return;
    console.log('formValue', this.form.value);
  }
}

幾個知識點:

  1. Reactive Forms 的 Validation 是通過 FormControl 來管理的 (上一 part 我說了,FormControl 是綜合管理器,它負責很多事情的)。
  2. Validators.required, Validators.email 是 Angular built-in 的 Validation。Angular 有很多 built-in 的 Validation 基本上概括了所有 Native Form 的 Validation。
  3. 和 Native Form 不同,不管 Form 是否 valid (form 內所有 accessor value 都 pass validation),submit 是一定會觸發的。
    Native Form 如果 form invalid 它是不會 submit 的。
    Reactive Forms 則一定會 submit,所以我們需要在 handle submit 時做一個 FormGroup.invalid/valid 檢查。

App Template

<form [formGroup]="form" #ngForm="ngForm" (ngSubmit)="submit()" autocomplete="off" >
  <input formControlName="name" placeholder="Name">
  @if (form.get('name')!.hasError('required') && ngForm.submitted) {
    <p>Name is Required</p>
  }
  <input formControlName="email" placeholder="Email">
  @if (form.get('email')!.hasError('required') && ngForm.submitted) {
    <p>Email is Required</p>
  }
  @if (form.get('email')!.hasError('email') && ngForm.submitted) {
    <p>Incorrect Email Format</p>
  }
  <input formControlName="phone" placeholder="Phone">
  @if (form.get('phone')!.hasError('required') && ngForm.submitted) {
    <p>Phone is Required</p>
  }
  <button>submit</button>
</form>

多了很多 @if,這是因爲要顯示 error message。

Reactive Forms 不像 Native Form 有自帶的 popup error message UI 體驗,Reactive Forms 完全沒有任何 UI 體驗,這是 Angular 刻意畫的線 -- 不碰 UI。

雖然 Angular 沒有 built-in UI 但是我們可以搭配 Angular Material UI 組件庫 來實現 UI 體驗,所以也不是什麼大問題。

本篇不可能爲了 UI 引入 Angular Material,所以我們就順便寫點簡單的意思意思就好。

@if (form.get('name')!.hasError('required') && ngForm.submitted) {
  <p>Name is Required</p>
}

form 是 FromGroup 對象,.get('name') 方法會返回 FormControl。

FormControl 負責管理 Validation,所以通過它可以知道當前是否有 required error,如果 value 是 empty string 那 hasError('required') 就會返回 true。

required error message 只會在 2 種情況下顯示

  1. 有 required error
  2. form 已經 submit 過了 (這是一種 UI 體驗,我只是做個例子)

ngForm.submitted 可以判斷當前 form 是否 submit 過了,

這個 ngForm 是一個 template variables,它來自

<form [formGroup]="form" #ngForm="ngForm" (ngSubmit)="submit()" autocomplete="off" >

FromGroupDirective exportAs 'ngForm',簡單說 ngForm 就是 FormGroup 指令啦。

最終效果

可以看到 Validation 的檢測是發生在 input 事件,而不是 submit 事件。所以 clear error message 會立刻發生。

這樣就搞定了!好,下一個需求。

Input Disabled

第三個需要是:如果 input 被 disable 了,最終的 formValue 不要包含這個 key。

disabled 也是由 FormControl 管理的。

在創建好 FormGroup 對象後,找出指定的 FormControl 執行 disable 方法就可以了。

或者在指定 accessor 加上 [disabled]="true" attrbute

<input formControlName="phone" placeholder="Phone" [disabled]="true">

這個 [disabled] 是 [formControlName] 指令的 @Input,它內部其實也是調用 FormControl.disable 方法。

FormGroup.value 會 skip 掉 disabled 的 FormControl。

小心坑:當 FormGroup 裏面所有的 FormControl 都是 disabled 的時候,FormGroup.value 將會返回所有的 value,就如同全部 FormControl 不是 disabled 一樣。

這樣就搞定了!好,下一個需求。

Custom Validation

第四個需要是:value 不可以只是空格,required 雖然可以驗證 empty string,但是純空格也不是 empty string,這就導致純可控可以 by pass required validation,這不是我們要的。

App 組件,創建一個 Custom Validation

const mustContainNonSpace: ValidatorFn = control => {
  // 1. 如果 value 是 empty string 就 skip validation
  //    因爲 Reactive Form Validation 的機制是所有 Validation 都會檢查
  //    都會 error,而 required 和 mustContainNonSpace 在 empty string 的情況下都會 failed
  //    但 UI 體驗上出一個 error message 會比較合理,所以當 empty string 的時候,
  //    只交給 required 負責就好,mustContainNonSpace 就 skip。
  //    return null 表示 validation passed 也可用於表達 skip validation
  if (control.value === '') return null;

  // 2. 如果 value trim 了是 empty string 那就返回 error code 和 info
  if (control.value.trim() === '') {
    return {
      // 3. key mustContainNonSpace 是 error code
      //    FormControl.hasError('mustContainNonSpace') 就是看這個
      mustContainNonSpace: true,
    };
  }
  // 3. 沒有 error 就 return null 表示 validation passed.
  return null;
};

它是一個函數,類型是 ValidatorFn

參數是 AbstractControl (FormControl 的抽象類),每當 value changes ValidatorFn 就會執行,返回 null 表示 Validation Passed,返回 ValidationErrors 

表示 Validation Failed。key 是 Error Code,用於 FormControl.hasError('Error Code'),value 可以是任何值,可以用它提供更詳細的錯誤資訊。

App 組件

export class AppComponent {
  form = new FormGroup({
    name: new FormControl('', {
      // 1. 把 mustContainNonSpace ValidatorFn 添加進 validators
      validators: [Validators.required, mustContainNonSpace],
    }),
    email: new FormControl(''),
    phone: new FormControl(''),
  });

  submit() {
    if (this.form.valid) {
      console.log('formValue', this.form.value);
    }
  }
} 

App Template

<input formControlName="name" placeholder="Name">
@if (form.get('name')!.hasError('required') && ngForm.submitted) {
  <p>Name is required</p>
}
@if (form.get('name')!.hasError('mustContainNonSpace') && ngForm.submitted) {
  <p>Input must contain non-space characters</p>
}

效果

總結

這一 part 我們很粗略的用 Reative Forms 重新實現了上一 part 表單的 4 個需求。

顯然 Reactive Forms 在設計上 (接口,擴展等等) 是比 Native Form 要好很多的。

唯一比較煩人的地方是 Reactive Forms 對待 UI 體驗的那條界限,它總是儘可能的不碰 UI。然而在現實場景中,UI 體驗與表單緊密相連,正所謂常在河邊走,哪有不溼鞋。

因此,我們常常會看見一些 Reactive Forms 專門爲了提升 UI 體驗而設計的接口,但它又僅僅只是輔助。這總給人一種感覺 -- "你明明就知道 UI 體驗,不然你怎麼會提供這些接口,但你又強調不碰 UI"。

舉個例子:

FormControl 只負責 Validation 不負責 error message,但是 error message 和 Validation 是緊密相關的,於是 FormControl 的 MaxLength ValidatorFn 不得不返回一些有用的錯誤信息,比如 requiredLength 和 actualLength。

 

半場休息

本篇上半部分主要是帶大家看一個整體性,Native Form 和 Reactive Forms 面對表單需求的解決思路。

當我們清楚了 Reactive Forms 的解決思路,再去看它具體的解決方案時就會比較容易理解,也容易舉一反三。

這是一種比較好的學習 Angular 方式。

好,本篇下半部分,會逐個詳細講解 Reactive Forms 的各個功能。走起🚀

 

FormControl

FormControl 是 Reactive Forms 的核心,掌握了它就等於掌握了 Reactive Forms 超過 50% 的知識,我們就從它開始吧。

FormControl 雖然帶有個 Form 字,又 under Reactive Forms,但其實它並不一定要用在表單,它是可以脫離表單使用的。

Reactive Forms 整體是針對表單沒錯,但是它內部是由好幾個特性組成的,這幾個特性是可以用於非表單場景的。

FormControl as Value Controller

上一 part 我們一直提到說 FormControl 是一個綜合管理器,它的職責非常多,功能非常多,它總共有 50 個屬性和方法😱。

爲了循序漸進的掌握它,我們可以把它拆分成 5 個職責來學習。

第一個職責是 Value Controller

顧名思義 FormControl 是 value 管理器,可用於管理 value。

創建 Value Control

const valueCtrl = new FormControl('Hello World');

get value from Value Control

console.log(valueCtrl.value); // 'Hello World'

set value to Value Control

valueCtrl.setValue('New Value'); // set value to 'New Value'
console.log(valueCtrl.value);    // 'New Value'

listen to value changes

valueCtrl.valueChanges.subscribe(() => {
  console.log('value changes'); // will fire after 1 second
});

setTimeout(() => {
  valueCtrl.setValue('New Value');
}, 1000);

總結

get, set, listening 有沒有讓你想起 Signals 或者 RxJS BehaviorSubject

沒錯 FormControl 作爲 Value Controller,它擁有像 Signals 或 BehaviorSubject 一樣監聽 value 變更的能力。

FormControl as Value Validator

FormControl 除了管理 value,它還可以對這些 value 進行 validation。

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

總結

Native Form 的目標是讓使用者在幾乎不需要編寫 CSS 和 JS 代碼的情況下,實現出還不錯的表單體驗。

這個目標顯然不是 Angular 期望達到的,所有 Angular 沒有選擇基於 Native Form,而是從起爐竈做出了 Reative Forms。

儘管如此,由於表單需求千差萬別,Reactive Forms 有時也會遇到無法滿足或難以擴展的情況。

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

   

 

 

 

 

 

目錄

上一篇 Angular 17+ 高級教程 – HttpClient

下一篇 Angular 17+ 高級教程 – NgModule

想查看目錄,請移步 Angular 17+ 高級教程 – 目錄

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章