[前端框架] Angular - 觀察對象、表單、管道

本篇深入探討一些資料流的應用觀念,包含 Observable 提供可觀察的非同步結果、以及 Form 表單的設計要點、pipe 管道的技巧。其中一些內容將會使用到 rxjs 的第三方工具使用。

Observable 可觀察物件

Observable 是一個用於處理非同步事件序列的類別,它可以讓我們以一種簡潔的方式來處理事件流,並讓我們的應用程式更加易於擴展和維護。常用於處理 HTTP 請求、事件、計時器等非同步操作,它可以讓我們訂閱一個序列,並在序列中發生事件時進行處理。當 Observable 發生事件時,訂閱者可以接收到這些事件,然後進行相應的處理,例如顯示數據、執行一些邏輯、更新 UI 等等。

Observable 常見的特性包括:

  • 支持異步操作,可以在事件發生時傳遞數據。
  • 可以將多個操作串連在一起,形成一個事件序列。
  • 可以將一個 Observable 轉換成另一個 Observable,以實現更複雜的操作。
  • 可以使用操作符對事件序列進行過濾、映射、組合等操作,以實現更多的功能。

在 Angular 中,Observable 是非常常用的一個類別,它廣泛應用於處理 HTTP 請求、表單驗證、事件監聽等非同步操作。Observable 可以讓我們更好地管理應用程式中的非同步操作,提高應用程式的可讀性和可維護性,是 Angular 應用程式中非常重要的一部分。

Observable 對在應用的各個部分之間傳遞訊息提供了支援。它們在 Angular 中頻繁使用,並且推薦把它們用於事件處理、非同步程式設計以及處理多個值等場景。觀察者(Observer)模式是一個軟體設計模式,它有一個物件,稱之為主體 Subject,負責維護一個依賴項(稱之為觀察者 Observer)的列表,並且在狀態變化時自動通知它們。 該模式和發佈/訂閱模式非常相似(但不完全一樣)。

Observable 可以被認為是一個 Data Sources,在 Angular 專案中,一個 Observable 就是從第三方 package (RxJS 函式庫)進行導入的物件。所以我們會有一個觀察者 Observer 並在時間線上讓 Observable 或 Data package 資料包由可觀察對象發出多個事件。

你可以連結一個按鈕,每當按下按鈕時 Data package 資料包就會發送一個事件給像是 Http Server 之類的獲得一個 http request,而當獲得 response 回傳時將資料包夾帶回來,這就是所謂的 subscribe 訂閱功能。

你可以透過三種 d 考量來處理返回的資料包可能,包含:

  • Handle Data: 有新資料時
  • Handle Error: 發生錯誤時
  • Handle Completion: 完成工作時

你可以控制當你收到資料包當下該做什麼事或是獲得什麼錯誤。而 Observable 結束時會發生什麼。Observable 拿來處理一些非同步任務,因為這裡所有的 Data Sources 或 Http 請求都是非同步作業。也許在 ES6 階段你學過 callback 或 promises,而 Observable 是另一種不同處理方法,與使用 Angular 具備優勢所提供的 Observable 有不同的選擇方式。

補充 Observable 來自於 RxJS 函式庫,透過 CLI 建立則會自動加入,如果手動環境沒有的需要自行安裝 RxJS

npm install rxjs
npm install rxjs-compat #可兼容舊版本前的 RxJS 代碼

RxJS 是 Reactive Extensions for JavaScript 的簡稱
它是一個用於處理非同步事件流和基於事件流的程序的函式庫。RxJS 基於觀察者模式和迭代器模式,提供了一個強大的工具集,使開發人員能夠更輕鬆地處理非同步事件,例如 HTTP 請求、鼠標事件、鍵盤事件、計時器等等。

RxJS 的核心概念是 Observable,Observable 表示一個非同步事件流,可以監聽並處理事件。Observable 提供了一些操作符,例如 map、filter、reduce、merge、combineLatest 等等,可以對事件流進行操作,使得開發人員能夠更方便地進行數據處理和操作。

RxJS 還提供了一些工具和操作符,例如 Subject、BehaviorSubject、ReplaySubject、operators、Schedulers 等等,這些工具和操作符可以使開發人員更加靈活地使用 Observable,實現更複雜的非同步操作。

RxJS 的優點包括:提供了一個簡潔而強大的 API,使得處理非同步事件流變得更加容易;具有良好的可讀性和可維護性,並支持運用函數式編程的思想進行開發;具有良好的擴展性和可定制性,可以方便地擴展和自定義操作符;支持在多種環境中使用,包括瀏覽器、Node.js 等等。

示範與前置準備

本篇的起始素材放置於 GitHub 底下提供使用,使用資料目錄 lokiObservable 作為初始環境。下載請記得 npm install 初始化環境。

Github download at lokiObservable-start Folder

素材本身提供簡單的路由頁面分為 Home,User1,User2。我們到 Home.component.ts 使用一下來自 RxJS 函式庫的內建 Observable 工具 Interval(),你可以設定時間並傳入一個數字,他會每秒觸發並計數。透過 subscribe 訂閱可觀察物件,

參閱 RJS - interval

home.component.ts
import { Component, OnInit } from '@angular/core';
import { interval } from 'rxjs'; //※重點

@Component({
selector: 'app-home',
templateUrl: './home.component.html',
styleUrls: ['./home.component.css']
})
export class HomeComponent implements OnInit {

constructor() { }

ngOnInit() {
interval(1000).subscribe(count => console.log(count)); //※重點
}

}

此如果試著離開 home 透過路由到其他頁再回來,會發現 Observable 不會因為銷毀 DOM 而停止訂閱。因此你需要銷毀當下取消訂閱。記住訂閱的屬性,其強型別為 Subscription。

home.component.ts
import { Component, OnDestroy, OnInit } from '@angular/core';
import { interval, Subscription } from 'rxjs'; //※重點

@Component({
selector: 'app-home',
templateUrl: './home.component.html',
styleUrls: ['./home.component.css']
})
export class HomeComponent implements OnInit, OnDestroy {

obsSubscriptionKey!: Subscription; //記住這個訂閱

constructor() { }

ngOnInit() {
this.obsSubscriptionKey = interval(1000).subscribe(count => console.log(count)); //※重點
}

ngOnDestroy(): void {
this.obsSubscriptionKey.unsubscribe(); //取消訂閱
}
}

這是 RxJS 內建的 Observable 其中一種工具,下一節我們來建構自訂的 Observable。

建構自訂的 Observable

參考以上同需求但使用自訂的,透過 new Observable() 並賦予觀察者為參數,使用觀察者的 next 來傳遞下一次的值。而訂閱與取消與前一節相同。

home.component.ts
import { Component, OnDestroy, OnInit } from '@angular/core';
import { interval, Observable, Subscription } from 'rxjs'; //※重點

@Component({
selector: 'app-home',
templateUrl: './home.component.html',
styleUrls: ['./home.component.css']
})
export class HomeComponent implements OnInit, OnDestroy {

private obsSubscriptionKey!: Subscription; //記住這個訂閱

constructor() { }

ngOnInit() {
// this.obsSubscriptionKey = interval(1000).subscribe(count => console.log(count));
const customIntervalObs = new Observable(observer => { //※重點
let count = 0;
setInterval(() => { //這是 JS 不是 RxJS
observer.next(count);
count++;
}, 1000);
});

this.obsSubscriptionKey = customIntervalObs.subscribe((res) => console.log(res));
}

ngOnDestroy(): void {
this.obsSubscriptionKey.unsubscribe(); //取消訂閱
}
}

error 錯誤與 complete 完成

這裡介紹錯誤與完成如何觸發,整個其時跟 ES6 的 Promise 觀念雷同。

自 RxJS 6.4 開始錯誤與完成的寫法有改動,參閱 RxJS - Subscribe Arguments

home.component.ts
import { Component, OnDestroy, OnInit } from '@angular/core';
import { interval, Observable, Subscription } from 'rxjs'; //※重點

@Component({
selector: 'app-home',
templateUrl: './home.component.html',
styleUrls: ['./home.component.css']
})
export class HomeComponent implements OnInit, OnDestroy {

private obsSubscriptionKey!: Subscription; //記住這個訂閱

constructor() { }

ngOnInit() {
// this.obsSubscriptionKey = interval(1000).subscribe(count => console.log(count));
const customIntervalObs = new Observable(observer => {
let count = 0;
setInterval(() => { //這是 JS 不是 RxJS
observer.next(count);
if (count === 2) //測試 error 要註解,不然跑不到 3
observer.complete();
if (count > 3)
observer.error(new Error('Count is 3!')); //※重點 建立錯誤事件與資料包
count++;
}, 1000);
});

this.obsSubscriptionKey = customIntervalObs.subscribe({
next: res => console.log(res),
error: err => console.error(err),
complete: () => console.info('complete')
});
}

ngOnDestroy(): void {
this.obsSubscriptionKey.unsubscribe(); //取消訂閱
}
}

