Writing

《React 101》React Hook Form 的原理與實作

文章發表於

前言

本篇文章將更深入地介紹 react-hook-form 這個套件本身是如何實作

基本概念

在 React 要讓頁面重新渲染的方式只有透過使用 useState 以及 useReducer 更新狀態。當狀態改變時,React 會用 Reconciliation 演算法遍歷整個元素樹,並與先前的樹進行比對,找出需要更新的節點,最後再對 DOM 樹進行相應的更新。

值得注意的是重新渲染是需要成本的,如果沒有適當的優化會造成整個頁面因為效能不足而掉幀,也就是使用者常說的頁面很卡,而這也是 react-hook-form 想要解決的問題,接下來將介紹它是如何透過 Observer 模式來解決此問題。

Observer 模式

觀察者模式是近年相當常見的設計模式,特別是在需要用來管理狀態的套件,像是 Zustand 或是 react-hook-form

其核心邏輯主要就是透過註冊監聽者 (listener) 的方式,只有狀態改變時才會通知給註冊者。任何資料上有變動都是先將最新資料存在內部的資料結構中,並不直接與透過 useState 與 React 做掛鉤。儘管這樣要寫更多邏輯來監聽內部資料的變動到通知 React 什麼時候做重新渲染,但這是一種更有效率方式來解決重新渲染所帶來的成本。

下面的範例簡單展示 Zustand 原始碼,並呈現 Zustand 如何透過監聽者的方式去優化 React 的效能,這同時也是類似於 react-hook-form 的核心邏輯!

import { useState, useLayoutEffect, useEffect, useRef } from 'react';
import { createSubject, useStore } from './zustand';
import { preinit} from 'react-dom'


const formStore = createSubject({
  firstName: '',
  lastName: '',
  email: '',
});

function RenderCounter({ name }) {
  const count = useRef(0);
  count.current += 1;
  return (
    <span className="absolute top-1 right-1 bg-blue-100 text-blue-800 text-xs font-medium px-2 py-0.5 rounded">
      {name} Render: {count.current}
    </span>
  );
}

function FirstNameDisplay() {
  const firstName = useStore(formStore, (state) => state.firstName);
  return (
    <div className="relative p-4 border rounded-lg bg-gray-50">
      <RenderCounter name="FirstNameDisplay" />
      <p className="text-gray-600">名字: <span className="font-bold text-black">{firstName}</span></p>
    </div>
  );
}

function LastNameDisplay() {
  const lastName = useStore(formStore, (state) => state.lastName);
  return (
    <div className="relative p-4 border rounded-lg bg-gray-50">
      <RenderCounter name="LastNameDisplay" />
      <p className="text-gray-600">姓氏: <span className="font-bold text-black">{lastName}</span></p>
    </div>
  );
}

function FirstNameInput() {
  const firstName = useStore(formStore, (state) => state.firstName);

  const handleChange = (e) => {
    formStore.setState({ firstName: e.target.value });
  };

  return (
    <div className="relative">
      <RenderCounter name="FirstNameInput" />
      <label htmlFor="firstName" className="block text-sm font-medium text-gray-700">名字</label>
      <input
        type="text"
        id="firstName"
        value={firstName}
        onChange={handleChange}
        className="mt-1 block w-full px-3 py-2 bg-white border border-gray-300 rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
      />
    </div>
  );
}

function LastNameInput() {
    const lastName = useStore(formStore, (state) => state.lastName);

    const handleChange = (e) => {
        formStore.setState({ lastName: e.target.value });
    };

    return (
        <div className="relative">
            <RenderCounter name="LastNameInput" />
            <label htmlFor="lastName" className="block text-sm font-medium text-gray-700">姓氏</label>
            <input
                type="text"
                id="lastName"
                value={lastName}
                onChange={handleChange}
                className="mt-1 block w-full px-3 py-2 bg-white border border-gray-300 rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
            />
        </div>
    );
}

