angular-forms

angular-forms

熱門

使用 Angular v21+ 的新 Signal Forms API 建立基於信號的表單。適用於建立具有自動雙向綁定、基於結構的驗證、欄位狀態管理和動態表單的情境。觸發時機:實作表單、加入驗證、建立多步驟表單,或建立含有條件欄位的表單。Signal Forms 為實驗性功能,但建議用於新的 Angular 專案。請勿用於不使用信號的模板驅動表單,或第三方表單函式庫如 Formly 或 ngx-formly。

596星標
70分支
更新於 2026/3/23
SKILL.md
readonlyread-only
name
angular-forms
description

使用 Angular v21+ 的新 Signal Forms API 建立基於信號的表單。適用於建立具有自動雙向綁定、基於結構的驗證、欄位狀態管理和動態表單的情境。觸發時機:實作表單、加入驗證、建立多步驟表單,或建立含有條件欄位的表單。Signal Forms 為實驗性功能,但建議用於新的 Angular 專案。請勿用於不使用信號的模板驅動表單,或第三方表單函式庫如 Formly 或 ngx-formly。

Angular Signal Forms

使用 Angular 的 Signal Forms API 建立型別安全、反應式的表單。Signal Forms 提供自動雙向綁定、基於結構的驗證以及反應式欄位狀態。

注意: Signal Forms 在 Angular v21 中為實驗性功能。若需穩定性的正式環境應用,請參閱 references/form-patterns.md 了解 Reactive Forms 模式。

基本設定

import { Component, signal } from '@angular/core';
import { form, FormField, required, email } from '@angular/forms/signals';

interface LoginData {
  email: string;
  password: string;
}

@Component({
  selector: 'app-login',
  imports: [FormField],
  template: `
    <form (submit)="onSubmit($event)">
      <label>
        Email
        <input type="email" [formField]="loginForm.email" />
      </label>
      @if (loginForm.email().touched() && loginForm.email().invalid()) {
        <p class="error">{{ loginForm.email().errors()[0].message }}</p>
      }
      
      <label>
        Password
        <input type="password" [formField]="loginForm.password" />
      </label>
      @if (loginForm.password().touched() && loginForm.password().invalid()) {
        <p class="error">{{ loginForm.password().errors()[0].message }}</p>
      }
      
      <button type="submit" [disabled]="loginForm().invalid()">Login</button>
    </form>
  `,
})
export class Login {
  // 表單模型 - 一個可寫入的信號
  loginModel = signal<LoginData>({
    email: '',
    password: '',
  });
  
  // 建立帶有驗證結構的表單
  loginForm = form(this.loginModel, (schemaPath) => {
    required(schemaPath.email, { message: 'Email 為必填' });
    email(schemaPath.email, { message: '請輸入有效的 Email 地址' });
    required(schemaPath.password, { message: '密碼為必填' });
  });
  
  onSubmit(event: Event) {
    event.preventDefault();
    if (this.loginForm().valid()) {
      const credentials = this.loginModel();
      console.log('送出:', credentials);
    }
  }
}

表單模型

表單模型是可寫入的信號,作為單一事實來源:

// 定義介面以確保型別安全
interface UserProfile {
  name: string;
  email: string;
  age: number | null;
  preferences: {
    newsletter: boolean;
    theme: 'light' | 'dark';
  };
}

// 建立帶有初始值的模型信號
const userModel = signal<UserProfile>({
  name: '',
  email: '',
  age: null,
  preferences: {
    newsletter: false,
    theme: 'light',
  },
});

// 從模型建立表單
const userForm = form(userModel);

// 使用點記法存取巢狀欄位
userForm.name                    // FieldTree<string>
userForm.preferences.theme       // FieldTree<'light' | 'dark'>

讀取值

// 讀取整個模型
const data = this.userModel();

// 透過欄位狀態讀取欄位值
const name = this.userForm.name().value();
const theme = this.userForm.preferences.theme().value();

更新值

// 取代整個模型
this.userModel.set({
  name: 'Alice',
  email: 'alice@example.com',
  age: 30,
  preferences: { newsletter: true, theme: 'dark' },
});

// 更新單一欄位
this.userForm.name().value.set('Bob');
this.userForm.age().value.update(age => (age ?? 0) + 1);

欄位狀態

每個欄位提供反應式信號,用於驗證、互動和可用性:

const emailField = this.form.email();

// 驗證狀態
emailField.valid()      // 若通過所有驗證則為 true
emailField.invalid()    // 若有驗證錯誤則為 true
emailField.errors()     // 錯誤物件陣列
emailField.pending()    // 若非同步驗證進行中則為 true

// 互動狀態
emailField.touched()    // 聚焦後失焦則為 true
emailField.dirty()      // 使用者修改後則為 true

// 可用性狀態
emailField.disabled()   // 若欄位被停用則為 true
emailField.hidden()     // 若欄位應隱藏則為 true
emailField.readonly()   // 若欄位為唯讀則為 true

// 值
emailField.value()      // 目前欄位值(信號)

表單層級狀態

表單本身也是一個欄位,具有彙總狀態:

// 當所有互動欄位都有效時,表單有效
this.form().valid()

// 當任一欄位被觸碰時,表單被觸碰
this.form().touched()

// 當任一欄位被修改時,表單被修改
this.form().dirty()

驗證

內建驗證器

import { 
  form, required, email, min, max, 
  minLength, maxLength, pattern 
} from '@angular/forms/signals';

