iT邦幫忙

2026 iThome 鐵人賽

DAY 15
0
Modern Web

再造輪子:30 天臨摹 React Hook 函式庫,探索背後的設計哲學系列 第 15 篇

【 Day 14 】高頻之下,「誰該被通知」還是效能問題嗎?|使用者輸入(三)

  • 分享至 

  • xImage
  •  

昨天的實驗裡,姓名欄只打了兩個字:先打 L,再打 e。兩種訂閱方式都讓送出按鈕只在 isDirty 變了的時候重新渲染。

如果是一次按鈕點擊,通知錯了人,也許只是多做幾次計算。但實際填表的人,不會只打兩個字。一個名字、一個 Email,往往是一連串的擊鍵,打字熟練的人,一秒就有好幾下。control 每收到一下擊鍵就推送一次,所以哪些元件被通知,也會跟著擊鍵一次又一次地重複。

那麼,在這樣的頻率下,「誰該被通知」還只是效能的問題嗎?

讓我們把擊鍵拉成一串,再替表單加上一行錯誤訊息,看看每一步有哪些元件被通知。

遠端世界 ✓ · 共享狀態 ✓ · 使用者輸入 ✍️ · 瀏覽器 API · 互動行為 · 真實 DOM

連續打字時,每一下擊鍵通知了誰

從 Day 12 到今天,我讓同一份表單跑了四個版本:

  • useState:單純用 useState 管理狀態的版本
  • 人人都聽:把狀態搬進 control,但每個元件都聽整份狀態的版本
  • 讀取時記下:依據 React Hook Form 概念寫的版本
  • 事先宣告:依據 TanStack Form 概念寫的版本

四個版本的元件樹相同,都是三個欄位加一顆讀 isDirty 的送出按鈕,只換掉狀態放在哪裡,以及欄位與送出按鈕怎麼訂閱。每一步結束時,測試只記下哪些元件被通知了,不記次數,也不記時間。

我暫且把每一步有哪些元件被通知,稱為這一步的「通知拓撲」 (notification topology)。因為我們接下來要比較的,不是時間快慢、不是次數多寡,而是通知如何在元件樹裡擴散。

在姓名欄打下第一個字時,isDirty 從 false 變成 true;刪回空白時,它又變回 false。連續打字的過程中,除了這兩步,其餘每一下擊鍵都跟「再打一個字」長得一樣。pnpm test 印出的「再打一個字(L → Le)」那一步是這樣:

元件 useState 人人都聽 讀取時記下 事先宣告
表單本體 通知 — — —
姓名欄 通知 通知 — 通知
Email 欄 通知 通知 — —
留言欄 通知 通知 — —
送出按鈕(讀 isDirty) 通知 通知 — —

接著,讓我們來看看每擊一次鍵,四個版本通知的元件範圍:

  • useState:表單本體,以及底下的每一個元件。
  • 人人都聽:表單本體以外的每一個元件。
  • 讀取時記下:沒有任何元件。
  • 事先宣告:只有姓名欄自己。

連續打字時,這張表的情況就會不斷重複出現。

到這裡,看起來還是一筆帳:被通知的元件少一點,重新計算就少一點。

可是,這樣的話,它跟共享狀態那幾天有什麼不同?Day 12 提過:「共享狀態的問題,是通知太廣;使用者輸入的問題,是通知太密。」太密,是不是只是同一筆帳多記了幾次?

回頭看表上被通知的元件。Email 欄與留言欄重新渲染之後,畫出來的還是同一個空白的欄位;送出按鈕重新渲染之後,也還是同一顆可以按的按鈕。它們有沒有被通知,使用者在畫面上看不出差別。

所以只看這張表,它確實比較像是一筆帳。

但表單裡還有一種元件,它一被通知,畫面就會跟著變。

替 Email 加上一行錯誤訊息

規則只有一條:Email 要有 @。

要讓錯誤訊息出現,control 得先會驗證。我們替 control 的狀態多加一份 errors,並讓建立 control 的人選擇什麼時候驗。

先從制定型別開始,我們從表單記下三種狀態 (FormState):values、isDirty 及 errors。

