iT邦幫忙

2025 iThome 鐵人賽

DAY 21
0
Modern Web

Angular 進階實務 30天系列 第 21

Day 21:Angular Reactive Forms – 表單結構基礎 — 單層 vs 巢狀

  • 分享至 

  • xImage
  •  

前言

前面的狀態管理,我們有提到了 Reactive Forms 的 API,現在讓我們進一步的討論關於 Reactive Forms 的部分。

「我們要如何設計表單,讓使用者正確、有效率地輸入資料?」

在 Angular 裡,表單(Forms)是與狀態緊密相關的 UI 元件。
簡單的場景,我們可能只需要單層表單(flat form),就能完成輸入。
但在更複雜的系統中,資料往往帶有結構性(例如:一個使用者有多個地址,一張訂單有多筆明細、一個活動有多個會員),這時就需要 巢狀表單(nested form) 來對應資料模型。

今天會先簡單的介紹最好懂的單層表單,逐步走到巢狀表單,並介紹常見的組合模式以及資料帶入的部分。

1. 單層表單:最簡單的結構

常見情境

  • 登入、查詢等欄位少、邏輯單純的頁面。

參考案例

  • 登入
  • 搜尋等單純輸入

程式碼範例

單層表單的特徵是「一個表單對應一個資料物件」,沒有更深的層級。這種設計適合欄位數量少,且邏輯相對單純的場景。

範例

form = this.fb.group({
  username: ['amy_lin'],
  password: ['123456']
});

form.value

{ "username": "amy_lin", "password": "123456" }

2. 巢狀表單:應付複雜資料模型

定義

  • 一個表單可以包含其他 FormGroup 或 FormArray,形成層級結構。

常見情境

  • 通常用在寫入、修改資料的 API 行為。

為什麼需要巢狀?

  • 資料模型本身具有結構(例如 User → Profile → Contact)。
  • 存在一對多的情境(多個地址、多筆明細、多個參與學員)。
  • 大型表單需要拆分,避免維護困難。

參考案例

  • 使用者註冊時,需要填寫「基本資料」與「聯絡方式」。

程式碼

巢狀表單的好處是可以貼近資料模型結構,方便直接與後端 API 對接。

範例

form = this.fb.group({
  name: ['王小明'],
  contact: this.fb.group({
    email: ['xiaoming@example.com'],
    phone: ['0912-345-678']
  })
});

form.value

{
  "name": "王小明",
  "contact": { "email": "xiaoming@example.com", "phone": "0912-345-678" }
}

3. 常見巢狀組合

在實務中,巢狀表單會出現各種組合。以下我們整理出四種最常見的結構,並透過程式碼展示。

3.1 FormGroup → FormGroup

情境需求

  • 活動申請:活動基本資料、活動預算、活動實際支出、學員名單。

程式碼

這樣的結構適合有「多層次資訊」的場景,例如專案管理、活動申請等。

form = this.fb.group({
  activity: this.fb.group({
    title: ['暑期夏令營'],
    date: ['2025-08-15'],
    budget: this.fb.group({ estimated: [100000], actual: [95000] }),
    participants: this.fb.group({ leader: ['王老師'], assistant: ['李助教'] })
  })
});

