iT邦幫忙

2026 iThome 鐵人賽

DAY 17
0
Modern Web

Angular 工程師的 React 陣痛期:30 天心智模型重建系列 第 17 篇

Day 17|表單一大就卡?用什麼比較快

  • 分享至 

  • xImage
  •  

系統裡欄位一多,控管起來就豆頁痛。我照之前學到的元件寫法,每個欄位都接上 state:

const [form, setForm] = useState<FileMeta>(initialValues);

<input
  value={form.name}
  onChange={e => setForm({ ...form, name: e.target.value })}
/>

寫完一測,描述欄位打字的時候,每打一個字,畫面就頓一下。

打開 React DevTools,原因一目了然:form 是整個表單共用的一個物件。描述欄位每打一個字,setForm 就產生一個新物件,整個表單元件重新 render——三十個欄位、每個欄位的錯誤訊息、底下的驗證邏輯,全部重跑一次。

Day 6 我說過:「大型表單怎麼處理,Week 3 會專門講。」今天就是那天。


一、Reactive Forms:表單是一個物件模型

在 Angular,這種表單我會用 Reactive Forms:

form = new FormGroup({
  name: new FormControl('', {
    nonNullable: true,
    validators: [Validators.required, Validators.maxLength(50)],
  }),
  category: new FormControl<'doc' | 'image' | 'cad'>('doc', { nonNullable: true }),
  effectiveDate: new FormControl<Date | null>(null),
  expiryDate: new FormControl<Date | null>(null),
  tags: new FormArray<FormControl<string>>([]),
});
<form [formGroup]="form" (ngSubmit)="save()">
  <input formControlName="name" />
  @if (form.controls.name.errors?.['required']) {
    <p>必填</p>
  }
</form>

整份表單是一個活在 TypeScript 裡的物件模型。每一個 FormControl 自己記得值、記得有沒有被碰過(touched)、有沒有被改過(dirty)、目前有哪些錯誤(errors)。模板只負責把畫面跟這些 control 綁起來。

打字的時候,變的是那一個 control 自己的值,不會牽動整份表單重算。

這套從來沒想過它的效能。它就是很順。


二、第一版:自己用 useState 撐

React 沒有內建表單工具,所以第一版我自己撐。

除了開場那個效能問題,越寫越發現缺的東西不只一樣:

const [form, setForm] = useState(initialValues);
const [errors, setErrors] = useState<Record<string, string>>({});
const [touched, setTouched] = useState<Record<string, boolean>>({});
const [isSubmitting, setIsSubmitting] = useState(false);

touched 要自己記、errors 要自己算、驗證規則散在 onChange 跟 onSubmit 裡、dirty 還沒做、動態增減的標籤欄位還沒做。

Reactive Forms 替我管的那些狀態,現在每一個都變成一個 useState,每一個都要自己接線。

這個模式很熟悉——Day 9 的 useSearch 也是這樣開始的。已經學乖了,這次直接去找套件。


三、React Hook Form:值不放在 state 裡

React 社群最主流的表單套件是 React Hook Form。改寫之後:

import { useForm } from 'react-hook-form';

function FileMetaEditor() {
  const { register, handleSubmit, formState: { errors } } = useForm<FileMetaForm>({
    defaultValues: { name: '', category: 'doc', tags: [] },
  });

  return (
    <form onSubmit={handleSubmit(data => save(data))}>
      <input {...register('name', { required: '必填', maxLength: { value: 50, message: '最多 50 字' } })} />
      {errors.name && <p>{errors.name.message}</p>}
      {/* ……其他欄位 */}
    </form>
  );
}

再打一次字:不卡了。 打開 DevTools,描述欄位打字的時候,表單元件一次都沒有重新 render。

祕密在 {...register('name')} 這一行。register 回傳一個物件,裡面有 name、onChange、onBlur,還有最關鍵的 ref。展開到 <input> 上之後,React Hook Form 拿到的是真實 DOM 元素的參考。

也就是說,它預設用的是非受控元件——Day 6 提過的那個 defaultValue 模式。值存在 DOM 裡,使用者打字時 DOM 自己更新,React 完全不需要介入。要送出或驗證的時候,再透過 ref 去讀。

這就是它快的原因:打字這件事,根本沒有經過 React 的 render。

而 formState 也很聰明。它用了一個 Proxy,你讀了哪些狀態,它就只在那些狀態變動時才讓元件更新。 上面那段只讀了 errors,所以只有錯誤訊息出現或消失時才會 render;isDirty、touchedFields 沒讀,它們再怎麼變也不會驚動元件。

讀到這裡我愣了一下:讀了什麼就只訂閱什麼——這不就是 Signals 的依賴追蹤?

原來 Reactive Forms 也是這樣想的

把兩邊放在一起看,結構其實一模一樣:

表單的狀態,都不放在 render 循環裡。

Reactive Forms 把狀態放進 FormControl 物件,React Hook Form 把狀態放進 DOM 加上它自己的內部 store。兩邊的畫面都只是訂閱需要的那一塊。

在第一版犯的錯,是把表單的每個值都塞進 React 的 state——等於每一次按鍵,都要整份表單重新拍一張快。對越多欄位的表單來說,這張快照太大了。


四、Zod:驗證規則變成一份 schema

React Hook Form 自己的驗證寫在 register 的參數裡,欄位一多就很雜。更常見的做法是搭配 Zod,把規則集中成一份 schema:

