iT邦幫忙

2025 iThome 鐵人賽

DAY 25
1
Modern Web

Angular 進階實務 30天系列 第 25

Day 25:Angular Reactive Forms – 動態表單 (Schema-driven Forms)

  • 分享至 

  • xImage
  •  

前言:從拆分表單到自動生成

👉 動態表單 (Schema-driven Forms) 自動生成適用場景

  • 表單欄位不是固定的,而是 後端 API 決定的
  • 使用者需要能夠動態新增題目(例如問卷系統)。
  • 表單的樣式、驗證十分固定。

這個不一定是後台系統就會用到,有時候後台系統為了加速查詢,可能會不同查詢頁加上不同的快速帶入功能,這種類型的表單,硬要用動態表單帶入的會就會有無限的特規,所以使用前請確認清楚,如果使用者在驗證方式跟排版比較多樣化,這個就不是你的最佳解。

像是服務評分、心理測驗或是E學院的測驗就可以考慮使用。

Day25


1. 什麼是動態表單?

  • 不再由開發者手動建立 FormGroup / FormControl。
  • 而是由一份 Schema(通常是 JSON) 來定義:
    • 欄位名稱
    • 欄位型態(text、email、select、checkbox…)
    • 驗證規則(必填、最小長度、pattern…)
    • UI 配置(標籤、placeholder、選項…)
  • 前端程式碼根據 Schema 自動生成表單結構

換句話說,Schema 就是一份「表單藍圖」,表單只是照著藍圖渲染出來。


2. Schema 範例

假設我們要設計一個問卷表單:

簡易版:

後續的範例會以簡單版的展示

interface SimpleSchema {
  type: 'object';
  title?: string;
  properties: Record<string, {
    ui: UIType;
    type: 'integer' | 'string' | 'array' | 'boolean';
    title: string;
    enum?: (number | string)[];   // single/multi/likert 用來渲染選項
    description?: string;

    weight?: number;        // 題目權重,預設 1
    scoreValues?: number[]; // 跟 enum 對齊(index 對 index)
    // - boolean:若未提供,預設 [0,1] 對應 [false,true]
    // - single:選到第 i 個 → scoreValues[i]
    // - multi:選到多個 → 全部相加
    // - likert:若 enum 是數字,直接取值 * weight;若提供 scoreValues 則優先用 scoreValues
  }>;
  required?: string[];
}

複雜板:

這個版本差在可以花式驗證,大家有興趣可以自己擴增來試試

  • 使用者名稱:文字輸入框(必填)
  • Email:Email 欄位(必填,格式檢查)
  • 性別:下拉選單(選項:男、女、其他)
  • 同意條款:必須勾選
type UIType = 'likert' | 'boolean' | 'single' | 'multi';

type ValidatorConfig = {
  // 基本
  required?: boolean;
  email?: boolean;
  minLength?: number;
  maxLength?: number;
  pattern?: string | RegExp;

  // 數值/量表
  min?: number;
  max?: number;

  // 複選數量
  minSelected?: number;
  maxSelected?: number;

  // 條件式
  requiredIf?: { dependsOn: string; equals: any };
  enableIf?: { dependsOn: string; equals: any };

  // 自訂 / 非同步(名稱映射到 registry)
  customValidators?: string[];
  asyncValidators?: string[];

  // 覆寫訊息(鍵名對應 Angular 的錯誤 key 或自訂)
  messages?: Partial<Record<
    | 'required' | 'email' | 'minLength' | 'maxLength' | 'pattern'
    | 'min' | 'max' | 'minSelected' | 'maxSelected'
    | 'requiredIf' | 'custom' | 'async',
    string
  >>;
};

interface SimpleSchemaV2 {
  type: 'object';
  title?: string;
  properties: Record<string, {
    ui: UIType;
    type: 'integer' | 'string' | 'array' | 'boolean';
    title: string;
    enum?: (number | string)[];
    description?: string;

    // scoring
    weight?: number;
    scoreValues?: number[];

    // 驗證規則
    validators?: ValidatorConfig;
  }>;
  required?: string[];
  // 表單層級驗證(可選)
  groupValidators?: Array<{ name: string; options?: any }>;
}

