《React 101》React Hook Form 的原理与实现
- 文章發表於
前言
本篇文章将更深入地介绍 react-hook-form 这个库本身是如何实现的
基本概念
在 React 中让页面重新渲染的方式只有通过使用 useState
以及 useReducer
更新状态。当状态改变时,React 会用 Reconciliation 算法遍历整个元素树,并与先前的树进行比对,找出需要更新的节点,最后再对 DOM 树进行相应的更新。
值得注意的是重新渲染是需要成本的,如果没有适当的优化会造成整个页面因为性能不足而掉帧,也就是用户常说的页面很卡,而这也是 react-hook-form 想要解决的问题,接下来将介绍它是如何通过 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> ); }