import { z } from 'zod';

export const fileMetaSchema = z
  .object({
    name: z.string().min(1, '必填').max(50, '最多 50 字'),
    category: z.enum(['doc', 'image', 'cad']),
    version: z.string().regex(/^v\d+\.\d+$/, '格式需為 v1.0'),
    effectiveDate: z.date(),
    expiryDate: z.date().optional(),
    tags: z.array(z.object({ value: z.string().min(1) })),
  })
  .refine(
    data => !data.expiryDate || data.expiryDate > data.effectiveDate,
    { message: '失效日必須晚於生效日', path: ['expiryDate'] },
  );

export type FileMetaForm = z.infer<typeof fileMetaSchema>;

接到表單上:

import { zodResolver } from '@hookform/resolvers/zod';

const form = useForm<FileMetaForm>({
  resolver: zodResolver(fileMetaSchema),
  defaultValues: { /* ... */ },
});

register 裡的驗證參數全部拿掉,規則都在 schema 裡。

這裡有三件事讓我很有感:

型別是從規則推出來的。 z.infer 直接產生 FileMetaForm 型別。在 Angular,FormGroup 的型別跟驗證規則是兩件事,改了一邊要記得改另一邊;這裡只有一份,不會對不上。

跨欄位驗證寫在同一個地方。 「失效日必須晚於生效日」這種規則,Angular 要寫一個掛在 FormGroup 上的 validator 函式;Zod 用 refine 寫在 schema 旁邊,path 指定錯誤要顯示在哪個欄位。

同一份 schema,伺服器也能用。 React Router 的 action 是在伺服器上處理表單送出的。把同一份 fileMetaSchema 拿去 action 裡再驗一次,前後端的規則就保證一致。

最後這點要誠實補一句:我常用後端是 Spring Boot,真正的資料驗證還是靠 Java 那邊的 Bean Validation,Zod schema 沒辦法共用過去。所以這份 schema 守的是「前端這一層」,後端那層照樣要寫。


五、動態欄位:FormArray 跟 useFieldArray

標籤欄位可以自由增減,Angular 用 FormArray。React Hook Form 的對應物是 useFieldArray:

const { control, register } = form;
const { fields, append, remove } = useFieldArray({ control, name: 'tags' });

return (
  <>
    {fields.map((field, index) => (
      <div key={field.id}>
        <input {...register(`tags.${index}.value`)} />
        <button type="button" onClick={() => remove(index)}>刪除</button>
      </div>
    ))}
    <button type="button" onClick={() => append({ value: '' })}>新增標籤</button>
  </>
);

兩個細節值得注意。

key 要用 field.id,不能用 index。 useFieldArray 會替每一項產生一個穩定的 id。用 index 當 key,刪掉中間一項時,後面的輸入框內容會錯位——Day 3 講的 track 跟 key,在這裡又出現了。

陣列裡要放物件,不能放純字串。 這也是為什麼 schema 裡的 tags 是 { value: string }[],而不是 string[]。一開始覺得多此一舉,後來才知道是為了讓每一項有地方放那個 id。


六、送出、監聽、伺服器回來的錯誤

剩下幾件事,對照著看就好:

監聽欄位變化。 Angular 用 valueChanges 訂閱;React Hook Form 用 watch 或 useWatch。例如類別選了 CAD 才顯示圖號欄位:

const category = useWatch({ control, name: 'category' });
// ...
{category === 'cad' && <input {...register('drawingNo')} />}

useWatch 只會讓用到它的元件更新,不會牽動整份表單。

送出。 跟 Day 14 的 TanStack Query 接起來:

const update = useMutation({ mutationFn: api.updateFileMeta });

<form onSubmit={handleSubmit(data => update.mutate(data))}>

伺服器回來的錯誤。 例如後端說檔名重複,要顯示在檔名欄位底下:

form.setError('name', { message: '檔名已存在' });

對應 Angular 的 control.setErrors({ duplicate: true })。


七、對照一下

Angular Reactive Forms React Hook Form + Zod
FormGroup、FormControl useForm
formControlName="name" {...register('name')}
Validators.required Zod schema
掛在 FormGroup 上的 validator refine
手動定義表單型別 z.infer 從 schema 推出
FormArray useFieldArray
valueChanges watch / useWatch
control.errors、touched、dirty formState.errors、touchedFields、isDirty
control.setErrors() setError()

今天的結論

這是我 Week 3 第一次覺得:重建出來的東西,跟 Angular 幾乎長得一樣。

Reactive Forms 跟 React Hook Form 走到了同一個結論:表單的狀態不能放在 render 循環裡。一個放進 FormControl 物件,一個放進 DOM 加上自己的 store,畫面都只訂閱需要的那一塊。

第一版之所以卡,是因為我把「受控元件」當成了 React 的標準答案,每一次按鍵都讓多個欄位一起重新拍照。React Hook Form 的解法,反而是退回非受控,讓 DOM 自己保管值。

Day 6 說受控元件是「把雙向綁定拆開給你看」。今天補上另一半:拆開來看是為了理解,不代表每個欄位都要這樣接。

至於 Zod,它給了我一個 Angular 沒有給過的東西:一份規則,同時是驗證、是型別、還能拿到伺服器上用。


上一篇
Day 16|沒有 NgModule 之後,牆要自己蓋
系列文
Angular 工程師的 React 陣痛期:30 天心智模型重建 共 17 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言