輸出結果(form.value

{
  "activity": {
    "title": "暑期夏令營",
    "date": "2025-08-15",
    "budget": {
      "estimated": 100000,
      "actual": 95000
    },
    "participants": {
      "leader": "王老師",
      "assistant": "李助教"
    }
  }
}

3.2 FormGroup → FormArray

情境需求

  • 一個使用者可能有多個住址。

案例

  • 使用者地址清單
  • 公司分店列表

程式碼

FormArray 可以讓我們動態增減表單項目,非常適合「清單型輸入」。

form = this.fb.group({
  name: ['林小美'],
  addresses: this.fb.array([
    // 地址一
    this.fb.group({
      city: ['台北市'],
      district: ['中正區'],
      street: ['重慶南路一段100號']
    }),
    // 地址二
    this.fb.group({
      city: ['新北市'],
      district: ['板橋區'],
      street: ['文化路二段88號']
    })
  ])
});

輸出結果(form.value

{
  "name": "林小美",
  "addresses": [
    {
      "city": "台北市",
      "district": "中正區",
      "street": "重慶南路一段100號"
    },
    {
      "city": "新北市",
      "district": "板橋區",
      "street": "文化路二段88號"
    }
  ]
}


3.3 FormArray → FormGroup

情境需求

  • 一張訂單可能包含多個商品,每個商品是一組欄位。

案例

  • 購物車
  • 報名表(多位參加者)

程式碼

這個結構與電商場景最為貼近,能清楚表示「多個商品組成一張訂單」。

form = this.fb.group({
  orderItems: this.fb.array([
    // 商品一
    this.fb.group({
      productName: ['iPhone 15'],
      quantity: [2],
      price: [35000]
    }),
    // 商品二
    this.fb.group({
      productName: ['AirPods Pro 2'],
      quantity: [1],
      price: [7500]
    })
  ])
});

輸出結果 (form.value)

{
  "orderItems": [
    {
      "productName": "iPhone 15",
      "quantity": 2,
      "price": 35000
    },
    {
      "productName": "AirPods Pro 2",
      "quantity": 1,
      "price": 7500
    }
  ]
}


說明

  • 外層 FormArrayorderItems(訂單商品清單)。
  • 每一個元素是 FormGroup,對應一個商品。
  • form.value 會自動轉成 JSON-like 結構,不包含 Angular 的控制項 class。
  • 這樣設計可以直接對應到後端 API 的「訂單明細」結構。

3.4 FormArray → FormArray

情境需求

  • 在電影院或活動場地,需要設計「座位表」來表示每一排、每一個座位的狀態(是否被預訂)。
  • 每一排(row)包含多個座位(seat),每個座位可以用布林值表示是否被選取 / 已預訂。

案例

  • 電影院座位表
  • 教室排座
  • 演唱會場地規劃

程式碼

// rows: FormArray< FormArray< FormControl<boolean> > >
form = this.fb.group({
  rows: this.fb.array([
    // 第 1 排(5 個座位)
    this.fb.array([
      this.fb.control<boolean>(false), // A1
      this.fb.control<boolean>(true),  // A2 已被預訂
      this.fb.control<boolean>(false), // A3
      this.fb.control<boolean>(false), // A4
      this.fb.control<boolean>(false), // A5
    ]),
    // 第 2 排(5 個座位)
    this.fb.array([
      this.fb.control<boolean>(false), // B1
      this.fb.control<boolean>(false), // B2
      this.fb.control<boolean>(false), // B3
      this.fb.control<boolean>(true),  // B4 已被預訂
      this.fb.control<boolean>(false), // B5
    ]),
  ])
});

輸出結果 (form.value)

{
  "rows": [
    [false, true, false, false, false],
    [false, false, false, true, false]
  ]
}

說明

  • 外層 FormArray 代表「排」(rows)。
  • 內層 FormArray 代表「該排的所有座位」。
  • 每個 FormControl<boolean> 代表一個座位的狀態(true = 已被選取或預訂false = 可用)。
  • 這種結構很適合用在 座位表、棋盤、時間格子 等「二維矩陣」場景。

4. 資料帶入:不改變結構,只改值

當表單控制項 結構已經建立完成,僅需要將 API 資料帶入現有控制項。

  • setValue:嚴格比對,key 與長度都要完全一致。
  • patchValue:寬鬆比對,只更新存在的 key;陣列索引須存在但可部分更新。

4.1 FormGroup 資料帶入

const userFromApi = {
  name: '王小明',
  contact: { email: 'xiaoming@example.com', phone: '0912-555-666' }
};

// 完整帶入
this.form.setValue(userFromApi);

// 局部帶入
this.form.patchValue({ contact: { phone: '0912-000-000' } });

4.2 FormArray 資料帶入(長度不變)

const addressesFromApi = [
  { city: '台北市', district: '中正區', street: '重慶南路100號' },
  { city: '新北市', district: '板橋區', street: '文化路二段88號' }
];

// 完整帶入(長度與 key 對齊)
this.form.get('addresses')!.setValue(addressesFromApi);

// 局部帶入(只改第一筆的 city)
this.form.get('addresses')!.patchValue([{ city: '桃園市' }]);

4.3 FormArray 資料帶入注意事項

  • FormArray 不會自動建立控制項。所以需要先建立他的結構才能帶入值,不然他沒有洞可以塞值。
  • setValue() → 長度不符會拋錯。
  • patchValue() → 多餘資料會被忽略,不會自動新增控制項。
  • ✅ 正確做法:先建立/同步結構,再帶入值
const addressesFromApi = [
  { city: '台北市', district: '中正區', street: '重慶南路100號' },
  { city: '新北市', district: '板橋區', street: '文化路二段88號' },
  { city: '桃園市', district: '中壢區', street: '中大路300號' }
];

const fa = this.form.get('addresses') as FormArray;

// 先同步結構
fa.clear();
for (const a of addressesFromApi) {
  fa.push(this.fb.group({
    city: [a.city],
    district: [a.district],
    street: [a.street]
  }));
}

// 再用 setValue/patchValue 更新值
fa.setValue(addressesFromApi);

4.4 二維矩陣資料帶入

(this.form.get('rows') as FormArray).at(0).at(1).setValue(true); // 單一座位
(this.form.get('rows') as FormArray).patchValue([[false, true]]); // 批次更新

📊 setValue vs patchValue

特性 setValue() patchValue()
完整性檢查 要求完整結構,key/index 不可少 允許部分更新,缺少欄位會忽略
值更新範圍 所有控制項都更新 只更新指定的控制項
事件觸發 所有控制項 + 父層的 valueChangesstatusChanges 都觸發 只有被更新的控制項與父層觸發
Validator 執行 所有相關控制項都重新驗證 只驗證被更新的控制項
常見場景 API 回傳完整資料,表單結構與資料完全一致 API 回傳部分資料,或只需更新部分欄位

5. 結構同步:新增或移除控制項

當 API 的資料筆數改變,或表單結構尚未建立,需要先同步結構,再帶入值。

常見方法:

  • clear() + push()
  • setControl()
  • 調整長度差

範例:同步地址清單

const addressesFromApi = [
  { city: '台北市', district: '中正區', street: '重慶南路100號' },
  { city: '新北市', district: '板橋區', street: '文化路二段88號' },
  { city: '桃園市', district: '中壢區', street: '中大路300號' }
];

const buildAddressGroup = (a: any) =>
  this.fb.group({ city: [a.city], district: [a.district], street: [a.street] });

const fa = this.form.get('addresses') as FormArray;
fa.clear();
for (const a of addressesFromApi) fa.push(buildAddressGroup(a));


📊 資料帶入 vs 結構同步

面向 資料帶入(setValue / patchValue) 結構同步(push / clear / setControl)
目標 更新現有控制項的值 修改控制項樹的結構
是否改變表單結構 ❌ 不會 ✅ 會(新增/刪除控制項)
失敗風險 setValue 結構不一致會拋錯 push/clear 用錯會導致結構與資料不同步
適合情境 API 資料筆數不變 API 資料筆數有變化,需要重建結構
程式碼關鍵字 setValue()patchValue() push()removeAt()clear()setControl()

6. 單層 vs 巢狀:該怎麼選?

  • 選單層:欄位少、邏輯單純、對應單一物件。
  • 選巢狀:需要貼近後端資料模型,有一對多或多層結構,或需要模組化維護。

👉 常見準則:表單結構 ≈ 資料模型


7. 常見錯誤與最佳實務

常見錯誤

  • FormArray沒有建立結構就塞值。
  • 控制項路徑寫錯,導致取不到 control。
  • 所有邏輯塞在父表單,難以維護。
  • 過度巢狀化造成維護困難

最佳實務

  • 表單結構 ≈ 資料模型。
  • 適度拆分子元件,避免「巨型表單」。
  • 層級不要超過 3 層,超過請確認設計。

8. 小結

在實務應用上,FormArray 需要先建立好結構,才能正確地使用 setValuepatchValue 進行資料帶入。整體來說,單層表單比較適合簡單的場景,而巢狀表單則能更好地對應複雜的資料模型。設計表單時,可以從需求的角度來思考,選擇最能貼近資料結構的方式。至於進一步的進階議題,下一篇將會探討 群組驗證與跨欄位邏輯,讓表單設計更符合實際應用的需求。


上一篇
Day20:實戰篇 – Angular Directive 實作權限控制
下一篇
Day 22:Angular Reactive Forms –群組驗證與跨欄位邏輯
系列文
Angular 進階實務 30天22
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言