iT邦幫忙

2025 iThome 鐵人賽

DAY 24
1
Modern Web

Angular 進階實務 30天系列 第 24

Day 24:Angular Reactive Forms – 跨元件巢狀與大表單拆分

  • 分享至 

  • xImage
  •  

[UI畫面操作,網站演示要出現Form.value]

前言:從動態集合到大型表單

在上一篇(篇三),我們透過購物車、報名表,甚至戲院座位表的案例,體驗了 FormArray 在動態集合中的效果

不過,實際專案往往更複雜:

  • 一個大型報名系統,可能包含「基本資料」「聯絡資訊」「付款資訊」等不同區塊。
  • 如果全部塞在一個元件裡,不僅程式碼龐大難維護,也會讓驗證與錯誤提示變得混亂。

👉 這時候,就需要 跨元件巢狀 的設計,把大表單切分成多個小表單元件,各自專注一塊,再由父元件統一組合。


1. 為什麼要拆分大表單?

問題點(不拆分的情況)

  • 所有欄位都寫在同一個 Component,看的迷失自我 → 上百行模板
  • 驗證邏輯全塞在同一個 FormGroup ,看得眼花撩亂→ 邏輯難以追蹤
  • 需要多人協作時,容易產生 Git 衝突。

拆分的好處

  • 模組化:每個子元件只管理自己的區塊。
  • 可重用:同樣的表單區塊(例如地址)可以放在不同場景中。
  • 可測試:單獨測試某個子表單的驗證,不必跑整個大表單。

2. 拆分的方式

ControlContainer 注入父群組

  • 繼承父表單的控制容器。
  • 適合深層巢狀、多層表單。

程式碼

子元件:

@Component({
  selector: 'app-personal-form',
  template: `
    <div formGroupName="personal">
      <input formControlName="name" placeholder="姓名">
      <input formControlName="birthday" type="date">
    </div>
  `,
  viewProviders: [{ provide: ControlContainer, useExisting: FormGroupDirective }]
})
export class PersonalFormComponent {}

父元件模板:

<form [formGroup]="form">
  <div formGroupName="personal">
    <app-personal-form></app-personal-form>
  </div>
</form>

3. 實務案例:報名表單切分

參考網址:Day24
操作完後可直接觀察form的值跟結構。

需求

一個報名表單包含:

  1. 個人資訊(姓名、生日)
  2. 聯絡方式(Email、手機)
  3. 付款資訊(信用卡號、有效期限)

其中姓名、生日、信用卡號、有效期限為必填,Email、手機為擇一必填。

拆分設計

  • PersonalFormComponent → 管理個人資訊
  • ContactFormComponent → 管理聯絡方式
  • PaymentFormComponent → 管理付款資訊
  • 父元件 RegisterFormComponent → 組合整體

父元件程式碼

// 跳過 import 的東西

type PersonalGroup = FormGroup<{
  name: FormControl<string>;
  birthday: FormControl<string>;
}>;

type ContactGroup = FormGroup<{
  email: FormControl<string | null>;
  phone: FormControl<string | null>;
}>;

type PaymentGroup = FormGroup<{
  cardNumber: FormControl<string>;
  expireDate: FormControl<string>;
}>;

type RootForm = FormGroup<{
  personal: PersonalGroup;
  contact: ContactGroup;
  payment: PaymentGroup;
}>;

@Component({
  selector: 'app-day24',
  standalone: true,
  imports: [
    ReactiveFormsModule,
    CommonModule,
    NzFormModule,
    NzButtonModule,
    NzGridModule,
    NzStepsModule,
    NzResultModule,
    NzSpinModule,
    PersonalFormComponent,
    ContactForm1Component,
    PaymentFormComponent
  ],
  templateUrl: './day24.component.html',
  styleUrl: './day24.component.scss'
})
export class Day24Component {
  step = signal(0);
  form: RootForm;

  private readonly groupOrder: Array<keyof RootForm['controls']> = ['personal', 'contact', 'payment'];