3. Angular Reactive Forms 動態生成

實際專案使用的時候,把資料整理成 properties 裡面的格式,就可以帶入使用了。

建立表單結構

import { CommonModule } from '@angular/common';
import { Component } from '@angular/core';
import { FormGroup, FormBuilder, Validators, ReactiveFormsModule, FormControl, ValidatorFn, ValidationErrors, AbstractControl, FormArray } from '@angular/forms';
import { NzAlertModule } from 'ng-zorro-antd/alert';
import { NzButtonModule } from 'ng-zorro-antd/button';
import { NzCardModule } from 'ng-zorro-antd/card';
import { NzCheckboxModule } from 'ng-zorro-antd/checkbox';
import { NzDividerModule } from 'ng-zorro-antd/divider';
import { NzFormModule } from 'ng-zorro-antd/form';
import { NzRadioModule } from 'ng-zorro-antd/radio';
import { NzSelectModule } from 'ng-zorro-antd/select';

type UIType = 'likert' | 'boolean' | 'single' | 'multi';

interface SimpleSchema {
  type: 'object';
  title?: string;
  properties: Record<string, {
    ui: UIType;
    type: 'integer' | 'string' | 'array' | 'boolean';
    title: string;
    enum?: (number | string)[];   // single/multi/likert 用來渲染選項
    description?: string;

    weight?: number;        // 題目權重,預設 1
    scoreValues?: number[]; // 跟 enum 對齊(index 對 index)
    // - boolean:若未提供,預設 [0,1] 對應 [false,true]
    // - single:選到第 i 個 → scoreValues[i]
    // - multi:選到多個 → 全部相加
    // - likert:若 enum 是數字,直接取值 * weight;若提供 scoreValues 則優先用 scoreValues
  }>;
  required?: string[];
}

const quizSchema: SimpleSchema = {
  type: 'object',
  title: '自我管理與專注量表(隨便設的題目)',
  properties: {
    // Likert:直接取 1~5
    q1: { ui: 'likert', type: 'integer', title: '我對未來感到樂觀。', enum: [1, 2, 3, 4, 5], description: '1=非常不同意,5=非常同意' },
    q2: { ui: 'likert', type: 'integer', title: '面對壓力時,我能快速恢復。', enum: [1, 2, 3, 4, 5], description: '1=非常不同意,5=非常同意' },
    q3: { ui: 'likert', type: 'integer', title: '我能長時間專注在單一任務上。', enum: [1, 2, 3, 4, 5], description: '1=非常不同意,5=非常同意' },
    q4: { ui: 'likert', type: 'integer', title: '我擅長清楚表達需求與想法。', enum: [1, 2, 3, 4, 5], description: '1=非常不同意,5=非常同意' },
    q5: { ui: 'likert', type: 'integer', title: '我會主動尋求資源或協助。', enum: [1, 2, 3, 4, 5], description: '1=非常不同意,5=非常同意' },

    // Boolean:未提供 scoreValues → 預設 [false=0, true=1]
    q6: { ui: 'boolean', type: 'boolean', title: '我固定在一天中的同一時段處理深度工作。' },

    // Single:用 scoreValues 對齊 enum
    q7: {
      ui: 'single', type: 'string', title: '我安排每日任務的方式',
      enum: ['臨時想到才做', '簡單待辦清單', '有優先順序的待辦', '有時間區塊與緩衝'],
      scoreValues: [0, 1, 2, 3]
    },

    // Multi:每勾一項加相對應分數(簡單相加)
    q8: {
      ui: 'multi', type: 'array', title: '我常用哪些提升專注的方法(可複選)',
      enum: ['番茄鐘', '關閉通知', '單一待辦板', '固定安靜場域'],
      scoreValues: [1, 1, 1, 1]
    },
  },
  required: ['q1', 'q2', 'q3', 'q4', 'q5', 'q6', 'q8']
};

