在上一篇(篇三),我們透過購物車、報名表,甚至戲院座位表的案例,體驗了 FormArray 在動態集合中的效果。
不過,實際專案往往更複雜:
👉 這時候,就需要 跨元件巢狀 的設計,把大表單切分成多個小表單元件,各自專注一塊,再由父元件統一組合。
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>
參考網址:Day24
操作完後可直接觀察form的值跟結構。
一個報名表單包含:
其中姓名、生日、信用卡號、有效期限為必填,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 {
}
大表單若全部塞在一個元件,維護上會比較困難。雖然也可以利用 input 控制子元件,或是不在父層定義表單名稱,直接在子元件寫上對應的 formGroupName,並把 useExisting 改成利用 FormGroupDirective 的方式也都可以拆成子元件,但目前覺得彈性最好的方式還是剛剛內文介紹的方式,看起來最乾淨、可讀性最高。
👉 下一篇(篇五),我們將探討 動態表單 (Schema-driven Forms),讓表單不需要手動定義,而是根據一份 Schema 自動生成。
https://github.com/angular/angular/pull/63408
Angular 團隊正在開發 signal form prototype。我還沒看過,但值得一看。
喔!我也來看看
我們一起學習吧。