iT邦幫忙

2026 iThome 鐵人賽

DAY 19
2
Modern Web

Angular 22 Signal 進化論系列 第 19 篇

Day 19:Signal Forms Cross-field Validation 與欄位更新時機

  • 分享至 

  • xImage
  •  

Day 19:Signal Forms Cross-field Validation 與驗證時機

上一篇介紹 Signal Forms 時,已經看到可以透過 Schema 定義欄位驗證,例如 required()、email()、minLength()。

這些規則只需要目前欄位的值就能判斷結果,但有些情況還需要參考其他欄位,例如:

  • 確認密碼要和新密碼一致
  • 結束日期不能早於開始日期
  • 最小值不能大於最大值

這類需要參考其他欄位的驗證,就是 Cross-field Validation。

什麼是 Cross-field Validation?

這篇先從確認密碼的案例開始,再補充欄位之間的相依關係,以及 touched()、debounce() 分別會影響什麼。

Cross-field Validation

假設使用者正在設定新密碼,表單有 New Password 和 Confirm Password:

interface PasswordModel {
  newPassword: string;
  confirmPassword: string;
}

readonly passwordModel = signal<PasswordModel>({
  newPassword: '',
  confirmPassword: '',
});

confirmPassword 不能只看自己的值,還需要和 newPassword 比較。

這時可以使用 validate() 自訂驗證規則:

import {
  form,
  minLength,
  required,
  schema,
  validate,
} from '@angular/forms/signals';

const passwordSchema = schema<PasswordModel>(path => {
  required(path.newPassword, {
    message: '請輸入新密碼',
  });

  minLength(path.newPassword, 8, {
    message: '密碼至少需要 8 個字元',
  });

  required(path.confirmPassword, {
    message: '請再次輸入新密碼',
  });

  validate(
    path.confirmPassword,
    ({ value, valueOf }) => {
      const newPassword = valueOf(path.newPassword);
      const confirmPassword = value();

      if (newPassword !== confirmPassword) {
        return {
          kind: 'passwordMismatch',
          message: '兩次輸入的密碼不一致',
        };
      }

      return null;
    }
  );
});

readonly passwordForm = form(
  this.passwordModel,
  passwordSchema
);

這條驗證掛在 path.confirmPassword,所以 value() 取得的是目前 confirmPassword 的值;如果還需要讀取其他欄位,就可以使用 valueOf()。

const newPassword = valueOf(path.newPassword);

兩個密碼不同時回傳 Validation Error,驗證通過則回傳 null。

value() 與 valueOf() 各自讀取什麼?

錯誤會反映在 Confirm Password 的 Field State:

<input
  type="password"
  [formField]="passwordForm.newPassword"
/>

<input
  type="password"
  [formField]="passwordForm.confirmPassword"
/>

@if (
  passwordForm.confirmPassword().touched() &&
  passwordForm.confirmPassword().invalid()
) {
  @for (
    error of passwordForm.confirmPassword().errors();
    track error.kind
  ) {
    <p>{{ error.message }}</p>
  }
}

valueOf() 也會建立相依關係

valueOf() 不只是取得另一個欄位的值,也會讓這個欄位成為驗證規則的相依來源。

假設一開始:

New Password      12345678
Confirm Password  12345678

兩個密碼相同,驗證會通過。

接著只修改 New Password:

New Password      abcdefgh
Confirm Password  12345678

即使 Confirm Password 沒有修改,驗證結果還是會重新計算。

因為這條規則透過 valueOf(path.newPassword) 讀取了 newPassword,所以 newPassword 改變時,這條驗證也會重新執行,不需要另外監聽欄位變化。

valueOf() 不只讀值,也會建立驗證相依

FieldContext 還能取得哪些資料

前面的 validate() Callback 會拿到 FieldContext,可以取得目前欄位或其他欄位的資訊:

  • value():取得目前正在驗證的欄位值
  • valueOf():取得其他欄位的值
  • stateOf():取得其他欄位的 Field State
  • fieldTreeOf():取得其他欄位的 FieldTree

如果驗證邏輯除了值以外,還需要知道其他欄位目前是不是 invalid()、touched() 等狀態,可以使用 stateOf()。

FieldContext:需要其他欄位的哪一層資訊?

fieldTreeOf() 可以取得其他欄位對應的 FieldTree。

例如表單裡有巢狀的 Address:

interface UserModel {
  name: string;
  address: {
    city: string;
    zipCode: string;
  };
}

驗證時如果需要取得整個 address 的 FieldTree,可以使用:

validate(path.name, ({ fieldTreeOf }) => {
  const addressTree = fieldTreeOf(path.address);

  // addressTree.city
  // addressTree.zipCode

  return null;
});