  constructor(private fb: FormBuilder, private msg: NzMessageService) {
    this.form = this.fb.group({
      personal: this.fb.group({
        name: this.fb.nonNullable.control('', [Validators.required, Validators.maxLength(50)]),
        birthday: this.fb.nonNullable.control('', [Validators.required])
      }),
      contact: this.fb.group({
        email: this.fb.control<string | null>('', [Validators.email]),
        phone: this.fb.control<string | null>('', [Validators.pattern(/^09\d{8}$/)])
      }),
      payment: this.fb.group({
        cardNumber: this.fb.nonNullable.control('', [Validators.required, Validators.pattern(/^\d{16}$/)]),
        expireDate: this.fb.nonNullable.control('', [Validators.required])
      })
    });

    // ✅ 把跨群組驗證掛到 root(確保會被觸發)
    this.form.addValidators(this.emailOrPhoneRequired('contact.email', 'contact.phone'));

    // ✅ 依目前步驟先設定啟用/停用(預設啟用 step 0)
    this.updateGroupStates();
  }

  // 父層跨群組驗證:Email 或手機至少一個
  emailOrPhoneRequired(emailPath: string, phonePath: string) {
    return (group: AbstractControl) => {
      const email = group.get(emailPath)?.value?.trim();
      const phone = group.get(phonePath)?.value?.trim();
      const valid = !!email || !!phone;

      const contactGroup = group.get('contact');
      if (!valid) {
        contactGroup?.setErrors({ ...(contactGroup?.errors ?? {}), atLeastOne: true });
      } else if (contactGroup?.errors) {
        const { atLeastOne, ...others } = contactGroup.errors;
        contactGroup.setErrors(Object.keys(others).length ? others : null);
      }
      return valid ? null : { atLeastOne: true };
    };
  }

  // MM/YY 基本格式驗證(原樣)
  mmYYValidator(control: AbstractControl) { /* ...保持不變... */ }

  // ✅ 依目前 step 啟用/停用群組;step === 3 全部停用
  private updateGroupStates(): void {
    const current = this.step();
    this.groupOrder.forEach((name, idx) => {
      const g = this.form.get(name) as FormGroup;
      if (current === 3) {
        g.disable({ emitEvent: false });
      } else if (idx === current) {
        g.enable({ emitEvent: false });
      } else {
        g.disable({ emitEvent: false });
      }
    });
  }

  // ✅ 統一切步驟並更新啟用狀態
  private goToStep(index: number): void {
    this.step.set(index);
    this.updateGroupStates();
  }

  // 換步驟前驗證目前步驟
  next(): void {
    const current = this.step();
    const groupMap: Record<number, string> = { 0: 'personal', 1: 'contact', 2: 'payment' };

    if (current <= 2) {
      const groupName = groupMap[current];
      const g = this.form.get(groupName) as FormGroup;
      g.markAllAsTouched();

      // 觸發所有驗證(含 root 的跨群組驗證)
      this.form.updateValueAndValidity({ onlySelf: false, emitEvent: true });

      if (g.invalid || (current === 1 && this.form.get('contact')?.errors?.['atLeastOne'])) {
        this.msg.error('請先完成本步驟的必填欄位與驗證。');
        return;
      }
    }

    this.goToStep(Math.min(3, current + 1));
  }

  prev(): void {
    this.goToStep(Math.max(0, this.step() - 1));
  }

  submit(): void {
    this.form.markAllAsTouched();
    this.form.updateValueAndValidity();

    if (this.form.invalid) {
      this.msg.error('表單驗證未通過,請檢查各步驟欄位。');
      return;
    }
    console.log('submit payload', this.form.value);
    this.msg.success('送出成功!(請開 console 檢視 payload)');
  }
}

父元件模板