export default function App() {
  preinit("https://cdn.tailwindcss.com", { as: "script", fetchPriority: "high" });

  return (
    <div className="bg-gray-100 min-h-screen p-8 font-sans">
      <div className="max-w-2xl mx-auto bg-white p-6 rounded-xl shadow-lg">
        <h1 className="text-2xl font-bold text-gray-800 mb-2">Pub-Sub 模式表單</h1>
        <p className="text-gray-500 mb-6">
          觀察右上角的渲染計數器。當您在一個輸入框中打字時,只有與該輸入框相關的元件會重新渲染。
        </p>

        <div className="space-y-4">
          <FirstNameInput />
          <LastNameInput />
        </div>

        <div className="mt-8 space-y-4">
          <h2 className="text-lg font-semibold text-gray-700">顯示區域</h2>
          <FirstNameDisplay />
          <LastNameDisplay />
        </div>
      </div>
    </div>
  );
}

createSubject 中的 subscribe 就是建立觀察者,並將內部狀態儲存在 state 當中,getState 就是讀取內部狀態的資料,而 setState 就是每當有新的資料變動時,會直接將新與舊 state 進行合併,並推播給所有有監聽的觀察者。useStore 可以看成是內部狀態與 React 元件之間的橋樑,儘管每次內部狀態更新時都會將最新狀態推播到有註冊的監聽者裡,但透過 selector 只去監聽必要狀態的更新,這避免了不必要的重新渲染。

API

createSubject

createSubject 與上面 Zustand 的核心概念是一樣的,就不再多做贅述了,如果想要看 react-hook-form 的實踐可以參考這裡,核心邏輯是一樣的!

function createSubject(initialState) {
const _observers = new Set();
const next = (value) => {
for (const observer of _observers) {
observer.next && observer.next(value);
}
};
const subscribe = (callback) => {
_observers.add(callback);
return {
unsubscribe: () => {
_observers.delete(callback);
},
};
};
const unsubscribe = () => {
_observers.clear();
}
return { next, subscribe, unsubscribe };
}

createFormControl

createFormControl 是 react-hook-form 整個套件裡面的核心,它控制了表單的各種狀態 (isSubmitted, error ...)、 核心 API 的邏輯 (register, handleSubmit, ...),在實作之前先來複習一下最重要的兩個 API registerhandleSubmit:

  • register 給定欄位名稱即可將該欄位綁定到 react-hook-form 的內部狀態中,而該 API 會回傳 onChange, onBlur, name 以及 ref

    <input {...register("firstName")} placeholder="First name" />
  • handleSubmit 是一個庫里函式,第一個接收的是 onSubmit 函式,再來就是 form 本身的 evt,當 form 觸發表單送出時,此 API 會進行內部的狀態更新、驗證表單最後再呼叫 onSubmit

    const { register, handleSubmit } = useForm();
    const onSubmit = (data) => console.log(data) // value from first name
    <form onSubmit={handleSubmit(onSubmit)}>
    <input {...register("firstName")} placeholder="First name" />
    </form>

首先先從 createFormControl 的 starter code 開始,內部狀態有三種分別為 _fields 存取欄位中的 metadata, _formValues 主要是用來更新欄位中的值,最後 _formState 則是儲存表單中任何的資訊,像是 isSubmitting, isSubmitted 等等。

API 像是 getValues 以及 setValue 都是與先前介紹 Zustand 原始碼類似的概念,接下來我們將介紹 register 以及 handleSubmit 是如何實作的。

function createFormControl() {
let _fields = {};
let _formValues = {};
let _formState = {
isSubmitting: false,
isSubmitted: false,
isSubmitSuccessful: false,
submitCount: 0,
};
const _subjects = {
state: createSubject()
};
const setValue = (name, value) => {
_formValues[name] = value;
_subjects.state.next({
name,
values: _formValues,
});
};
const getValues = () => _formValues;
const handleSubmit = (onSubmit) => async (evt) => {
if (evt) {
evt.preventDefault && evt.preventDefault();
evt.persist && evt.persist();
}
// TODO: adding the submit logic
};
const register = (name) => {
// TODO: adding the register logic
return {
name: name,
onChange: () => {},
onBlur: () => {},
ref: null
}
}
return {
setValue,
getValues,
register,
handleSubmit
}
}
export default createFormControl;

