Writing

《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 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