<div nz-row nzJustify="center" class="container" style="padding: 24px;">
  <div nz-col nzSpan="24" nzMd="18" nzLg="12">
    <h2>報名表單(分步驟)</h2>

    <nz-steps [nzCurrent]="step()">
      <nz-step nzTitle="個人資訊"></nz-step>
      <nz-step nzTitle="聯絡方式"></nz-step>
      <nz-step nzTitle="付款資訊"></nz-step>
      <nz-step nzTitle="確認送出"></nz-step>
    </nz-steps>

    <form nz-form [formGroup]="form" style="margin-top: 16px;">
      <!-- Step 0:個人資訊 (子元件內的 formControlName 會依附到「最近的 FormGroupName 容器」 (personal 群組)) -->
      @defer (when step() === 0) {
      <div formGroupName="personal">
        <app-personal-form></app-personal-form>
      </div>
      }

      <!-- Step 1:聯絡方式(Lazy Load) -->
      @defer (when step() === 1) {
        <div formGroupName="contact">
          <app-contact-form>
          </app-contact-form>
        </div>
      }

      <!-- Step 2:付款資訊(Lazy Load) -->
      @defer (when step() === 2) {
        <div formGroupName="payment">
          <app-payment-form></app-payment-form>
        </div>

      }

      <!-- Step 3:確認送出 -->
      @if (step() === 3) {
      <div style="border:1px solid #eee;padding:16px;margin-bottom:16px;border-radius:8px;">
        <h3>確認資料</h3>
        <pre style="white-space: pre-wrap; margin: 0;">{{ form.value | json }}</pre>
      </div>
      }

      <!-- 導覽按鈕 -->
      <div style="display:flex; gap:8px; margin-top: 8px;">
        <button nz-button (click)="prev()" [disabled]="step() === 0">上一步</button>
        <button nz-button nzType="default" *ngIf="step() < 3" (click)="next()">下一步</button>
        <button nz-button nzType="primary" *ngIf="step() === 3" (click)="submit()">送出報名</button>
      </div>
    </form>
  </div>
</div>

子元件

個人資料

@Component({
  selector: 'app-personal-form',
  standalone: true,
  imports: [CommonModule, ReactiveFormsModule, NzFormModule, NzInputModule],
  template: 
  `
  <section style="border:1px solid #eee;padding:16px;margin-bottom:16px;border-radius:8px;">
  <legend>個人資訊</legend>

  <nz-form-item>
    <nz-form-label [nzSpan]="6" nzRequired>姓名</nz-form-label>
    <nz-form-control [nzSpan]="18" [nzErrorTip]="'姓名為必填,且不得超過 50 字。'">
      <input nz-input formControlName="name" placeholder="請輸入姓名" />
    </nz-form-control>
  </nz-form-item>

  <nz-form-item>
    <nz-form-label [nzSpan]="6" nzRequired>生日</nz-form-label>
    <nz-form-control [nzSpan]="18" [nzErrorTip]="'請選擇生日。'">
      <input nz-input type="date" formControlName="birthday" />
    </nz-form-control>
  </nz-form-item>
</section>

  `
  ,
  viewProviders: [{ provide: ControlContainer, useExisting: FormGroupName  }]
})
export class PersonalFormComponent {}

聯絡方式

import { CommonModule } from '@angular/common';
import { Component, Host } from '@angular/core';
import { ControlContainer, FormGroup, FormGroupName, ReactiveFormsModule } from '@angular/forms';
import { NzFormModule } from 'ng-zorro-antd/form';
import { NzInputModule } from 'ng-zorro-antd/input';

@Component({
  selector: 'app-contact-form-1',
  standalone: true,
  imports: [CommonModule, ReactiveFormsModule, NzFormModule, NzInputModule],
  template: 
  `
  <div style="border:1px solid #eee;padding:16px;margin-bottom:16px;border-radius:8px;">
  <legend>聯絡方式</legend>

  <nz-form-item>
    <nz-form-label [nzSpan]="6">Email</nz-form-label>
    <nz-form-control [nzSpan]="18" [nzErrorTip]="'Email 格式不正確。'">
      <input nz-input formControlName="email" placeholder="example@domain.com" />
    </nz-form-control>
  </nz-form-item>

  <nz-form-item>
    <nz-form-label [nzSpan]="6">手機</nz-form-label>
    <nz-form-control [nzSpan]="18" [nzErrorTip]="'手機格式需為 09 開頭的 10 碼數字。'">
      <input nz-input formControlName="phone" placeholder="09xxxxxxxx" />
    </nz-form-control>
  </nz-form-item>
    <div
      *ngIf="showGroupError"
      class="ant-form-item-explain-error"
      style="margin-left: calc(6/24*100%);"
      aria-live="polite">
      請至少填寫 Email 或手機
    </div>
</div>

  `,
  viewProviders: [{ provide: ControlContainer, useExisting: FormGroupName }],
})
export class ContactFormComponent {
  constructor(@Host() private readonly cc: ControlContainer) {}