register

register 其實是相對單純的 API,主要就是將 name 作為 identifer 注入到 _fields 內,並回傳 nameonChangeref,每當 onChange 被觸發時,更新最新的值到 formValues

const register = (name) => {
if (!_fields[name]) {
_fields[name] = {
_f: { name, ref: null },
}
}
return {
name: name,
onChange: (evt) => {
setValue(name, evt.target.value)
},
ref: (element) => {
_fields[name]._f.ref = element;
}
}
}

handleSubmit

handleSubmit 做的就是在每一階段更新 _formState 的狀態,從表單資料送出到回傳成功或是錯誤,每一階段都會透過向監聽者進行推播。

const _updateFormState = (updates) => {
_formState = { ..._formState, ...updates };
_subjects.state.next({
...updates,
values: _formValues,
});
};
const handleSubmit = (onSubmit) => async (evt) => {
if (evt) {
evt.preventDefault && evt.preventDefault();
evt.persist && evt.persist();
}
_updateFormState({ isSubmitting: true });
try {
const submittedValue = structuredClone(_formValues)
await onSubmit(submittedValue);
_updateFormState({
isSubmitted: true,
isSubmitting: false,
isSubmitSuccessful: true,
submitCount: _formState.submitCount + 1,
});
} catch (err) {
_updateFormState({
isSubmitted: true,
isSubmitting: false,
isSubmitSuccessful: false,
submitCount: _formState.submitCount + 1,
});
}
};

useForm

useForm 就是 react-hook-form 的內部 API 與 React 串聯的橋樑。

import { useRef } from "react";
import createFormControl from "../core/createFormContorl";
function useForm() {
const controlRef = useRef(null);
if (!controlRef.current) {
controlRef.current = createFormControl();
}
return {
register: controlRef.current.register,
setValue: controlRef.current.setValue,
getValues: controlRef.current.getValues,
handleSubmit: controlRef.current.handleSubmit,
// Expose internal refs for advanced hooks (useWatch, useFormState)
control: controlRef.current,
};
}
export default useForm;

迷你版本的 react-hook-form 大概就完成了,目前為止,我們可以使用 useForm 的 API 來註冊輸入欄位到內存,然後表單送出時回傳當前表單的值

import { useState, useLayoutEffect, useEffect, useRef } from 'react';
import { preinit} from 'react-dom'
import useForm from './useForm';

function RenderCounter({ name }) {
  const count = useRef(0);
  count.current += 1;
  return (
    <span className="absolute top-1 right-1 bg-blue-100 text-blue-800 text-xs font-medium px-2 py-0.5 rounded">
      {name} Render: {count.current}
    </span>
  );
}

export default function App() {
  preinit("https://cdn.tailwindcss.com", { as: "script", fetchPriority: "high" });

  const { register, handleSubmit } = useForm();
  const onSubmit = (data) => console.log('--->', data)

  return (
    <div className="bg-gray-100 min-h-screen p-8 font-sans">
      <div className="max-w-2xl mx-auto bg-white p-6 rounded-xl shadow-lg">
        <h1 className="text-2xl font-bold text-gray-800 mb-2">Pub-Sub 模式表單</h1>
        <p className="text-gray-500 mb-6">
          觀察右上角的渲染計數器。當您在一個輸入框中打字時,只有與該輸入框相關的元件會重新渲染。
        </p>

        <form className="space-y-4" onSubmit={handleSubmit(onSubmit)}>
          <div className="relative">
            <RenderCounter name="FirstNameInput" />
            <label htmlFor="firstName" className="block text-sm font-medium text-gray-700">名字</label>
            <input
              type="text"
              id="firstName"
              {...register("firstName")}
              className="mt-1 block w-full px-3 py-2 bg-white border border-gray-300 rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
            />
          </div>
          <div className="relative">
              <RenderCounter name="LastNameInput" />
              <label htmlFor="lastName" className="block text-sm font-medium text-gray-700">姓氏</label>
              <input
                  type="text"
                  id="lastName"
                  {...register("lastName")}
                  className="mt-1 block w-full px-3 py-2 bg-white border border-gray-300 rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
              />
          </div>
          <button type="submit">Submit</button>
        </form>
      </div>
    </div>
  );
}