@Component({
  selector: 'app-day25',
  standalone: true,
  imports: [
    CommonModule,
    NzCardModule,
    NzButtonModule,
    NzAlertModule,
    ReactiveFormsModule,
    NzRadioModule,
    NzDividerModule,
    NzSelectModule,
    NzCheckboxModule,
    NzFormModule
  ],
  templateUrl: './day25.component.html',
  styleUrl: './day25.component.scss'
})
export class Day25Component {
  schema = quizSchema;
  propKeys = Object.keys(this.schema.properties);

  form: FormGroup;
  submitted = false;
  total = 0;
  resultText = '';

  constructor(private fb: FormBuilder) {
    this.form = this.buildForm(this.schema);
  }

  // 讓複選題至少選 1 個的驗證器
  private atLeastOneSelected(): ValidatorFn {
    return (ctrl: AbstractControl): ValidationErrors | null => {
      const v = ctrl.value as unknown;
      return Array.isArray(v) && (v as boolean[]).some(Boolean)
        ? null
        : { atLeastOne: true };
    };
  }

  // 依 Schema 建 FormControl(支援 boolean/single/multi/likert)
  private buildForm(schema: SimpleSchema): FormGroup {
    const group: Record<string, any> = {};

    for (const key of Object.keys(schema.properties)) {
      const prop = this.schema.properties[key];
      const isRequired = !!this.schema.required?.includes(key);

      switch (prop.ui) {
        case 'multi': {
          const opts = (prop.enum || []) as any[];
          const controls = opts.map(() => this.fb.nonNullable.control<boolean>(false));
          const validators = isRequired ? [this.atLeastOneSelected()] : [];
          group[key] = new FormArray<FormControl<boolean>>(controls, validators);
          break;
        }
        case 'boolean': {
          group[key] = new FormControl<boolean | null>(
            null,
            isRequired ? [Validators.required] : []
          );
          break;
        }
        case 'single':
        case 'likert': {
          group[key] = new FormControl<number | string | null>(
            null,
            isRequired ? [Validators.required] : []
          );
          break;
        }
      }
    }

    return this.fb.group(group);
  }

  isRequired(key: string): boolean {
    return this.schema.required?.includes(key) ?? false;
  }

  isInvalid(key: string) {
    const c = this.form.get(key);
    return !!c && c.invalid && (c.dirty || this.submitted);
  }

  onSubmit() {
    this.submitted = true;
    if (this.form.invalid) {
      this.form.markAllAsTouched();
      return;
    }

    const values = this.form.value as Record<string, any>;
    const total = this.propKeys.reduce((acc, k) => acc + this.scoreOf(k, values[k]), 0);
    const maxTotal = this.getMaxTotal();

    this.total = total;

    const pct = maxTotal > 0 ? (total / maxTotal) * 100 : 0;
    if (pct >= 80) this.resultText = '整體狀態良好,韌性與自我效能感較高。';
    else if (pct >= 60) this.resultText = '中性偏佳,建議持續關注壓力管理與支持系統。';
    else this.resultText = '近期可能壓力較高或資源不足,建議適度休息並尋求支持。';
  }