這時拿到的是 address 這組欄位的 FieldTree,而不是單一欄位值。

像確認密碼、日期區間這類只需要比較值的情況,通常使用 valueOf() 就足夠了。

日期區間也是 Cross-field Validation

Cross-field Validation 不只會出現在密碼欄位。

例如活動表單有開始日期和結束日期:

interface EventModel {
  startDate: Date;
  endDate: Date;
}

readonly eventModel = signal<EventModel>({
  startDate: new Date('2026-10-01'),
  endDate: new Date('2026-10-02'),
});

const eventSchema = schema<EventModel>(path => {
  validate(
    path.endDate,
    ({ value, valueOf }) => {
      const startDate = valueOf(path.startDate);
      const endDate = value();

      if (endDate < startDate) {
        return {
          kind: 'invalidDateRange',
          message: '結束日期不能早於開始日期',
        };
      }

      return null;
    }
  );
});

這次規則掛在 endDate,value() 取得 End Date,valueOf(path.startDate) 則取得 Start Date。

日期區間也是 Cross-field Validation

touched() 和驗證時機

前面的 Template 會同時判斷:

@if (
  passwordForm.confirmPassword().touched() &&
  passwordForm.confirmPassword().invalid()
) {
  ...
}

這裡是在處理兩件不同的事:

  • invalid():代表目前的驗證結果
  • touched():表示使用者是否已經操作過這個欄位

欄位值更新到 Form Model 後,驗證規則就會重新計算。即使欄位已經是 Invalid,只要還沒有 Touched,畫面仍然可以先不顯示錯誤。

例如 Confirm Password 一開始是空的,required() 已經可以判斷它沒有通過驗證,但畫面還要求 touched() 必須是 true,所以錯誤訊息不會一開始就出現。

等使用者進入欄位再離開後,touched() 會變成 true。如果這時欄位仍然是 Invalid,錯誤訊息才會顯示。

touched() 不會影響 Validator 是否執行,主要是用來控制驗證結果什麼時候顯示在畫面上。

使用 debounce() 調整更新時機

Signal Forms 預設會隨著使用者輸入更新欄位值,行為接近 onInput。

如果不希望每輸入一個字元就立刻把值同步到 Form Model,可以使用 debounce() 調整更新時機。

例如希望等使用者離開 Email 欄位後再更新:

import {
  debounce,
  email,
  required,
  schema,
} from '@angular/forms/signals';

const registerSchema = schema<RegisterModel>(path => {
  debounce(path.email, 'blur');

  required(path.email, {
    message: 'Email 為必填',
  });

  email(path.email, {
    message: 'Email 格式錯誤',
  });
});

使用 debounce(path.email, 'blur') 後,輸入時不會立刻更新 Form Model,而是等欄位 Blur 後才同步新的值,相關驗證也會跟著重新計算。

除了 'blur',也可以指定等待時間:

debounce(path.email, 300);

這時會等使用者停止輸入一段時間後,再更新 Form Model。

更新方式大致可以整理成:

  • 預設:輸入時就更新,行為接近 onInput
  • debounce(path, 'blur'):Blur 後才更新 Form Model
  • debounce(path, 300):停止輸入一段時間後再更新 Form Model

這裡的 debounce() 控制的是欄位值更新到 Form Model 的時機,和前面用來控制錯誤訊息顯示的 touched() 是兩件不同的事。

如果 Cross-field Validation 的欄位也設定 debounce(),驗證會等新的值同步到 Model 後再重新計算。

例如使用:

debounce(path.confirmPassword, 'blur');

Confirm Password 會等到 Blur 後才更新,再依照新的值重新執行 Cross-field Validation。

touched() 與 debounce() 處理不同的時機

本日結語

Cross-field Validation 適合處理需要同時參考多個欄位的驗證,例如確認密碼、日期區間。透過 valueOf() 讀取其他欄位後,這些欄位也會成為驗證的相依來源,不需要再另外監聽欄位變化。

除了欄位之間的相依,表單裡還有幾個容易混在一起的時機:

  • invalid():目前的驗證結果
  • touched():欄位是否已經被操作,常用來控制錯誤訊息顯示
  • debounce():控制欄位值什麼時候同步到 Form Model

Signal Forms 預設會隨輸入更新欄位值,也可以透過 debounce() 改成 Blur 後或等待一段時間再更新。

把驗證結果、錯誤顯示和值更新時機分開後,Cross-field Validation 的處理會更清楚,也比較不需要額外維護其他狀態。

資料來源


上一篇
Day 18:Angular Signal Forms 穩定了,跟我們用了這麼久的 Reactive Forms 到底差在哪?
系列文
Angular 22 Signal 進化論 共 19 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言