《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 register
與 handleSubmit
:
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 logicreturn {name: name,onChange: () => {},onBlur: () => {},ref: null}}return {setValue,getValues,register,handleSubmit}}export default createFormControl;
register
register
其實是相對單純的 API,主要就是將 name
作為 identifer 注入到 _fields
內,並回傳 name
、onChange
跟 ref
,每當 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> ); }