  private scoreOf(key: string, raw: any): number {
    const prop = this.schema.properties[key];
    const weight = prop.weight ?? 1;

    switch (prop.ui) {
      case 'likert': {
        // 若有 scoreValues 就用它;否則用 enum 的數值(1~5…)
        if (Array.isArray(prop.scoreValues) && prop.enum) {
          const idx = (prop.enum as any[]).findIndex(v => v === raw);
          const sc = idx >= 0 ? (prop.scoreValues[idx] ?? 0) : 0;
          return sc * weight;
        }
        const n = Number(raw ?? 0);
        return (isNaN(n) ? 0 : n) * weight;
      }

      case 'boolean': {
        // 若有 scoreValues(2 個值)就用它;否則預設 false=0, true=1
        if (Array.isArray(prop.scoreValues) && prop.scoreValues.length >= 2) {
          const idx = raw === true ? 1 : 0; // 假設 scoreValues 對應 [false, true]
          return (prop.scoreValues[idx] ?? 0) * weight;
        }
        return (raw === true ? 1 : 0) * weight;
      }

      case 'single': {
        // 用 scoreValues 對應 enum;沒有就 0 分
        if (Array.isArray(prop.scoreValues) && prop.enum) {
          const idx = (prop.enum as any[]).findIndex(v => v === raw);
          const sc = idx >= 0 ? (prop.scoreValues[idx] ?? 0) : 0;
          return sc * weight;
        }
        return 0;
      }

      case 'multi': {
        const enumVals = (prop.enum || []) as any[];
        const sv = prop.scoreValues;

        // 1) raw 可能是 boolean[](FormArray),把 true 的索引挑出
        let selectedIdxs: number[] = [];
        if (Array.isArray(raw) && raw.length && typeof raw[0] === 'boolean') {
          selectedIdxs = (raw as boolean[])
            .map((b, i) => (b ? i : -1))
            .filter(i => i >= 0);
        }
        // 2) 或者 raw 可能是值的陣列(少見,但保險處理)
        else if (Array.isArray(raw)) {
          selectedIdxs = (raw as any[])
            .map(v => enumVals.findIndex(e => e === v))
            .filter(i => i >= 0);
        }

        // 有 scoreValues:加總被勾選的分數
        if (Array.isArray(sv) && sv.length) {
          const subtotal = selectedIdxs.reduce((sum, i) => sum + (sv[i] ?? 0), 0);
          return subtotal * weight;
        }

        // 沒定義 scoreValues:預設一個選項 1 分
        return selectedIdxs.length * weight;
      }
    }

    return 0;
  }

  private getMaxTotal(): number {
    let max = 0;

    for (const key of this.propKeys) {
      const prop = this.schema.properties[key];
      const weight = prop.weight ?? 1;

      switch (prop.ui) {
        case 'likert': {
          if (Array.isArray(prop.scoreValues) && prop.scoreValues.length) {
            max += Math.max(...prop.scoreValues) * weight;
          } else {
            const nums = (prop.enum || []).filter(e => typeof e === 'number') as number[];
            const m = nums.length ? Math.max(...nums) : 0;
            max += m * weight;
          }
          break;
        }
        case 'boolean': {
          if (Array.isArray(prop.scoreValues) && prop.scoreValues.length >= 2) {
            max += Math.max(prop.scoreValues[0] ?? 0, prop.scoreValues[1] ?? 0) * weight;
          } else {
            max += 1 * weight; // 預設 true=1
          }
          break;
        }
        case 'single': {
          if (Array.isArray(prop.scoreValues) && prop.scoreValues.length) {
            max += Math.max(...prop.scoreValues) * weight;
          }
          // 沒有 scoreValues 則視為資訊題,最大分 0
          break;
        }
        case 'multi': {
          if (Array.isArray(prop.scoreValues) && prop.scoreValues.length) {
            // 預設最大分:全選
            max += prop.scoreValues.reduce((a, b) => a + b, 0) * weight;
          } else {
            // 沒定義 scoreValues 時,每個選項 1 分 → 全選
            max += (prop.enum?.length ?? 0) * weight;
          }
          break;
        }
      }
    }

    return max;
  }

  // 取得複選題的選項(字串陣列)
  getMultiOptions(key: string): string[] {
    return (this.schema.properties[key].enum || []) as string[];
  }

  // 勾選/取消勾選時更新 FormControl(型別是 string[])
  onToggleMulti(key: string, val: string, checked: boolean) {
    const ctrl = this.form.get(key) as FormControl<string[]>;
    const arr = ctrl.value ?? [];
    const next = checked ? Array.from(new Set([...arr, val])) : arr.filter(x => x !== val);
    ctrl.setValue(next);
    ctrl.markAsDirty();
    ctrl.updateValueAndValidity();
  }
}