errors 記的是每個欄位上一次被驗的結果,通過或還沒驗過都是 null。

export type FieldErrors<V extends FormValues> = Readonly<
  Record<keyof V, string | null>
>;

export interface FormState<V extends FormValues> {
  readonly values: V;
  readonly isDirty: boolean;
  readonly errors: FieldErrors<V>;
}

我們會在之前使用的 createFormControl 傳入可選的驗證選項 (ValidationOptions) 作為第二個引數,其中驗證模式 (ValidationMode) 有三種:寫入時驗 (onChange)、離開欄位時驗 (onBlur) 以及送出時驗 (onSubmit);另外 validate 則是驗證欄位的函式。

export type ValidationMode = "onChange" | "onBlur" | "onSubmit";

export interface ValidationOptions<V extends FormValues> {
  readonly mode: ValidationMode;
  readonly validate: (name: keyof V, values: V) => string | null;
}

寫入時,只有 mode 是 onChange 才驗這一個欄位。驗的工作交給 nextErrors,它交回驗完之後的 errors,內容等一下再看。推送的那一行,則從 setValue 裡抽出來成為 push:

const push = (name: keyof V | null): void => {
  for (const listener of [...listeners]) listener({ name });
};

const setValue = (name: keyof V, value: string): void => {
  if (Object.is(state.values[name], value)) return;
  const values = { ...state.values, [name]: value };
  state = {
    values,
    isDirty: differsFrom(values, defaultValues),
    errors:
      validation?.mode === "onChange"
        ? nextErrors(values, [name])
        : state.errors,
  };
  push(name);
};

離開欄位與送出,都不會改變值,只可能改變錯誤,所以兩者共用 revalidate。送出不屬於任何一個欄位,推送時帶 null;而且不論 mode 是哪一種,送出都會驗全部欄位:

const revalidate = (
  targets: readonly (keyof V)[],
  name: keyof V | null,
): void => {
  const errors = nextErrors(state.values, targets);
  if (Object.is(errors, state.errors)) return;
  state = { ...state, errors };
  push(name);
};

const blur = (name: keyof V): void => {
  if (validation?.mode === "onBlur") revalidate([name], name);
};

const submit = (): void => revalidate(names, null);

這裡的 names 是 Object.keys(defaultValues)。這份 control 跟前兩天一樣,是為了觀察通知而寫的最小版本,並不是 production-ready 的實作。

💡 真正的表單函式庫在驗證上還有很多這裡沒做的事,例如非同步驗證、schema 驗證、送出之後改用另一種時機重新驗證(RHF 的 reValidateMode),以及 RHF 另外兩種時機 onTouched 與 all。它們回答的是「怎麼驗」,這裡只保留「什麼時候驗」。

表單這一邊,Email 欄在離開時呼叫 control.blur,表單送出時呼叫 control.submit;錯誤訊息則用昨天照 RHF 臨摹的 useFormState,讀出 errors.email。為了能切換三種時機,最外層放三顆按鈕,每換一種時機,就重新建立一份表單:

import { useState } from "react";

import {
  createFormControl,
  type FormControl,
  type ValidationMode,
} from "./form-control";
import { useFormState } from "./use-form-state";

const defaultValues = { name: "", email: "", message: "" };

type Values = typeof defaultValues;

const validate = (name: keyof Values, values: Values): string | null =>
  name === "email" && !values.email.includes("@") ? "Email 要有 @" : null;

const EmailError = ({ control }: { control: FormControl<Values> }) => {
  const formState = useFormState(control);
  console.log("錯誤訊息 渲染");
  return <p role="alert">{formState.errors.email}</p>;
};

const SignupForm = ({ mode }: { mode: ValidationMode }) => {
  const [control] = useState(() =>
    createFormControl(defaultValues, { mode, validate }),
  );

  return (
    <form
      onSubmit={(event) => {
        event.preventDefault();
        control.submit();
      }}
    >
      <label>
        Email
        <input
          defaultValue={defaultValues.email}
          onChange={(event) => control.setValue("email", event.target.value)}
          onBlur={() => control.blur("email")}
        />
      </label>
      <EmailError control={control} />
      <button type="submit">送出</button>
    </form>
  );
};