const userForm = form(this.userModel, (schemaPath) => {
  // 必填欄位
  required(schemaPath.name, { message: '名稱為必填' });
  
  // Email 格式
  email(schemaPath.email, { message: '無效的 Email' });
  
  // 數值範圍
  min(schemaPath.age, 18, { message: '必須年滿 18 歲' });
  max(schemaPath.age, 120, { message: '無效的年齡' });
  
  // 字串/陣列長度
  minLength(schemaPath.password, 8, { message: '最少 8 個字元' });
  maxLength(schemaPath.bio, 500, { message: '最多 500 個字元' });
  
  // 正則表達式模式
  pattern(schemaPath.phone, /^\d{3}-\d{3}-\d{4}$/, {
    message: '格式:555-123-4567',
  });
});

條件驗證

const orderForm = form(this.orderModel, (schemaPath) => {
  required(schemaPath.promoCode, {
    message: '折扣需要優惠碼',
    when: ({ valueOf }) => valueOf(schemaPath.applyDiscount),
  });
});

自訂驗證器

import { validate } from '@angular/forms/signals';

const signupForm = form(this.signupModel, (schemaPath) => {
  // 自訂驗證邏輯
  validate(schemaPath.username, ({ value }) => {
    if (value().includes(' ')) {
      return { kind: 'noSpaces', message: '使用者名稱不能包含空格' };
    }
    return null;
  });
});

跨欄位驗證

const passwordForm = form(this.passwordModel, (schemaPath) => {
  required(schemaPath.password);
  required(schemaPath.confirmPassword);
  
  // 比較欄位
  validate(schemaPath.confirmPassword, ({ value, valueOf }) => {
    if (value() !== valueOf(schemaPath.password)) {
      return { kind: 'mismatch', message: '密碼不相符' };
    }
    return null;
  });
});

非同步驗證

import { validateHttp } from '@angular/forms/signals';

const signupForm = form(this.signupModel, (schemaPath) => {
  validateHttp(schemaPath.username, {
    request: ({ value }) => `/api/check-username?u=${value()}`,
    onSuccess: (response: { taken: boolean }) => {
      if (response.taken) {
        return { kind: 'taken', message: '使用者名稱已被使用' };
      }
      return null;
    },
    onError: () => ({
      kind: 'networkError',
      message: '無法驗證使用者名稱',
    }),
  });
});

條件欄位

隱藏欄位

import { hidden } from '@angular/forms/signals';

const profileForm = form(this.profileModel, (schemaPath) => {
  hidden(schemaPath.publicUrl, ({ valueOf }) => !valueOf(schemaPath.isPublic));
});
@if (!profileForm.publicUrl().hidden()) {
  <input [formField]="profileForm.publicUrl" />
}

停用欄位

import { disabled } from '@angular/forms/signals';

const orderForm = form(this.orderModel, (schemaPath) => {
  disabled(schemaPath.couponCode, ({ valueOf }) => valueOf(schemaPath.total) < 50);
});

唯讀欄位

import { readonly } from '@angular/forms/signals';

const accountForm = form(this.accountModel, (schemaPath) => {
  readonly(schemaPath.username); // 永遠唯讀
});

表單提交

import { submit } from '@angular/forms/signals';

@Component({
  template: `
    <form (submit)="onSubmit($event)">
      <input [formField]="form.email" />
      <input [formField]="form.password" />
      <button type="submit" [disabled]="form().invalid()">Submit</button>
    </form>
  `,
})
export class Login {
  model = signal({ email: '', password: '' });
  form = form(this.model, (schemaPath) => {
    required(schemaPath.email);
    required(schemaPath.password);
  });
  
  onSubmit(event: Event) {
    event.preventDefault();
    
    // submit() 會標記所有欄位為已觸碰,並在有效時執行回呼
    submit(this.form, async () => {
      await this.authService.login(this.model());
    });
  }
}

陣列與動態欄位

interface Order {
  items: Array<{ product: string; quantity: number }>;
}

@Component({
  template: `
    @for (item of orderForm.items; track $index; let i = $index) {
      <div>
        <input [formField]="item.product" placeholder="Product" />
        <input [formField]="item.quantity" type="number" />
        <button type="button" (click)="removeItem(i)">Remove</button>
      </div>
    }
    <button type="button" (click)="addItem()">Add Item</button>
  `,
})
export class Order {
  orderModel = signal<Order>({
    items: [{ product: '', quantity: 1 }],
  });
  
  orderForm = form(this.orderModel, (schemaPath) => {
    applyEach(schemaPath.items, (item) => {
      required(item.product, { message: '產品為必填' });
      min(item.quantity, 1, { message: '最小數量為 1' });
    });
  });
  
  addItem() {
    this.orderModel.update(m => ({
      ...m,
      items: [...m.items, { product: '', quantity: 1 }],
    }));
  }
  
  removeItem(index: number) {
    this.orderModel.update(m => ({
      ...m,
      items: m.items.filter((_, i) => i !== index),
    }));
  }
}

顯示錯誤

<input [formField]="form.email" />

@if (form.email().touched() && form.email().invalid()) {
  <ul class="errors">
    @for (error of form.email().errors(); track error) {
      <li>{{ error.message }}</li>
    }
  </ul>
}

@if (form.email().pending()) {
  <span>驗證中...</span>
}

根據狀態設定樣式

<input
  [formField]="form.email"
  [class.is-invalid]="form.email().touched() && form.email().invalid()"
  [class.is-valid]="form.email().touched() && form.email().valid()"
/>

重設表單

async onSubmit() {
  if (!this.form().valid()) return;
  
  await this.api.submit(this.model());
  
  // 清除互動狀態
  this.form().reset();
  
  // 清除值
  this.model.set({ email: '', password: '' });
}

如需 Reactive Forms 模式(正式環境穩定),請參閱 references/form-patterns.md