注意的是,若是觸發了 error 會取消此觀察,導致這個訂閱後續會被取消。而 complete 則是會正常結束訂閱取消,以這兩個動作結果來說 ngOnDestroy 的 unsubscribe 就顯得多餘,但強制習慣添加 unsubscribe 不會構成問題。

總結一下,絕大部分你不會自己創建一個自訂可觀察對象。而是 Angular 提供的 Hook 之中(例如路由)都會用到本身提供的 Observable 與訂閱。

RxJS 運算子

Angular 整合 RxJS 不少運算子,等同於 JS 領域那些運算子。本節會使用 RxJS 提供的 map 以及 Pipe 來示範。map 本身同 Array.map 觀念,能將連續的資料做映射處理回傳。而 pipe 是管道能在傳遞過程中進行轉譯處理。

假設我們希望 console 從 0,1,2 變成 Round 1,Round 2,Round 3。你可能會直接寫在 console.log 處。

home.component.ts
this.obsSubscriptionKey = customIntervalObs.subscribe({
next: res => console.log(`Round ${res+1}`), //※重點
error: err => console.error(err),
complete: () => console.info('complete')
});

事實上這樣寫不好。如果可以能否在訂閱當下時就能做文字變化。

  • 首先需要用到 pipe,這是 RxJS 提供的運算子,每個 observable 都有 pipe() 可用。
  • 再來是透過 map 將我們每次透過訂閱獲得的資料做映射處理並 return 給 pipe

pipe - rxjs/operators
RxJS 的 pipe 是一個函數,用於連接一系列的操作符。每一個操作符都接收一個 Observable,對其進行轉換或過濾,然後返回一個新的 Observable。pipe 函數將這些操作符連接起來,從而形成一個管道,讓數據可以流經整個管道,最終經過轉換或過濾後,產生新的數據流。

map - rxjs/operators
RxJS 中的 map 操作符是一個用於對數據流進行轉換的操作符。它可以接收一個回調函數,該函數會被應用到數據流中的每一個值上,並將其轉換為一個新的值,最終返回一個新的 Observable。

home.component.ts
//...
import { map } from 'rxjs/operators';
//...

export class HomeComponent implements OnInit, OnDestroy {
//...
ngOnInit() {
//...
this.obsSubscriptionKey = customIntervalObs
.pipe( //※重點
map( //※重點
(data: any) => 'Round ' + (data + 1)
)
)
.subscribe({
next: res => console.log(res),
error: err => console.error(err),
complete: () => console.info('complete')
});
//...
}
//...
}

或者你可以使用 filter 來過濾大於 0 的 data 才輸出。pipe 可以多個工作。

home.component.ts
//...
import { map } from 'rxjs/operators';
//...

export class HomeComponent implements OnInit, OnDestroy {
//...
ngOnInit() {
//...
this.obsSubscriptionKey = customIntervalObs
.pipe(
filter( //※重點
(data: any) => data > 0
),
map(
(data: any) => 'Round ' + (data + 1)
)
)
.subscribe({
next: res => console.log(res),
error: err => console.error(err),
complete: () => console.info('complete')
});
//...
}
//...
}

Subjects 主題

在 RxJS 中,Subject 是一個特殊的 Observable,它可以被用來向多個觀察者發出數據,並且也可以被用來作為一個觀察者來接收數據。具體來說,Subject 可以被看作是一個可觀察對象和一個觀察者的結合體,它可以同時執行這兩種角色。當 Subject 被訂閱時,它會將所有的數據都發送給所有的觀察者,因此所有的觀察者都可以得到完整的數據流。當 Subject 接收到新的數據時,它會將這些數據發送給所有的觀察者,這樣就可以實現多個觀察者之間的通信。

例如,下面的代碼中,我們創建了一個 Subject,並向其中添加了三個觀察者,然後通過調用 next 方法向 Subject 中添加了一個數據:

import { Subject } from 'rxjs';

const subject = new Subject();

subject.subscribe(value => console.log(`Observer 1: ${value}`));
subject.subscribe(value => console.log(`Observer 2: ${value}`));
subject.subscribe(value => console.log(`Observer 3: ${value}`));

subject.next('Hello world');

// console print
// Observer 1: Hello world
// Observer 2: Hello world

代碼中,我們先創建了一個 Subject,然後使用 subscribe 方法向其中添加了三個觀察者,這三個觀察者都會接收到 Subject 中發出的數據。最後,我們使用 next 方法向 Subject 中添加了一個字符串 ‘Hello world’,這樣所有的觀察者都會收到這個字符串。需要注意的是,Subject 會將數據流中的所有數據都發送給觀察者,因此如果 Subject 在觀察者訂閱之前就已經發出了一些數據,那麼這些數據將會被丟失。

使用 Subject 的時機

以本素材需求為優化前描述,以 service 的方式而非 input/output,理解不同元件之間使用 Service 方式為基礎,採用 emit 發射器方式傳遞資料流方式,以及改用 Subject 方式的優化差異前後。

優化前透過 Emit 發射

於下層 user.component 規劃一個事件按鈕,當按下”啟用它”時會發射一個事件給 user.service 而更改 boolean 值,而上層 app.component 透過 service 訂閱到這個 boolean 值得變化來決定是否顯示”已啟用”。

  • 透過 cli 指令ng g s user快速建立 user.service.ts 並搬移至 user 目錄下專用,這個 service 能提供一個事件型物件之變數 activateEmitter。
  • 調整 user.component.html 追加”啟用它”的事件按鈕與onActivate()動作。
  • 調整 user.component.ts,引用 UserService,並規劃 onActivate() 向 UserService 發送 true。
  • 於 app.component.html 追加根據本地變數 boolean 是否顯示指定文字”顯示它”。
  • 於 app.component.ts,透過引用 UserService 並訂閱 activateEmitter 的變化,如果有變動就取得此值存回本地變數 boolean。
user/user.service.ts
import { EventEmitter, Injectable } from '@angular/core';

@Injectable({
providedIn: 'root'
})

export class UserService {
activateEmitter = new EventEmitter<boolean>();
constructor() { }
}
user.component.html
<button class="btn btn-primary" (click)="onActivate()">啟用它</button>
user.component.ts
import { UserService } from './user.service';
import { Component, OnInit } from '@angular/core';
import { ActivatedRoute, Params } from '@angular/router';

@Component({
selector: 'app-user',
templateUrl: './user.component.html',
styleUrls: ['./user.component.css']
})
export class UserComponent implements OnInit {
id!: number;

constructor(
private route: ActivatedRoute,
private userService: UserService //※重點
) {
}

ngOnInit() {
this.route.params.subscribe((params: Params) => {
this.id = +params['id'];
});
}

onActivate() { //※重點
this.userService.activateEmitter.emit(true);
}
}
app.component.html
<p *ngIf="userActivated">已啟用!</p>
<hr>
app.components.ts
import { UserService } from './user/user.service'; //※重點
import { Component, OnInit } from '@angular/core';

@Component({
selector: 'app-root',
templateUrl: './app.component.html',
styleUrls: ['./app.component.css']
})
export class AppComponent implements OnInit {
userActivated = false;

constructor(
private UserService: UserService //※重點
) { }

ngOnInit() {
this.UserService.activateEmitter.subscribe(res => this.userActivated = res); //※重點
}
}

@Injectable() & providers in app.module.ts vs @Injectable({providedIn: ‘root’})
創建一個 Service Class 並使用 @Injectable() 裝飾器來標記此類別為可注入的 service。使用 @Injectable() 裝飾器可以讓 Angular 在創建實例時正確地處理依賴關係。在 app.module.ts 內將 service 添加到 providers 陣列中以使其在各處可被使用:

app.module.ts
@NgModule({
// ...
providers: [
UserService // 添加
],
})

如果 @Injectable() 裝飾器中的 providedIn 屬性被設置為 root,則不需要將 service 添加到 providers 陣列中。這將使服務在整個應用程序中都可用。@Injectable() 裝飾器帶有一個配置對象 { providedIn: ‘root’ },它表示此服務應該被注入到整個應用的根級別。

目前操作能透過 service 讓兩個元件彼此傳遞資料。

使用 Subject 優化

比起 Emit 方式讓元件做 Emit 發射器的訂閱,更推薦透過 Subject 方式來捕獲這裡的資料流。

  • 調整 user.service 的變數 Emit 物件更改為 Subject,使它成為一個特殊的 Observable 可以被用來向觀察者發出數據。
  • 回到 user.component.ts,要修改 Subject 的內容,透過 next 使資料推往下一個狀況。
user.service.ts
import { EventEmitter, Injectable } from '@angular/core';
import { Subject } from 'rxjs'; //※重點