  private get group(): FormGroup {
    return this.cc.control as FormGroup; // 這就是 contact 群組
  }

  get showGroupError(): boolean {
    const g = this.group;
    // 你也可以改成 g.invalid && (g.touched || g.dirty) 視 UX 而定
    return !!g.errors?.['atLeastOne'] && (g.touched || g.dirty);
  }
}

付款方式

import { CommonModule } from '@angular/common';
import { Component, Input } from '@angular/core';
import { ReactiveFormsModule, FormGroup, FormGroupName, ControlContainer } from '@angular/forms';
import { NzFormModule } from 'ng-zorro-antd/form';
import { NzInputModule } from 'ng-zorro-antd/input';

@Component({
  selector: 'app-payment-form',
  standalone: true,
  imports: [CommonModule, ReactiveFormsModule, NzFormModule, NzInputModule],
  template:
  `
  <div style="border:1px solid #eee;padding:16px;margin-bottom:16px;border-radius:8px;">
  <legend>付款資訊</legend>

  <nz-form-item>
    <nz-form-label [nzSpan]="6" nzRequired>信用卡號</nz-form-label>
    <nz-form-control [nzSpan]="18" [nzErrorTip]="'請填寫 16 碼數字的卡號。'">
      <input nz-input maxlength="16" formControlName="cardNumber" placeholder="16 碼數字,不含空白" />
    </nz-form-control>
  </nz-form-item>

  <nz-form-item>
    <nz-form-label [nzSpan]="6" nzRequired>有效期限</nz-form-label>
    <nz-form-control [nzSpan]="18" [nzErrorTip]="'請輸入正確的 MM/YY。'">
      <input nz-input formControlName="expireDate" placeholder="MM/YY,如 08/28" />
    </nz-form-control>
  </nz-form-item>
</div>
  `,
  viewProviders: [{ provide: ControlContainer, useExisting: FormGroupName }], 

})
export class PaymentFormComponent {
}


4. 驗證與錯誤管理策略

  • 子元件內:顯示驗證
  • 父元件內:設定整個表單模組驗證
  • 特別注意:上下步驟移動的時候,要避免使用者可以改到前一步的資料。這裡是用 disabled 處理,大家可以根據自己使用需求或設計師提供畫面調整,他也可以是 tab 上下頁切換之類的,總之要記得避免沒有驗證到的情況。

小結

大表單若全部塞在一個元件,維護上會比較困難。雖然也可以利用 input 控制子元件,或是不在父層定義表單名稱,直接在子元件寫上對應的 formGroupName,並把 useExisting 改成利用 FormGroupDirective 的方式也都可以拆成子元件,但目前覺得彈性最好的方式還是剛剛內文介紹的方式,看起來最乾淨、可讀性最高。

👉 下一篇(篇五),我們將探討 動態表單 (Schema-driven Forms),讓表單不需要手動定義,而是根據一份 Schema 自動生成。


上一篇
Day 23:Angular Reactive Forms – FormArray 與動態集合
下一篇
Day 25:Angular Reactive Forms – 動態表單 (Schema-driven Forms)
系列文
Angular 進階實務 30天25
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

2 則留言

1
connieleung
iT邦新手 4 級 ‧ 2025-09-07 10:20:19

https://github.com/angular/angular/pull/63408

Angular 團隊正在開發 signal form prototype。我還沒看過,但值得一看。

Zoe Wu iT邦新手 4 級 ‧ 2025-09-07 13:38:11 檢舉

喔!我也來看看

1
connieleung
iT邦新手 4 級 ‧ 2025-09-07 15:40:02

viewprovider 太酷了

Zoe Wu iT邦新手 4 級 ‧ 2025-09-08 08:06:23 檢舉

我也覺得太好用了

我要留言

立即登入留言