useWatch

有時候我們可能需要去監聽表單欄位裡面某個特定值的變動,這也是 useWatch 的功能,可以傳入所要監聽的欄位名稱 name,該值可以是陣列 (監聽多個欄位)、字串 (監聽單一個欄位)或是 undefined (監聽全部欄位),這樣就可以像是先前 Zustand 所展示過的,只針對特定值的改動進行重新渲染。

import { useState, useLayoutEffect, useEffect, useRef } from 'react';
import { preinit } from 'react-dom'
import useForm from './useForm';
import useWatch from './useWatch';

function RenderCounter({ name }) {
  const count = useRef(0);
  count.current += 1;
  return (
    <span className="absolute top-1 right-1 bg-blue-100 text-blue-800 text-xs font-medium px-2 py-0.5 rounded">
      {name} Render: {count.current}
    </span>
  );
}


function FirstNameDisplay({ control }) {
  const firstName = useWatch({ control, name:"firstName" });
  return (
    <div className="relative p-4 border rounded-lg bg-gray-50">
      <RenderCounter name="FirstNameDisplay" />
      <p className="text-gray-600">名字: <span className="font-bold text-black">{firstName}</span></p>
    </div>
  );
}

function LastNameDisplay({ control }) {
  const lastName = useWatch({ control, name:"lastName" });
  return (
    <div className="relative p-4 border rounded-lg bg-gray-50">
      <RenderCounter name="LastNameDisplay" />
      <p className="text-gray-600">姓氏: <span className="font-bold text-black">{lastName}</span></p>
    </div>
  );
}

export default function App() {
  preinit("https://cdn.tailwindcss.com", { as: "script", fetchPriority: "high" });

  const { register, handleSubmit, control } = useForm();
  const onSubmit = (data) => console.log('--->', data)

  return (
    <div className="bg-gray-100 min-h-screen p-8 font-sans">
      <div className="max-w-2xl mx-auto bg-white p-6 rounded-xl shadow-lg">
        <h1 className="text-2xl font-bold text-gray-800 mb-2">Pub-Sub 模式表單</h1>
        <p className="text-gray-500 mb-6">
          觀察右上角的渲染計數器。當您在一個輸入框中打字時,只有與該輸入框相關的元件會重新渲染。
        </p>

        <form className="space-y-4" onSubmit={handleSubmit(onSubmit)}>
          <div className="relative">
            <RenderCounter name="FirstNameInput" />
            <label htmlFor="firstName" className="block text-sm font-medium text-gray-700">名字</label>
            <input
              type="text"
              id="firstName"
              {...register("firstName")}
              className="mt-1 block w-full px-3 py-2 bg-white border border-gray-300 rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
            />
          </div>
          <div className="relative">
              <RenderCounter name="LastNameInput" />
              <label htmlFor="lastName" className="block text-sm font-medium text-gray-700">姓氏</label>
              <input
                  type="text"
                  id="lastName"
                  {...register("lastName")}
                  className="mt-1 block w-full px-3 py-2 bg-white border border-gray-300 rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
              />
          </div>
          <button type="submit">Submit</button>
        </form>

         <div className="mt-8 space-y-4">
           <h2 className="text-lg font-semibold text-gray-700">顯示區域</h2>
           <FirstNameDisplay control={control} />
           <LastNameDisplay control={control} />
         </div>
      </div>
    </div>
  );
}

如果您喜歡這篇文章,請點擊下方按鈕分享給更多人,這將是對筆者創作的最大支持和鼓勵。
Buy me a coffee