@Injectable({
providedIn: 'root'
})

export class UserService {
// activateEmitter = new EventEmitter<boolean>();
activateEmitter = new Subject<boolean>(); //※重點
constructor() { }
}
user.component.ts
import { UserService } from './user.service';
import { Component, OnInit } from '@angular/core';
import { ActivatedRoute, Params } from '@angular/router';

@Component({
selector: 'app-user',
templateUrl: './user.component.html',
styleUrls: ['./user.component.css']
})
export class UserComponent implements OnInit {
id!: number;

constructor(
private route: ActivatedRoute,
private userService: UserService
) {
}

ngOnInit() {
this.route.params.subscribe((params: Params) => {
this.id = +params['id'];
});
}

onActivate() {
// this.userService.activateEmitter.emit(true);
this.userService.activateEmitter.next(true); //※重點
}

如此一來,同樣可正常運作,但過程是透過 Subject 來觀察變化取得,而不是去訂閱 Emit 的變化取得。同樣的需要當離開畫面去銷毀這裡的觀察。

  • 對 Subject 觀察者 app.compoonent.ts 考量取消訂閱的動作。先宣告本地變數型別 Subscription,在初始化去設定 subscribe 時做紀錄,最後 ngOnDestroy 階段下取消訂閱。
app.component.ts
import { Subscription } from 'rxjs';
import { UserService } from './user/user.service';
import { Component, OnDestroy, OnInit } from '@angular/core';

@Component({
selector: 'app-root',
templateUrl: './app.component.html',
styleUrls: ['./app.component.css']
})
export class AppComponent implements OnInit, OnDestroy {
userActivated = false;
private activatedSub: Subscription; //※重點

constructor(
private UserService: UserService
) {
this.activatedSub = this.UserService.activateEmitter.subscribe(res => this.userActivated = res); //※重點
}

ngOnInit() {
// this.UserService.activateEmitter.subscribe(res => this.userActivated = res);
}

ngOnDestroy() {
this.activatedSub.unsubscribe(); //※重點
}
}

Forms 表單

用表單處理使用者輸入是許多常見應用的基礎功能。 應用透過表單來讓使用者登入、修改個人檔案、輸入敏感資訊以及執行各種資料輸入任務。在 Angular 的使用環境上,依賴 SPA 觀念來設計前端所需要的表單提交。

在 Angular 中,表單有兩種不同的方式可以實現:模板驅動表單(Template-driven form)和反應式表單(Reactive form)。兩者都從檢視中捕獲使用者輸入事件、驗證使用者輸入、建立表單模型、修改資料模型,並提供追蹤這些更改的途徑。

模板驅動表單:
是基於模板的方式來建立表單,也就是說在 HTML 中編寫表單元素並添加 ngModel 指令,透過這些指令來聯繫表單元素和組件中的屬性,從而實現表單數據的綁定和驗證。這種方式相對簡單,適合簡單的表單,但在處理複雜的表單時可能會變得難以維護和擴展。

反應式表單:
是基於 RxJS 的方式來建立表單,使用 ReactiveForms 模塊提供的 FormGroup、FormControl、FormArray 等類來建立表單元素,然後使用 ReactiveForms 模塊提供的驗證器來驗證表單數據。這種方式更加靈活,可以處理複雜的表單場景,並且可以透過 RxJS 的操作符來處理表單數據的異步操作,例如 debounceTime、switchMap 等。

總體而言,模板驅動表單適用於簡單的表單,反應式表單適用於複雜的表單,並且可以提供更好的可維護性和可擴展性。使用哪種方式取決於表單的複雜度以及開發團隊的偏好。

Template-driven 模板驅動表單

模板驅動表單依賴範本中的指令來建立和操作底層的物件模型。它們對於嚮應用新增一個簡單的表單非常有用,比如電子郵件列表登錄檔單。它們很容易新增到應用中,但在擴充套件性方面不如響應式表單。如果你有可以只在範本中管理的非常基本的表單需求和邏輯,那麼模板驅動表單就很合適。

示範與前置準備

本篇的起始素材放置於 GitHub 底下提供使用,使用資料目錄 lokiFormTD 作為初始環境。下載請記得 npm install 初始化環境。

Github download at lokiFormTD-start Folder

這是一個簡單的表格,目前沒有任何 sumbit 行為,接下來示範如何 TD 來完成表單提交,透過 Angular 來驅動表單。

如果你是使用 CLI 來完成初始化,CLI 幫你添加 FormsModule 的 import,這是表單 TD 會用到的 FormsModule。

app.module.ts
import { FormsModule } from '@angular/forms';//※重點
//...

@NgModule({
//...
imports: [
BrowserModule,
FormsModule, //※重點
],
//...
})

Angular 不會自動偵測你的表單那些表單元素是否要協助處理,你需要主動提供類似 ngModel 的數據綁訂。這裡不需要使用[(ngModel)]直接使用屬性ngModel就好,以及提供 name 屬性值。

app.component.html
<!-- ... -->
<input
type="text"
id="username"
class="form-control"
ngModel
name="username"
>
<!-- ... -->
<input
type="email"
id="email"
class="form-control"
ngModel
name="email"
>
<!-- ... -->
<select
id="secret"
class="form-control"
ngModel
name="secret"
>
<!-- option ... -->
</select>
<!-- ... -->

使用參數來訪問表單

接著我們需要一個方法來進行作業處理,同時在範本的 form 元素上去綁定這個提交事件。同時可利用本地變數的方式 (#name),將自己當作參數提交出去,可觀察 console 結果為何。

app.component.html
<form
(ngSubmit)="onSubmit(myForm)"
#myForm
>
<!-- ... -->
</form>
app.component.ts
import { Component } from '@angular/core';

@Component({
selector: 'app-root',
templateUrl: './app.component.html',
styleUrls: ['./app.component.css']
})
export class AppComponent {
suggestUserName() {
const suggestedName = 'Superuser';
}

onSubmit(form: HTMLFormElement) {
console.log(form);
}
}

實際上還有更好用的傳遞之內容參數,就是讓 Angular 能自動對這個表單捕獲有設定 ngModel 屬性的表單元素。就是將本地變數屬姓名不動,多添加值為 ngForm。 這樣就能用 ngForm 來捕獲表單內的資料。

app.component.html
<form
(ngSubmit)="onSubmit(myForm)"
#myForm="ngForm"
>
app.component.ts
import { Component } from '@angular/core';
import { NgForm } from '@angular/forms';

@Component({
selector: 'app-root',
templateUrl: './app.component.html',
styleUrls: ['./app.component.css']
})
export class AppComponent {
suggestUserName() {
const suggestedName = 'Superuser';
}

onSubmit(form: NgForm) {
// console.log(form);
console.log(form.value);

}
}

你可以研究 ngForm 鎖提供的變數除了 value 還提供不少好東西。包含像是元素類型、是否 dirty(已填), disabled, enabled, errors, valid… 等等,這些都是能拿來做更好的用戶體驗。之後再細談示範。

使用 @ViewChild 訪問表單

這裡有另一個方式來訪問表單,還記得我們能透過@ViewChild來訪問本地變數嗎?保留上一做法做比較:

app.component.html
<!-- <form
(ngSubmit)="onSubmit(myForm)"
#myForm="ngForm"
> -->
<form
(ngSubmit)="onSubmit()"
#myForm="ngForm"
>
app.component.ts
import { Component, ViewChild } from '@angular/core';
import { NgForm } from '@angular/forms';

@Component({
selector: 'app-root',
templateUrl: './app.component.html',
styleUrls: ['./app.component.css']
})
export class AppComponent {
@ViewChild('myForm') viewForm!: NgForm; //※重點
suggestUserName() {
const suggestedName = 'Superuser';
}
// onSubmit(form: NgForm) {
// console.log(form.value);
// }
onSubmit() { //※重點
console.log(this.viewForm.value);
}
}

我們可以不靠別名參數,直接從 TS 的 ViewChild 去訪問模板下的元素捕捉 ngForm 並提供強型別。

關於 @ViewChild
ViewChild 是 Angular 中的一個裝飾器(Decorator),它用於在 Component 中獲取對 DOM 元素、指令或子組件的引用。通常在 Component 中,我們需要與模板上的元素進行交互,例如改變元素的內容、屬性或事件等。使用 ViewChild 可以讓我們在 Component 中獲取對模板上的元素或指令的引用,以便進行操作或訪問其屬性、方法等。

ViewChild 通過在 Component 中聲明一個名稱與對應元素的類型來使用。舉個例子,如果我們想要獲取模板中的一個標籤元素,可以在 Component 中聲明一個名稱和對應元素類型的變量,並使用@ViewChild裝飾器進行標記。例如:

import { Component, ViewChild, ElementRef } from '@angular/core';

@Component({
selector: 'app-example',
template: '<h1 #myTitle>Hello Angular</h1>'
})
export class ExampleComponent {
@ViewChild('myTitle', { static: true }) title: ElementRef;
}

上面的代碼中,我們在 Component 的模板中定義了一個標籤元素 h1,並通過#myTitle 聲明了它的名稱。在 Component 中,使用@ViewChild裝飾器聲明了一個名稱為 title,類型為 ElementRef 的變量,這個變量指向了模板中的 myTitle 元素。

需要注意的是,當使用 ViewChild 時,元素必須已經在 DOM 中渲染出來。如果我們想在 Component 初始化的時候就獲取元素的引用,可以將第二個參數{static: true}傳遞給@ViewChild 裝飾器,這樣就可以在 ngOnInit() 生命週期鉤子函數中訪問元素了。如果不傳遞這個參數,元素的引用只能在 ngAfterViewInit() 鉤子函數中訪問。

Validation 驗證

你可以透過 HTML5 的 required 屬性添加,讓 Angular 自動配置確保表單提交是否無效以及檢查這些欄位是否有效輸入。而 mail 部分還能添加屬性 mail 讓 Angular 自動判斷 email 格式(也就是否有@符號而已)。同時可發現 Angular 會自動樣式表的的 class 名稱 ng-dirty, ng-valid 或 ng-invalid 來讓我們做視覺上的差異設計。

更多驗證器的功能請參考 官方文件

您可能想要啟用 HTML5 驗證(默認情況下,Angular 禁用它)。您可以通過將 添加 ngNativeValidate 到模板中的 Form 屬性來執行此操作。

<form
(ngSubmit)="onSubmit()"
#myForm="ngForm"
ngNativeValidate
>
<!-- ... -->
</form>
ddd

驗證不會主動阻擋提交,我們可以透過對 submit 按鈕規劃一個 disable 判斷,利用本地變數的 ngForm.valid 值來確保 disabled 是否啟用。

app.component.html
<button
class="btn btn-primary"
type="submit"
[disabled]="!myForm.valid"
>Submit</button>

你還能改善驗證時的 CSS 樣式,可多選擇到當有碰過的欄位但驗證錯誤的才做樣式效果。這些 css 的 class 由 Angular 自動生成。

app.component.css
form .ng-invalid.ng-touched {
border: 1px solid red;
}

也可多添加提示字利用*ngIf來完成,對驗證時反應的提示欄位 p 添加本地變數且必需其指定值為 ngModel。

app.component.html
<div class="form-group">
<label for="email">Mail</label>
<input
type="email"
id="email"
class="form-control"
ngModel
name="email"
required
email
#myMail="ngModel"
>
</div>
<p *ngIf="!myMail.valid&&myMail.dirty">Please input valid value</p>

單向綁定與雙向綁定

目前為止都只是對 Input 元素透過 ngModel 獲取資料。若要從 TS 設定初始預設值,可透過單向綁定(ngModel)成功指定預設值給 html 模板。因此 select 元素要綁訂預設值,我們可以透過單向(雙向也可)綁訂魚 TS 內的屬性。

app.component.html
<select
id="secret"
class="form-control"
name="secret"
required
(ngModel)="defaultAns"
>
app.component.ts
export class AppComponent {
@ViewChild('myForm') viewForm!: NgForm;
defaultAns = 'pet'; //※重點

suggestUserName() {
const suggestedName = 'Superuser';
}
// onSubmit(form: NgForm) {
// console.log(form.value);
// }
onSubmit() {
console.log(this.viewForm.value);
}
}

假若希望利用雙向綁定[(ngModel)],也就是 TS 給予預設值顯示於 HTML 模板上,而 HTML 模板上的修改使得 TS 進行值的被改變。對表單元素的值進行雙向綁定,你可以當作任何表單元素的輸入於 submit 之前進行其他操作必要。可檢查 submit 之後的 console 是否捕獲到這個 textarea 輸入或預設值。

app.component.html
<textarea
name="userAns"
rows="3"
[(ngModel)]="answer"
></textarea>
<p> 你輸入了:{{answer}}</p>
app.component.ts
export class AppComponent {
@ViewChild('myForm') viewForm!: NgForm;
defaultAns = 'pet';
answer='your answer'; //※重點

suggestUserName() {
const suggestedName = 'Superuser';
}
// onSubmit(form: NgForm) {
// console.log(form.value);
// }
onSubmit() {
console.log(this.viewForm.value);
}
}

目前為止,表單值的三種型式為:

  • ngModel: 沒有綁定,讓 Angular 能得知這裡的表單元素(可以是作為 ngSubmit 底下的所有訪問)。
  • (ngModel): 單向綁定,同上,同時 TS 指定一預設值提供給該表單元素。
  • [(ngModel)]: 雙向綁定,同上,也能從 html 立即修改綁定給 TS,利於其他功能操作上之考量(例如及時顯示於 p 元素上)。

ngModelGroup 群組化

回到一開始,我們知道如果要將指定欄位給模板驅動表單 TD 使用,你必需第一步手動對指定的欄位添加屬性 ngModel 才能咬到這些元素。事實上你可以把在某父層元件設定 ngModelGroup 弄成群組化。Angular 會試著找到這些下層持有 ngModel 屬性的弄成同一層物件,使得你的資料結構方便整理。

app.component.html
<div id="user-data" ngModelGroup="userData"> <!-- ※重點 -->
<div class="form-group">
<label for="username">Username</label>
<input
type="text"
id="username"
class="form-control"
ngModel
name="username"
required
>
</div>
<button
class="btn btn-secondary"
type="button"
>Suggest an Username</button>
<div class="form-group">
<label for="email">Mail</label>
<input
type="email"
id="email"
class="form-control"
ngModel
name="email"
required
email
#myMail="ngModel"
>
</div>
<p *ngIf="!myMail.valid&&myMail.dirty">Please input valid value</p>
</div>

同時驗證功能也會在這個 div#user-data 作用,因此畫面上只要這個群組下只要其中一個驗證錯誤會是這個 div#user-data 驗證錯誤。與 ngModel 有一樣的控制驗證,也能用本地變數操作提示字段的設計。

app.component.html
<div
id="user-data"
ngModelGroup="userData"
#myUserData="ngModelGroup"
>...</div>
<p *ngIf="!myUserData.valid&&myUserData.dirty">Please check user and mail</p>

radio 的預設與驗證

我們在畫面上多設計一個 radio 並探討如何控制它。先來到 ts 這裡初始屬性值。並在試著的位置上貼上 html

app.component.ts
genders = ['man', 'woman']; //※重點
app.component.html
<div class="radio" *ngFor="let item of genders">
<label>
<input type="radio" [value]="item" name="gender"> {{item}}
</label>
</div>

然而 radio 這樣的表單元素若採用預設值,原有觀念下使用 checked 你可以這樣設定預設值。

app.component.html
<input type="radio" [value]="item" name="gender" [checked]="item==='woman'"> {{item}}

如果我們要列入 ngModel 方便 TD 捕獲時,會發現原本的 checked 失效了,這是由於其實本來有選中,因為我們指定了 ngModel 未提供任何值,因此 angular 根據沒有值而幫我們取消了。所以你應該這樣寫

app.component.html
<input type="radio" [value]="item" name="gender" ngModel="woman"> {{item}}

若要增加驗證功能,如同前面增加 required 即可。

app.component.html
<input type="radio" [value]="item" name="gender" ngModel="woman" required> {{item}}

自動填入與重置

ngForm 是用於表單處理的指令,提供了許多有用的功能來處理表單的輸入和驗證。其中包括以下幾個方法:

  • setValue
    用於設置整個表單的值,可以一次性設置多個表單控制元件的值。需要傳遞一個對象,對象的鍵值就是表單控制元件的名稱,值是要設置的值。如果表單控制元件的名稱在對象中不存在,則該控制元件的值不會被設置。可選的 options 參數可以用於設置是否只更新本身和是否觸發事件。
    setValue(
    value: { [key: string]: any; },
    options?: { onlySelf?: boolean; emitEvent?: boolean; }
    ): void
  • patchValue
    用於部分更新表單的值,僅更新傳入的對象中存在的控制元件的值,對於不存在的控制元件的值不做更改。可選的 options 參數可以用於設置是否只更新本身和是否觸發事件。
    patchValue(
    value: { [key: string]: any; },
    options?: { onlySelf?: boolean; emitEvent?: boolean; }
    ): void
  • reset
    用於重置表單的值和狀態。默認情況下,該方法會重置表單中的所有控制元件的值和狀態。可以傳入一個對象作為可選的 formState 參數,用於設置表單的初始值。可選的 options 參數可以用於設置是否只更新本身和是否觸發事件。
    reset(
    formState?: any,
    options?: { onlySelf?: boolean; emitEvent?: boolean; }
    ): void

這些方法可以用於在代碼中設置和更新表單的值,這在某些情況下非常有用,比如在表單提交時清除表單的值或者在表單初始化時設置表單的預設值。

素材上還有一個沒作用的按鈕,我們拿來綁定一個事件,當我們按下這個按鈕能幫我們把所有欄位填上預設值。

app.component.html
<button class="btn btn-secondary" type="button" (click)="suggestUserName()">Suggest an Username</button>

我們可以利用剛剛做好的秘密通道,透過@ViewChild而產出的 viewForm 可用屬性,獲得的是一個物件資料,就反向塞回去。

app.component.ts
export class AppComponent {
@ViewChild('myForm') viewForm!: NgForm;
defaultAns = 'pet';
answer = 'your answer';
genders = ['man', 'woman']; //※重點

suggestUserName() {
const suggestedName = suggestedName;
const defaultData = {
gender: "man",
secret: "pet",
userAns: "your sky",
userData: { username: 'super', email: 'aa@aa' }
};
this.viewForm.setValue(defaultData);
}
onSubmit() {
console.log(this.viewForm.value);
}
}

但如果只是想局部或是一個欄位呢?你可以改用 patchValue。

app.component.ts
suggestUserName() {
const suggestedName = 'Superuser';
this.viewForm.form.patchValue({
userData: { username: suggestedName }
});
}

至於重置的方式為reset(),我們把這個代碼放置在提交後執行。

app.component.ts
onSubmit() {
console.log(this.viewForm.value.userData);
this.viewForm.reset();
}

Reactive forms 反應式表單

兩者最大差異在於一個是 html 的範本來設計表單,一個是 TypeScript 來設計表單。響應式表單提供對底層表單物件模型直接、顯式的訪問。它們與模板驅動表單相比,更加健壯:它們的可擴充套件性、可複用性和可測試性都更高。如果表單是你的應用程式的關鍵部分,或者你已經在使用響應式表單來建構應用,那就使用響應式表單。

示範與前置準備

本篇的起始素材放置於 GitHub 底下提供使用,使用資料目錄 lokiFormReactive 作為初始環境。下載請記得 npm install 初始化環境。

Github download at lokiFormReactive-start Folder

這是一個簡單的表格,目前沒有任何 submit 行為,接下來示範如何 Reactive 來完成表單提交。

ReactiveFormsModule

用於支援在 Angular 應用中使用 Reactive Form。Reactive Form 是 Angular 表單中的一種方式,它提供了一個可觀察的資料結構,可以很方便地管理表單中的數據。使用 Reactive Form 需要引入 ReactiveFormsModule 模組。在引入之後,就可以使用 FormGroup、FormControl 等 Reactive Form 相關的類別。

其中 FormGroup 是 Reactive Form 中的一個重要類別,它可以用來組織表單控制元素,例如將多個 FormControl 組成一個 FormGroup。在 FormGroup 中可以對所有 FormControl 進行校驗和設置默認值,也可以監聽 FormGroup 中的所有 FormControl 的變化。使用 Reactive Form 可以讓表單處理變得更簡潔、直觀,更適合使用複雜的表單場景,例如表單的動態添加、刪除欄位等。

因此,而與 TD 不同,我們不需要 FormsModule 模組,但我們需要 ReactiveFormsModule 來實施表格。

app.module.ts
import { ReactiveFormsModule } from '@angular/forms';//※重點
//...

@NgModule({
//...
imports: [
BrowserModule,
ReactiveFormsModule, //※重點
],
//...
})

FormGroup

FormGroup 是一個用於管理表單控制項的類別。它與 FormControl、FormArray 以及 ngModel 等相似,用於在模板中管理表單的輸入值。FormGroup 類別表示一組表單控制項,它可以包含任意數量的 FormControl、FormGroup 和 FormArray。每個表單控制項都可以與模板中的一個表單控制項綁定,並且可以使用 FormGroup 來檢驗表單中所有的值。

FormGroup 可以幫助我們在模板中輕鬆地管理表單的值,可以使用它的方法和屬性來設置、獲取和驗證表單的值。例如,使用 setValue 方法可以將表單中的所有控制項的值設置為一個對象。

然而 TD 表格使用的是 ngForm,在 Reactive 表格則是使用 FormGroup 模組。試著對素材規劃也一併 FormGroup 設定。

app.component.ts
import { Component } from '@angular/core';
import { FormGroup } from '@angular/forms';

@Component({
selector: 'app-root',
templateUrl: './app.component.html',
styleUrls: ['./app.component.css']
})
export class AppComponent {
genders = ['male', 'female'];
myForm!: FormGroup;
}

綁定 HTML 表單

我們需要讓 Angular 知道是哪個 HTML 需要同步給 TypeScript 處理,從範本中對 form 原入添加屬性綁定 FormGroup,並指定給你在 typeScript 內的持有 FormGroup 型別的相同變數。接著還要指定那些表單元素持有 formControlName 屬性中相同的屬性名。

app.component.ts
import { Component, OnInit } from '@angular/core';
import { FormControl, FormGroup } from '@angular/forms';

@Component({
selector: 'app-root',
templateUrl: './app.component.html',
styleUrls: ['./app.component.css']
})
export class AppComponent implements OnInit {
genders = ['male', 'female'];
myForm!: FormGroup;

ngOnInit() {
this.myForm = new FormGroup({ //※重點
'username': new FormControl(null),
'email': new FormControl(null),
'gender': new FormControl('male')
});
}
}
app.component.html
<div class="container">
<div class="row">
<div class="col">
<form [formGroup]="myForm"> <!-- ※重點,進行屬性綁定 -->
<div class="form-group">
<label for="username">Username</label>
<input type="text" id="username" class="form-control" formControlName="username">
<!--
formControlName="username" 等價 [formControlName]="'username'"
你可以使用右邊寫法的屬性綁定,但這裡是字串不是 ts 的屬性變數,所以左側寫法較方便
-->
</div>
<div class="form-group">
<label for="email">email</label>
<input type="text" id="email" class="form-control" formControlName="email">
</div>
<div class="radio" *ngFor="let gender of genders">
<label>
<input type="radio" [value]="gender" formControlName="gender">{{ gender }}</label>
</div>
<button class="btn btn-primary" type="submit">Submit</button>
</form>
</div>
</div>
</div>

接著 submit 的事件綁定給 form 元素去執行 typeScript 內的方法,使用(ngSubmit)="onSubmit()",而這裡不用傳遞參數或任何設定,因為資料本來就在 typescript 那裏。

app.component.html
<form [formGroup]="myForm" (ngSubmit)="onSubmit()">
app.component.ts
onSubmit() {
// console.log(this.myForm);
console.log(this.myForm.value);
}

Validation 驗證

在 TD 作法,使用 HTML 的 required 屬性就能開啟驗證,但在 Reactive 這裡不受作用,因為表格輸入綁定方式是根據你的指令從 TypeScript 那裏同步配置給範本上,所以為什麼每個欄位元素都要 formControl 宣告 new 建構函式出來。所以你必須要從 TS 那裏透過參數去調整每個表單控制。

在 formControl 的參數,第一個為預設值,第二個為驗證器。驗證器需要依賴內建模組並使用Validators.required。當然你可以使用陣列填入第二參數,代表這個表單控制有多個驗證都要執行。舉例 email 除了必填也要符合 email 格式。

app.component.ts
ngOnInit() {
this.myForm = new FormGroup({
'username': new FormControl(null, Validators.required),
'email': new FormControl(null, [Validators.required, Validators.email]),
'gender': new FormControl('male')
});
}

目前已經會自動提供驗證所需的樣式 class 名稱,而一樣 submit 當下不會幫你阻擋。

我們能透過 GET 函式如formGroup.get(key).*來獲得特定資訊,例如透過myForm.get('username').validmyForm.get('username').touched來控制提示驗證是否出現。

app.component.html
<form [formGroup]="myForm"> <!-- ※重點,進行屬性綁定 -->
<div class="form-group">
<label for="username">Username</label>
<input
type="text"
id="username"
class="form-control"
formControlName="username"
>
<p *ngIf="!myForm.get('username')?.valid && myForm.get('username')?.touched"> <!-- ※重點,確認某指定參數 -->
please input a value
</p>
</div>
<!-- ... -->
<p *ngIf="!myForm?.valid && myForm?.touched"> <!-- ※重點,確認整份參數 -->
please input a value
</p>
<button class="btn btn-primary" type="submit">Submit</button>
</form>

這裡使用?符號是因為 valid 可能型別上為 null。

同樣能改善驗證時的 CSS 樣式,可多選擇到當有碰過的欄位但驗證錯誤的才做樣式效果。這些 css 的 class 也由 Angular 自動生成且 class 命名相同。

app.component.css
form .ng-invalid.ng-touched {
border: 1px solid red;
}

FormGroup 嵌套群組化

在 TD 表單上,我們使用過 ngModelGroup 群組化,而 Reactive 這裡是利用兩層 FormGroup 來做群組化。舉例我們將 username 與 email 群組起來。

app.component.ts
ngOnInit() {
this.myForm = new FormGroup({
'userData': new FormGroup({ //※重點,多一層群組化
'username': new FormControl(null, Validators.required),
'email': new FormControl(null, [Validators.required, Validators.email]),
}),
'gender': new FormControl('male')
});
}

但目前有錯誤訊息找不到 username 的控制對象因為沒有userData>*的位置,所以同步的範本那裏要跟著修改。找到或規劃上層的 div 來扮演 userData。然後現在所有get()位置的 username 與 email 的字串變成userData.usernameuserData.email位置。

app.component.html
<div formGroupName="userData"> <!-- ※重點,對應 TS 的層級群組 -->
<div class="form-group">
<label for="username">Username</label>
<input
type="text"
id="username"
class="form-control"
formControlName="username"
>
<p *ngIf="!myForm.get('userData.username')?.valid && myForm.get('userData.username')?.touched"> <!-- ※重點,參數的層級位置變動 -->
please input a value
</p>
</div>
<div class="form-group">
<label for="email">email</label>
<input
type="text"
id="email"
class="form-control"
formControlName="email"
>
</div>
</div>

FormArray 表單控制元素陣列

FormArray 是一個可重複的控制項集合,用於處理動態表單和多選表單。它是 FormGroup 的一部分,可以將一個或多個 FormControl 作為它的子控制項添加到 FormArray 中。使用 FormArray 時,可以動態添加或刪除 FormControl 或 FormGroup,並對它們進行驗證、檢查值等操作。FormArray 可以用於表單中的重複控制項,例如動態添加多個 email 輸入欄位、多選框等。

如果表單欄位是動態增加,new FormArray([])是很適合的用途,你可以一開始就預設塞入一些 formControl 例如new FormArray([new FormControl()])這樣,可單一可多數。接著來示範如何去用到這個 FormArray();

  • 素材增加按鈕並事件綁定觸發一個動作 (click)="onAddLike()
  • 這個onAddLike()方法是,會幫我們對 FormArray 塞入一個新的 FormControl 且該 FormControl 會設定必填驗證
  • 範本上會有一個群組為formArrayName="likes"指定,內部元素會跑回圈。
  • 內部元素是根據多少元素來執行批次輸出,要知道多少元素就必須依賴方法getControls()來獲得。
  • getControls()能透過 get(‘likes’).controls 回傳給我們在 likes 裡面有那些 control。
  • 這些 control 也需要指定 formControlName 為何,但陣列只需要 index 為名稱,因此利用迴圈來提供 index 為 i
app.component.html
<button type="button" (click)="onAddLike()">Add like felid</button>
<div formArrayName="likes">
<div *ngFor="let item of getControls();let i = index">
<input
type="text"
[formControlName]="i"
>
</div>
</div>
app.component.ts
export class AppComponent implements OnInit {
genders = ['male', 'female'];
myForm!: FormGroup;

ngOnInit() {
this.myForm = new FormGroup({
'userData': new FormGroup({
'username': new FormControl(null, Validators.required),
'email': new FormControl(null, [Validators.required, Validators.email]),
}),
'gender': new FormControl('male'),
'likes': new FormArray([]) //※重點
});
}

onSubmit() {
console.log(this.myForm.value);
}
onAddLike() { //※重點
const newCtl = new FormControl(null, Validators.required);

//※重點 - 透過 GET 找到這個 FormArray,並給予結果為 FormArray 型別
(<FormArray>this.myForm.get('likes')).push(newCtl);
}
getControls() { //※重點
return (<FormArray>this.myForm.get('likes')).controls;
}
}

formArray 本身也算是一種 FormGroup,驗證的判斷是一同整個群組下做反應的。

自訂 custom 驗證

目前為止的驗證都來自內建的 required 或 mail,如果想要自訂該如何做,這裡示範假設有指定的用戶名不想讓人輸入使用的客製化驗種。

  • 規劃需驗證的資料陣列,這裡為 lockUserName 並指定兩個名字想鎖住。
  • 創造一個方法,提供 FormControl 為參數,屆時會跟本區域內的 lockUserName 做判斷,如果要鎖住需要提供{s:string:b:boolean}這樣的東西回傳,否則為 null
  • 來到想進行驗證的欄位,對 new 函式參數添加這個你自訂的驗證器,變成[Validators.required, this.checkLuckName.bind(this)]。bind 是因為 JS 封閉空間觀念要克服。
  • 最後測試驗證一下
component.ts
export class AppComponent implements OnInit {
genders = ['male', 'female'];
myForm!: FormGroup;
lockUserName = ['Loki', 'Max']; //※重點

ngOnInit() {
this.myForm = new FormGroup({
'userData': new FormGroup({
//※重點 bind 進去才能在該函式內使用 this 找到 luckUserName
'username': new FormControl(null, [Validators.required, this.checkLuckName.bind(this)]),
'email': new FormControl(null, [Validators.required, Validators.email]),
}),
'gender': new FormControl('male'),
'likes': new FormArray([])
});
}

onSubmit() {
console.log(this.myForm.value);
}
onAddlike() {
const newCtl = new FormControl(null, Validators.required);
(<FormArray>this.myForm.get('likes')).push(newCtl);
}
getControls() {
return (<FormArray>this.myForm.get('likes')).controls;
}
//※重點 - 非強迫,可指定 return 的 強型別
checkLuckName(ctl: FormControl): { [s: string]: boolean } | null {
// 找到會提供該位置索引數字,當找不到時則為-1
if (this.lockUserName.indexOf(ctl.value) !== -1) return { 'nameLuck': true };
return null;
}
}

利用除錯功能

如果要除錯這部分想知道每個表單欄位到底在進行什麼驗證,可透過 console 檢查這個大 FromGroup 每一層 error 或 Controls 內的任何一個對象 error。下列是幫你找到這個 username 的錯誤資訊,而驗證就是檢查這個底下的 error 是否非 null。

app.component.ts
onSubmit() {
// console.log(this.myForm.value);
// console.log(this.myForm);

// console.log(this.myForm.controls['userData'].get('username')?.errors);
console.log(this.myForm.get('userData.username')?.errors); // 同上
}

如果感到好奇,你可以嘗試其他欄位打內建 required 的錯誤會獲得甚麼樣的 error,答案會是{'required':true}。所以你可以利用這個 errors 位置去控制範本任何一處作為提示文字的判斷結果。

<!-- <p *ngIf="!myForm.get('userData.username')?.valid && myForm.get('userData.username')?.touched"> 
please input a value
</p> -->
<p *ngIf="myForm.get('userData.username').errors['required]">
please input a value
</p>

非同步自訂驗證

假設你的驗證功能來自於後端伺服器等待回應,你不能立即處理判斷,我們需要等待非同步後結果才能告知驗證是否通過。這裡使用 Promise 與 setTimeOut 來模擬 2 秒後的動作。

  • 規劃方法並參考自訂驗證做法,它會經過 promise 或是 Observable 來回傳 resolve 或 reject。而回傳的型別屬於 AsyncValidatorFn
  • 綁定此自訂非同步驗證給 email 第三個參數
app.component.ts
import { FormArray, FormControl, FormGroup, Validators, AsyncValidatorFn } from '@angular/forms'; //※重點
import { Observable } from 'rxjs';
//...
export class AppComponent implements OnInit {
//...
ngOnInit() {
this.myForm = new FormGroup({
'userData': new FormGroup({
'username': new FormControl(null, [Validators.required, this.checkLuckName.bind(this)]),
//※重點 -第三個參數作為非同步驗證,因回傳的資料型別為 AsyncValidatorFn,注意先宣告
'email': new FormControl(null, [Validators.required, Validators.email], <AsyncValidatorFn>this.checkLuckMailAsync),
}),
'gender': new FormControl('male'),
'likes': new FormArray([])
});

checkLuckMailAsync(ctl: FormControl): Promise<any> | Observable<any> {
const promise = new Promise((res, rej) => {
setTimeout(() => {
if (ctl.value === 'a@a') res({ 'emailLuck': true });
else res(null);
// 這裡不要用 res(null),避免發生 error 而中斷 valid 的後續動作
}, 2000);
});
return promise;
}
}

訂閱 FormGroup 的變化

我們可以對 FormGroup 底下的 valueChanges 與 statusChanges 是可觀察的對象,能進行進行訂閱做事何用途,這裡寫在 onInit 測試示範。

app.component.ts
ngOnInit() {
this.myForm = new FormGroup({
'userData': new FormGroup({
'username': new FormControl(null, [Validators.required, this.checkLuckName.bind(this)]),
'email': new FormControl(null, [Validators.required, Validators.email], <AsyncValidatorFn>this.checkLuckMailAsync),
}),
'gender': new FormControl('male'),
'likes': new FormArray([])
});

this.myForm.valueChanges.subscribe(val => console.log(val)); //※重點:訂閱 FormGroup 其中當有值變化時
this.myForm.statusChanges.subscribe(val => console.log(val)); //※重點:訂閱 FormGroup 其中當有驗證結果變化時
}

FormGroup 的資料套用與清除

FormGroup 的 setValue、patchValue、reset 是用來更新表單的值的方法。這些方法都可以用於表單控件的值設置,例如 FormControl、FormArray 等。需注意的是,使用這些方法時,傳遞的值必須與表單結構相符,否則會出現錯誤。

  • setValue
    設置整個 FormGroup 的值。使用方式為 formGroup.setValue(value, options),其中 value 為要設置的值,options 為可選項目,如 emitEvent 用於決定是否觸發值更改事件。
  • patchValue
    設置部分 FormGroup 的值。使用方式為 formGroup.patchValue(value, options),其中 value 為要設置的部分值,options 同樣為可選項目。
  • reset
    重置 FormGroup 的值為初始值。使用方式為 formGroup.reset(value, options),其中 value 為要重置的值,若未指定則使用 FormGroup 建立時的初始值,options 同樣為可選項目。

在 FormGroup 模組底下,提供了 setValue 能複寫整個資料,這用於 onInit 階段進行整個初始化欄位預設值很有用。或者想局部複寫更新也可以,透過 patchValue 使用。可跟 setValue 並存無所謂只是刷兩次

app.component.ts
ngOnInit() {
this.myForm = new FormGroup({
'userData': new FormGroup({
'username': new FormControl(null, [Validators.required, this.checkLuckName.bind(this)]),
'email': new FormControl(null, [Validators.required, Validators.email], <AsyncValidatorFn>this.checkLuckMailAsync),
}),
'gender': new FormControl('male'),
'likes': new FormArray([])
});

this.myForm.valueChanges.subscribe(val => console.log(val));
this.myForm.statusChanges.subscribe(val => console.log(val));

this.myForm.setValue({ //※重點:全部套入
userData: {
username: 'July',
email: 'cc@cc'
},
gender: 'female',
likes: []
});
this.myForm.patchValue({ //※重點:部分套入
userData: {
username: 'Marry',
email: 'dd@cc'
}
});
}

最後講到 reset 部分,我們可以放到 submit 階段作業。

app.component.ts
onSubmit() {
console.log(this.myForm.get('userData.username')?.errors);
this.myForm.reset(); //※重點:reset
}

Pipes 管道

pipe 是一種轉換值的工具,類似於 JavaScript 中的 Array.map() 或 Array.filter()。管道可以接受任何數量的輸入值,對它們進行處理,並返回新值。使用管道可以使代碼更加乾淨和易於維護,而不需要寫大量的邏輯來處理數據。Angular 內置了許多常用的管道,例如 date、currency、uppercase、lowercase 等。除了內置的管道外,還可以創建自己的自定義管道。

管道可以像以下這樣使用:

<!-- 使用内置管道 -->
<p>Today is {{ today | date }}</p>

<!-- 使用自定義管道 -->
<p>The value {{ value }} squared is {{ value | square }}</p>

自定義 pipe 的方法:

import { Pipe, PipeTransform } from '@angular/core';

@Pipe({
name: 'square'
})
export class SquarePipe implements PipeTransform {
transform(value: number): number {
return value * value;
}
}

這是一個非常簡單的自定義管道,它將傳入的數字乘以自己。在模板中使用它就像使用內置管道一樣簡單。本節將介紹一些內建管道以及如何自訂管道。

示範與前置準備

本篇的起始素材放置於 GitHub 底下提供使用,使用資料目錄 lokiPipe 作為初始環境。下載請記得 npm install 初始化環境。

Github download at lokiPipe-start Folder

Pipes 的內建基礎方法

管道(pipes)是一個非常重要的概念,用於對數據進行轉換和處理。它們可以接受任何類型的輸入並返回顯示在模板中的輸出。以下是一些管道的內建方法:

  • async:用於處理 Observable 和 Promise 的異步數據。當數據返回時,它會自動更新模板中的值。
  • uppercase:將字符串轉換為大寫。
  • lowercase:將字符串轉換為小寫。
  • currency:格式化貨幣數字,可以指定貨幣符號、小數點位數等等。
  • date:將日期格式化為不同的字符串形式,可以指定日期格式。
  • decimal:格式化數字為小數形式,可以指定小數點位數。
  • json:將對象轉換為 JSON 字符串。
  • slice:提取字符串的子字符串,可以指定起始位置和結束位置。
  • percent:將數字轉換為百分比形式,可以指定小數點位數。
  • titlecase:將字符串中每個單詞的第一個字母轉換為大寫。
  • async:用於處理 Observable 和 Promise 的異步數據。當數據返回時,它會自動更新模板中的值。

這些內建的管道可以用於模板中的任何地方。如果需要自定義的管道,也可以使用 @Pipe 裝飾器來創建。

  • 素材內可看到提供一些 server 陣列並透過迴圈方式輸出到畫面上。
  • 已設計一組方法作為提供 ngClass 的屬性綁定的結果。ngClass 能提供物件資料內含 Boolean 值,ngClass 會根據哪個為 true 來提供該 class 名稱。因此方法內會提供全部 class 名稱並搭配 boolean 來要求 ngClass 自己判斷哪個 class 要添加到 CSS。

uppercase

現在我們想嘗試對 instanceType 所顯示的資料轉成大寫,你只要到範本上使用 Pipe 內建的 uppercase 就能達到,你不需要去在 TS 內規畫任何屬性本身變化或改變任何值。

app.component.html
<strong>{{ server.name }}</strong> | {{ server.instanceType | uppercase }} | {{ server.started }}

date

started 所顯示的資料沒有處理過,因此日期格式為new Date()預設 ISO 時間表示法。目前{{ server.started }}輸出如下:

Mon Aug 09 1920 00:00:00 GMT+0800 (台北標準時間)

而我們能透過 pipe 的 data 來改善,調整{{ server.started | data}}後輸出如下:

Aug 9, 1920

而事實上這樣的需求還沒滿足到,因此 pipe 是能提供參數的,透過:來指定參數獲得字串值使用。現在調整{{ server.started | date:'fullDate'}}後輸出如下:

如果該 pipe 能提供多個參數則是類似這樣的語法來設定{{yourString | pipeName:'arg1':'arg2}}

Monday, August 9, 1920

其他 pipe

你可以照訪 官方 API 參考文件 - pipe 部份,裡面會有所有 pipe 介紹,包含我們剛試玩過的 UpperCasePipe 與 DatePipe。其中 DatePipe 可以去了解參數根據國情不同有更適合的時間顯示方式。 例如我們可以改成{{ server.started | date:'西元 yyyy 年 MM 月 dd 日 HH:mm'}}如下結果:

西元 1920 年 08 月 09 日 00:00

這裡簡述一些基本 pipe 可自行前往了解。

  • DatePipe :
  • UperCase - 有英文的部分全部轉換成大寫
  • LowerCase - 有英文的部分全部轉換成小寫
  • DecimalPipe - 數字單位關於小數點的捨進於第幾位
  • Currency - 貨幣格式,雷同 DecimalPipe
  • PercentPipe - 百分比格式
  • JsonPipe - 可將任意值轉 JSON,例如字串、數字、物件
  • SlicePipe - 把某個物件、集合切割出其中的某一塊資料出來,同 Array.Slice 觀念

複數 pipe 疊加與順序

你可以對一字串插值套用兩組 pipe 但有從左至右的解讀順序執行問題,舉例時間部份想套完整日期文字並轉大寫。那勢必是時間的物件先跑日期轉文字再跑文字轉大寫,如果反過來會造成失敗,因為時間物件不是字串,轉大寫 pipe 無法處理。標準順序 {{ server.started | date:'fullDate' | uppercase}}為:

MONDAY, AUGUST 9, 1920

建立自訂 pipe

這裡會教你如何創建自己需要的 pipe,例如我們需要一個可自動判斷文字超過 15 個字會自動切掉的自訂管道。跟著以下步驟學習:

  • 首先需創建管道的文件short-text.pipe.ts,或依賴 CLI 完成ng g p short-text
  • 來到 short-text 管道這裡會使用到 PipeTransform 作為實體化界面提供我們 transform 轉換的方法
  • 使用 transform 來獲得什麼進行處理再進行回傳,如果 CLI 完成的這裡已經準備好了,只是我們要使用 string 不適合 unknown。
  • 我們試著設計拿到字串後透過 substring 只回傳前 10 個字段。
  • 記得去 app.module 的 declarations 註冊這個自訂管道 ShortTextPipe,如果 CLI 完成這裡已經準備好了
  • 以及注意要宣告 pipe 名稱透過@pipe()指定名稱叫 shortText,如果 CLI 完成的這裡已經準備好了。
short-text.pipe.ts
import { Pipe, PipeTransform } from '@angular/core';

@Pipe({
name: 'shortText'
})
export class ShortTextPipe implements PipeTransform {

transform(value: string, ...args: unknown[]): unknown {
if (value.length > 15) return value.substring(0, 15) + '...';
return value;
}
}
app.module.ts
//...
import { AppComponent } from './app.component';
import { ShortTextPipe } from './short-text.pipe'; //※重點

@NgModule({
declarations: [
AppComponent,
ShortTextPipe //※重點
],
imports: [
BrowserModule,
FormsModule,
],
providers: [],
bootstrap: [AppComponent]
})
export class AppModule { }

現在你能在範本上使用{{ server.name | shortText }}獲得以下效果:

stableProduction Serv... | MEDIUM | MONDAY, AUGUST 9, 1920
stableUser Database | LARGE | MONDAY, AUGUST 9, 1920
offlineDevelopment Ser... | SMALL | MONDAY, AUGUST 9, 1920
stableTesting Environ... | SMALL | MONDAY, AUGUST 9, 1920

設計管道參數

在 transform 函式內第一個參數為要處理的資料,第二個以上參數開始為本身 pipe 的參數提供。我們能借用參數從範本來控制侷限字數為多少。

app.component.ts
import { Pipe, PipeTransform } from '@angular/core';

@Pipe({
name: 'shortText'
})
export class ShortTextPipe implements PipeTransform {

transform(value: string, ...args: number[]): string {
if (value.length > args[0]) return value.substring(0, args[0]) + '...';
return value;
}
}

如此一來能從範本來控制字數,舉例{{ server.name | shortText:5 }}的完整效果如下:

app.component.html
stableProdu... | MEDIUM | MONDAY, AUGUST 9, 1920
stableUser ... | LARGE | MONDAY, AUGUST 9, 1920
offlineDevel... | SMALL | MONDAY, AUGUST 9, 1920
stableTesti... | SMALL | MONDAY, AUGUST 9, 1920

利用 pipe 做到篩選

pipe 除了能在字串插值時做轉換,也能在 ngFor 這樣的屬性命令上做出轉換處理。舉例來說 li 這樣的透過 ngFor 來達到批次輸出,如果部份項目不想輸出,你沒辦法在同 ngFor 層上添加 ngIf 控制是否輸出,換成程式觀念 if 條件一定是在 for 底下進入後才判斷。所以會用到兩層去且會產生空的 li 元素,像這樣:

app,component.html
<li
class="list-group-item"
*ngFor="let server of servers"
[ngClass]="getStatusClasses(server)"
>
<div *ngIf="server.status==='stable'">
<span class="badge rounded-pill bg-secondary float-end">
{{ server.status }}
</span>
<strong>{{ server.name | shortText:5 }}</strong> | {{ server.instanceType| uppercase }} | {{
server.started |
date:'fullDate' | uppercase}}
</div>
</li>

這樣會產生多餘空白 li,因此你可以使用 ng-container 模擬假的元素層給 ngFor 使用。這樣是由 ngIf 來決定 li 的出現。

app.component.html
<ng-container *ngFor="let server of servers">
<li
*ngIf="server.status==='stable'"
class="list-group-item"
[ngClass]="getStatusClasses(server)"
>
<span class="badge rounded-pill bg-secondary float-end">
{{ server.status }}
</span>
<strong>{{ server.name | shortText:5 }}</strong> | {{ server.instanceType | uppercase }} | {{
server.started |
date:'fullDate' | uppercase}}
</li>
</ng-container>

以上是 ngFor 跟 ngIf 搭配的正確做法,但還有另一個做法就是不靠 ngIf 來決定,而是 ngFor 搭配 pipe 且只需一層真實元素就能搞定,原理為 ngFor 當下要輸出的內容<li>...</li>會轉換給 pipe 執行再輸出,因此我們只要在 pipe 階段去考慮不會原汁原味提供還是給個空談。這裡會順便把功能做更好:

  • 規劃 input 欄位 text,並提供 HTML5 新功能 lists 供預設選擇,並使用 ngModel 雙向綁定將此欄位的 value 同步到 TS 去。
  • 來到 TS 這裡配合產生此屬性初始為空字串。
  • 回到範本,我們要在 ngFor 當下的動作多一個管道作業,因此位於 let…of… 這裡後續多管道動作,暫定送交給自訂管道 filterString,同時有兩參數一個是 input 的 value 等價 filterStatus 本地屬性,另一個為純文字’status’只單純不想寫死在 TS 沒有彈性。
  • 自訂管道使用 CLI 指令ng g p filter-string獲得相關檔案並環境完成。
  • 規劃 FilterStringPipe 設計,主要是如果沒東西進來或是沒輸入字串就退回去;批次觀看 li 資料如果 status 值是我們要的收集起來,最後還回去。
app.component.html
<div class="container">
<div class="row">
<div class="col">
<input
type="text"
[(ngModel)]="filterStatus"
list="options"
><!-- ※重點:ngModel 雙向綁定 -->
<datalist id="options">
<option value="stable">stable</option>
<option value="offline">offline</option>
</datalist>
<hr>
<ul class="list-group">
<li
class="list-group-item"
*ngFor="let server of servers | filterString:filterStatus:'status'"
[ngClass]="getStatusClasses(server)"
><!-- ※重點:將本地屬性 filterStatus 作為第一組參數給自訂 pipe, -->
<span class="badge rounded-pill bg-secondary float-end">
{{ server.status }}
</span>
<strong>{{ server.name | shortText:5 }}</strong> | {{ server.instanceType| uppercase }} | {{ server.started | date:'fullDate' | uppercase}}
</li>
</ul>
</div>
</div>
</div>
app.component.ts
filterStatus = '';  //※重點:綁定屬性
filter-string.pipe.ts
import { Pipe, PipeTransform } from '@angular/core';

@Pipe({
name: 'filterString'
})
export class FilterStringPipe implements PipeTransform {
transform(lists: any, ...args: unknown[]): unknown {
// console.log(args[0]);
if (!lists.length || args[0] === '') return lists; //※重點: lists 數量為 0 或未輸入 input 為空字串,則直接不過濾

const resultAry = [];
for (const item of lists) {
if (item.status === args[0]) resultAry.push(item);
}
return resultAry;
}
}

潛在問題與排除

看似完美有個效能問題要討論先,我們試著先設計一按鈕能夠自動添加 server 資料到列表之中。

  • 範本上規劃事件綁定能對 TS 要求插入一筆假 server
  • TS 部份宣告一方法能幫我們塞一組到原本的 servers 屬性內
app.component.html
<button (click)="addFakeServer()">Add Test Server</button>
app.component.ts
addFakeServer() {
this.servers.push({
instanceType: 'medium',
name: 'Test Server',
status: 'stable',
started: new Date(15, 1, 2017)
});
}

縣在試著在各時間可能下操作新增按鈕,可發現一些問題所在。原因在於每當事後新資料增加時,Angular 不會重新對 pipe 要求重新運行重載,只有在一開始初始資料的送交給 pipe 這些項目有被修改才會重新觸發 pipe,這是為了效能上的調節。如果你希望犧牲效能能夠被徵測到,可以透過調整設定來回歸都要被徵測到。回到 pipe 檔案設定 pure 為 false。

short-text.pipe.ts
@Pipe({
name: 'shortText',
pure: false //※重點:強制每次檢查,但影響效能
})

async pipe 非同步管道

現在討論管道對於非同步作業下的幫助。假設元件規劃一屬性為 appStatus 假裝經過後端而非同步結果所獲得的結果字串值 stable!!(這裡用 promise 進行 timeout 2 秒)。然後將這個屬性透過字串插值方式放入到範本上:

app.component.ts
export class AppComponent {
appStatus = new Promise((res, rej) => {
setTimeout(() => {
res('stable!!')
}, 2000);
});
//...
}
app.component.html
<h1>Server is {{appStatus}}</h1>

現在畫面上出現的為Server is [object Promise]且兩秒後也不會更新,因為我們現在這個屬性的值是一個 promise 的產物,過去你可能需要先創立一個空字串,在讓 promise 結果去複寫空字串。現在可以透過 pipe 直接幫我們轉換非同步的產物並直接提取。使用{{appStatus | async}}調整。async 可以對 promise 或 observables 使用,pipe 會意識到這是一個非同步產物,會等待並試著將結果轉出。

app.component.html
<h1>Server is {{appStatus | async}}</h1>

現在是從 Server is 然後等兩秒變 Server is stable!!

參考文獻