const modes: ValidationMode[] = ["onChange", "onBlur", "onSubmit"];

export const App = () => {
  const [mode, setMode] = useState<ValidationMode>("onChange");

  return (
    <>
      {modes.map((m) => (
        <button key={m} type="button" onClick={() => setMode(m)}>
          {m}
        </button>
      ))}
      <SignupForm key={mode} mode={mode} />
    </>
  );
};

打下第一個字,錯誤訊息就出現了

掛載時,主控台先印出一行「錯誤訊息 渲染」。接著選 onChange,在 Email 欄打下 l,主控台再印出一行:

錯誤訊息 渲染

畫面上多了一行「Email 要有 @」。

測試裡的那一棵樹,還多了姓名欄、留言欄與送出按鈕。pnpm test 印出的「在 Email 欄打下第一個字(空白 → l)」那一步,三種時機並排是這樣(讀取時記下的版本):

元件 onChange onBlur onSubmit
表單本體 — — —
姓名欄 — — —
Email 欄 — — —
留言欄 — — —
Email 錯誤訊息(讀 errors.email) 通知 — —
送出按鈕(讀 isDirty) 通知 通知 通知

送出按鈕那一列,昨天已經看過:第一個字讓 isDirty 跨過那條線。不同的是錯誤訊息那一列,只有 onChange 通知了它。

使用者才打了一個字,畫面就說這個 Email 錯了。可是使用者根本還沒有機會打出 @。

這一格的「通知」,跟上一張表的那些不一樣。它不是一次畫面上看不出差別的重新計算,而是使用者眼前多出來的一行字。

遠端資料通常要等一個請求回來才變一次;共享狀態那幾天,多半要等使用者按下一個按鈕。表單不一樣,使用者每打一個字,狀態就變一次,而那一刻,使用者的視線就停在這個欄位上。

當更新頻率高到每次輸入都可能觸發通知,「誰該知道」就從效能優化變成了互動品質。

再打一個字:驗了,但錯誤沒有變

接著打下 e,值從 l 變成 le。在 onChange 下,這一下擊鍵又驗了一次 Email,主控台卻沒有新的輸出。pnpm test 印出的「再打一個字(l → le)」那一步,整張表都是「—」:

元件 onChange onBlur onSubmit
表單本體 — — —
姓名欄 — — —
Email 欄 — — —
留言欄 — — —
Email 錯誤訊息(讀 errors.email) — — —
送出按鈕(讀 isDirty) — — —

明明驗了,為什麼錯誤訊息沒有被通知?

答案要回到剛才先跳過的 nextErrors:

const nextErrors = (
  values: V,
  targets: readonly (keyof V)[],
): FieldErrors<V> => {
  if (validation === null) return state.errors;
  let errors = state.errors;
  for (const name of targets) {
    const error = validation.validate(name, values);
    if (!Object.is(errors[name], error)) errors = { ...errors, [name]: error };
  }
  return errors;
};

它逐一驗過指定的欄位,只有某一格的結果跟上一次不同,才換一份新的 errors;每一格都相同,就交回原本那一份。le 驗出來的仍然是「Email 要有 @」,所以 setValue 換了一份新的 values,errors 卻還是同一份。

推送還是發生了。但昨天的 changedKeys 逐個 key 比較前後兩份狀態,這一次只交回 values。由於錯誤訊息的帳上只有 errors,而 errors 沒有換,所以它沒有被通知。

驗了一次,不代表錯誤變了。

假使 nextErrors 每次驗完都交回一份新的 errors,即使內容一模一樣,錯誤訊息也會在每一下擊鍵被通知一次。畫面上仍是同一行字,但「什麼時候才算變了」的答案,就從「錯誤的內容換了」變成了「又驗了一次」。

所以驗證其實有兩個時間點要決定:什麼時候驗,以及驗完之後,什麼時候才算變了。

三種時機,決定輸入什麼時候算數

le 之後,接著離開 Email 欄,再按下送出,三種時機各走一遍。除了錯誤訊息,其餘各列在三種時機下都相同,所以這裡只把四步裡錯誤訊息那一列排在一起:

