📝 Angular 表單:Template-driven vs Reactive Forms
📌 兩種表單策略
Angular 提供兩種建立表單的方式:
| 特性 | Template-driven | Reactive Forms |
|---|---|---|
| 定義位置 | HTML 模板中 | TypeScript 類別中 |
| 資料模型 | 隱式(由模板建立) | 顯式(FormGroup / FormControl) |
| 驗證 | 用模板指令 | 用 TypeScript 函式 |
| 適合場景 | 簡單表單 | 複雜表單(推薦) |
| 可測試性 | 較差 | 優秀 |
📌 模板驅動表單(Template-driven)
// 需要匯入 FormsModule
import { Component } from '@angular/core';
import { FormsModule } from '@angular/forms';
interface UserForm {
name: string;
email: string;
age: number;
}
@Component({
selector: 'app-simple-form',
standalone: true,
imports: [FormsModule],
template: `
<form #myForm="ngForm" (ngSubmit)="onSubmit(myForm)">
<div>
<label>姓名:</label>
<input name="name" [(ngModel)]="user.name" required minlength="2" #nameInput="ngModel">
<div *ngIf="nameInput.invalid && nameInput.touched" class="error">
<span *ngIf="nameInput.errors?.['required']">姓名必填</span>
<span *ngIf="nameInput.errors?.['minlength']">至少 2 個字</span>
</div>
</div>
<div>
<label>Email:</label>
<input name="email" [(ngModel)]="user.email" required email #emailInput="ngModel">
<div *ngIf="emailInput.invalid && emailInput.touched" class="error">
Email 格式不正確
</div>
</div>
<button type="submit" [disabled]="myForm.invalid">送出</button>
</form>
`
})
export class SimpleFormComponent {
user: UserForm = { name: '', email: '', age: 0 };
onSubmit(form: any) {
if (form.valid) {
console.log('表單資料:', this.user);
}
}
}
📌 響應式表單(Reactive Forms)—— 推薦
// register.component.ts
import { Component, OnInit } from '@angular/core';
import { FormBuilder, FormGroup, FormArray, Validators, ReactiveFormsModule } from '@angular/forms';
import { CommonModule } from '@angular/common';
@Component({
selector: 'app-register',
standalone: true,
imports: [ReactiveFormsModule, CommonModule],
template: `
<form [formGroup]="registerForm" (ngSubmit)="onSubmit()">
<div>
<label>使用者名稱:</label>
<input formControlName="username">
<div *ngIf="f['username'].invalid && f['username'].touched" class="error">
<span *ngIf="f['username'].errors?.['required']">必填</span>
<span *ngIf="f['username'].errors?.['minlength']">至少 3 個字</span>
</div>
</div>
<div>
<label>Email:</label>
<input formControlName="email">
</div>
<div formGroupName="password">
<div>
<label>密碼:</label>
<input type="password" formControlName="pwd">
</div>
<div>
<label>確認密碼:</label>
<input type="password" formControlName="confirmPwd">
</div>
<div *ngIf="registerForm.get('password')?.errors?.['passwordMismatch']" class="error">
兩次密碼不一致
</div>
</div>
<div>
<label>興趣:</label>
<div formArrayName="hobbies">
<div *ngFor="let hobby of hobbies.controls; let i = index">
<input [formControlName]="i">
<button type="button" (click)="removeHobby(i)">✕</button>
</div>
</div>
<button type="button" (click)="addHobby()">+ 新增興趣</button>
</div>
<button type="submit" [disabled]="registerForm.invalid">註冊</button>
</form>
<pre>表單值:{{ registerForm.value | json }}</pre>
<pre>表單狀態:{{ registerForm.status }}</pre>
`
})
export class RegisterComponent implements OnInit {
registerForm!: FormGroup;
constructor(private fb: FormBuilder) { }
ngOnInit() {
this.registerForm = this.fb.group({
username: ['', [Validators.required, Validators.minLength(3)]],
email: ['', [Validators.required, Validators.email]],
password: this.fb.group({
pwd: ['', [Validators.required, Validators.minLength(8)]],
confirmPwd: ['', Validators.required]
}, { validators: this.passwordMatchValidator }),
hobbies: this.fb.array([]) // 動態陣列
});
}
// 便利 getter
get f() { return this.registerForm.controls; }
get hobbies() { return this.registerForm.get('hobbies') as FormArray; }
// 自訂驗證器:密碼比對
passwordMatchValidator(group: FormGroup): { [key: string]: boolean } | null {
const pwd = group.get('pwd')?.value;
const confirmPwd = group.get('confirmPwd')?.value;
return pwd === confirmPwd ? null : { passwordMismatch: true };
}
addHobby() {
this.hobbies.push(this.fb.control(''));
}
removeHobby(index: number) {
this.hobbies.removeAt(index);
}
onSubmit() {
if (this.registerForm.valid) {
console.log('送出表單:', this.registerForm.value);
} else {
// 標記所有欄位為 touched,觸發錯誤顯示
this.registerForm.markAllAsTouched();
}
}
}
📌 FormControl、FormGroup、FormArray
import { FormControl, FormGroup, FormArray } from '@angular/forms';
// FormControl — 單一表單欄位
const name = new FormControl('預設值');
console.log(name.value); // '預設值'
console.log(name.valid); // true
console.log(name.touched); // false
// FormGroup — 一組表單欄位
const form = new FormGroup({
firstName: new FormControl(''),
lastName: new FormControl('')
});
console.log(form.value); // { firstName: '', lastName: '' }
// FormArray — 動態數量的表單欄位(如多個電話號碼)
const phones = new FormArray([
new FormControl('0912-345-678'),
new FormControl('02-1234-5678')
]);
phones.push(new FormControl('新號碼')); // 動態新增
phones.removeAt(0); // 動態移除
📌 自訂驗證器
import { AbstractControl, ValidationErrors, ValidatorFn } from '@angular/forms';
// 同步驗證器 — 檢查是否包含禁止字詞
export function forbiddenNameValidator(forbidden: RegExp): ValidatorFn {
return (control: AbstractControl): ValidationErrors | null => {
const isForbidden = forbidden.test(control.value);
return isForbidden ? { forbiddenName: { value: control.value } } : null;
};
}
// 非同步驗證器 — 檢查使用者名稱是否已被使用
export function uniqueUsernameValidator(
userService: UserService
): AsyncValidatorFn {
return (control: AbstractControl): Observable<ValidationErrors | null> => {
return userService.checkUsername(control.value).pipe(
map(isTaken => isTaken ? { usernameTaken: true } : null)
);
};
}
// 使用自訂驗證器
this.fb.group({
username: ['', [Validators.required, forbiddenNameValidator(/admin/i)]],
});
📌 小結
- Template-driven 適合簡單表單,Reactive Forms 適合複雜表單
- FormControl = 單一欄位、FormGroup = 欄位群組、FormArray = 動態欄位
- Reactive Forms 在 TypeScript 中定義,可測試性高
- 驗證器分為同步和非同步,可自訂驗證邏輯
markAllAsTouched()可以在送出時觸發所有驗證訊息顯示