動態渲染模板

<div>
  <nz-card [nzTitle]="schema.title">
    <form nz-form [formGroup]="form" (ngSubmit)="onSubmit()">

      <ng-container *ngFor="let key of propKeys">
        <nz-form-item>
          <!-- 標籤區:標題 + 必填星號 + 描述 -->
          <nz-form-label [nzRequired]="isRequired(key)" [nzFor]="key">
            {{ schema.properties[key].title }}
            <div *ngIf="schema.properties[key].description">
              {{ schema.properties[key].description }}
            </div>
          </nz-form-label>

          <!-- 控制區:綁定驗證狀態與錯誤提示 -->
          <nz-form-control [nzErrorTip]="'此題必填'" [nzValidateStatus]="form.controls[key]">

            <ng-container [ngSwitch]="schema.properties[key].ui">

              <!-- Likert:1~5 -->
              <ng-container *ngSwitchCase="'likert'">
                <nz-radio-group [formControlName]="key" [id]="key">
                  <label nz-radio *ngFor="let v of schema.properties[key].enum" [nzValue]="v">
                    {{ v }}
                  </label>
                </nz-radio-group>
              </ng-container>

              <!-- 是非題 -->
              <ng-container *ngSwitchCase="'boolean'">
                <nz-radio-group [formControlName]="key" [id]="key">
                  <label nz-radio [nzValue]="true">是</label>
                  <label nz-radio [nzValue]="false">否</label>
                </nz-radio-group>
              </ng-container>

              <!-- 單選題 -->
              <ng-container *ngSwitchCase="'single'">
                <nz-radio-group [formControlName]="key" [id]="key">
                  <label nz-radio *ngFor="let v of schema.properties[key].enum" [nzValue]="v">
                    {{ v }}
                  </label>
                </nz-radio-group>
              </ng-container>

              <!-- 複選題(FormArray) -->
              <ng-container *ngSwitchCase="'multi'">
                <div [formArrayName]="key" [id]="key">
                  <label nz-checkbox *ngFor="let v of getMultiOptions(key); let i = index" [formControlName]="i">
                    {{ v }}
                  </label>
                </div>
              </ng-container>

            </ng-container>
          </nz-form-control>
        </nz-form-item>

        <nz-divider></nz-divider>
      </ng-container>

      <div>
        <button nz-button nzType="primary" [disabled]="form.invalid">送出</button>
      </div>
    </form>

    <nz-divider></nz-divider>

    <nz-alert *ngIf="submitted && form.valid" nzType="success" nzShowIcon [nzMessage]="'總分:' + total + ' 分'"
      [nzDescription]="resultText">
    </nz-alert>
  </nz-card>
</div>

4. 優勢與挑戰

優勢

  • 不需重刻程式:表單結構由 Schema 驅動,使用者透過特定介面調整題目就好。
  • 快速開發:問卷系統、後台管理工具能快速生成表單。
  • 一致性:Schema 也能給後端用來驗證,前後端共用規則。

挑戰

  • 複雜度:Schema 越大,解析邏輯越複雜。
  • 維護成本:需要定義一個統一的 Schema 格式,否則會混亂。

5. 實務建議

  1. Schema 要先規範好
    • 欄位型態、驗證規則要有標準。
    • 不同團隊共用一份定義文件。
  2. 不適合專案不用強行 Schema-driven
    • 請確認好未來專案需求方向,再決定要不要用

小結

  • 動態表單 (Schema-driven Forms) 的核心概念是:用一份 Schema 來生成表單,而不是手刻 FormGroup。
  • 適合用在「少客製化內容,題目樣式固定,純題目內容經常更新」的專案,例如問卷或測驗。
  • 雖然強大,但需要規範 Schema 格式,並處理效能問題。

上一篇
Day 24:Angular Reactive Forms – 跨元件巢狀與大表單拆分
下一篇
Day 26:何時該抽元件?從視覺相似到業務邏輯的判斷法則
系列文
Angular 進階實務 30天27
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言