時機 打下 l 再打 le 離開 Email 欄 按下送出
onChange 通知 — — —
onBlur — — 通知 —
onSubmit — — — 通知

三種時機下,錯誤訊息都只被通知一次,差別在哪一步:

  • onChange:打下第一個字,錯誤就出現了。使用者還在打,畫面已經在說這一欄錯了。
  • onBlur:使用者離開這一欄時,才看到錯誤。
  • onSubmit:使用者一路填完、按下送出,才第一次知道 Email 少了 @。

三種時機看起來像是一個設定項。但從這張表來看,它們回答的是同一個問題:使用者還在打字的時候,打到一半的輸入算不算數?

onChange 讓每一下擊鍵都算數;onBlur 要等使用者離開這一欄;onSubmit 則等到使用者表示「我填好了」。太早,使用者還沒打完就看到錯誤;太晚,送出之後才知道要回頭改。

所以這三個時機並不是分成對與錯。它們比較像是在決定:錯誤訊息該在使用者打字的哪一刻被通知。

兩個函式庫都把這個決定交給了呼叫端。RHF 用 useForm 的 mode 選一種,預設是 onSubmit;TanStack Form 則把驗證函式分別掛在 validators 的 onChange、onBlur 與 onSubmit 上。兩邊在送出時,也都會驗全部欄位。

💡 真正的 RHF 在送出那一步,跟上表不太一樣。它的 handleSubmit 最後一定會推送一份帶著 errors 的狀態,而 Day 13 提過,它比對的是 key 的名字,不是值;所以讀過 errors 的元件,每按一次送出都會重新渲染,錯誤的內容變沒變都一樣。也就是說,在 onChange 與 onBlur 下,錯誤訊息在按下送出時也會被通知。臨摹的 control 只在錯誤真的變了才推送,所以不重現這一點;它是我用 react-hook-form 7.87.0 實際跑過才確認的。

兩種訂閱方式,兩張不同的表

前面幾張表都是「讀取時記下」的版本。換成「事先宣告」,「再打一個字(l → le)」那一步是這樣:

元件 onChange onBlur onSubmit
表單本體 — — —
姓名欄 — — —
Email 欄 通知 通知 通知
留言欄 — — —
Email 錯誤訊息(讀 errors.email) — — —
送出按鈕(讀 isDirty) — — —

錯誤訊息那一列,兩個版本相同;不同的是 Email 欄。

事先宣告的欄位是受控的,它的值要先經過選擇器,才畫得到 <input> 上,所以每一下擊鍵,它都被自己的值通知。讀取時記下的欄位不讀 control,值留在 DOM 裡,所以它沒有被通知。

這跟昨天看到的是同一件事:訂閱關係由讀取產生時,不讀就沒有訂閱;由呼叫端宣告時,要值就得宣告。

所以這幾張表能說的,並不是哪一邊比較好,而是兩種訂閱來源,推導出了不同的通知拓撲。

被通知的,是使用者看得見的東西

回到今天的問題。把擊鍵拉成一串之後,大多數的擊鍵都長得像「再打一個字」,表上被通知的欄位與按鈕,重新渲染完畫面上看不出差別。

但錯誤訊息被通知的那一刻,使用者就看到了一行字。什麼時候驗、驗完什麼時候才算變了,決定的是那一行字會在哪一下擊鍵出現。

在這樣的頻率下,「誰該被通知」不只是一筆重新渲染的帳,也是使用者打字時會看到什麼。

我們花了三天決定誰該被通知。那如果根本沒有人需要被通知呢?

本文對照 react-hook-form 7.87.0、@tanstack/react-form 1.33.5,查核於 2026-08-31;RHF 的 mode 預設值、handleSubmit,以及 @tanstack/form-core 1.33.5 的 validators,複查於 2026-09-28;實驗跑在 react 19.3.0 與 react-dom 19.3.0,查核於 2026-09-28。


上一篇
【 Day 13 】訂閱關係,是誰寫下的?|使用者輸入(二)
下一篇
【 Day 15 】提交這件事,真的需要持續訂閱嗎?|使用者輸入(四完)
系列文
再造輪子:30 天臨摹 React Hook 函式庫,探索背後的設計哲學 共 17 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言