RUA!

组件泛型实例-封装可复用的表单组件

TABLE OF CONTENTS

当前很多 UI 库都会为我们集成可复用的 Form 组件,并且是开箱即用。但有时候我们往往可能需要为自己的组件集成 Form。单纯的手动管理所有的状态可能不是件理想的活,尤其是表单验证。

React Hook Forms 为我们提供了完善的状态管理,并且可以集成到任何组件中去。

React Hook Form - performant, flexible and extensible form library
Performant, flexible and extensible forms with easy-to-use validation.
https://react-hook-form.com/

你可能会问,如今已经有了像是 MUI、Ant Design 等此类优秀的组件库,为什么还需要使用 React Hook Forms 来管理表单。

MUI: The React component library you always wanted

MUI: The React component library you always wanted
MUI provides a simple, customizable, and accessible library of React components. Follow your own design system, or start with Material Design.
https://mui.com/zh/

虽然一些优秀的成熟组件库会为我们提供良好的表单解决方案,但它终究需要与组件库一起使用。而并非只是提供表单的状态管理,并没有完全的与组件库解耦合。

同时,当我们使用诸如 Daisyui 等此类的 CSS 组件时,它们是与状态完全解耦合的。我们需要自己为其维护状态。

daisyUI — Tailwind CSS Components ( version 4 update is here )
Best Tailwind Components Library - Free UI components for Tailwind CSS
https://daisyui.com/

Hook our form

对于一个表单来说,提供的表单项越多,所需要的状态管理就越繁琐。不仅仅是状态管理,后续的表单验证才是一个表单的核心所在。

React Hook Forms 对 TypeScript 支持良好,有了 TypeScript 我们就可以在开发时验证表单类型。而表单的数据类型也是后续封装通用组件较为繁琐的一个地方。

import { useForm } from 'react-hook-form';

type Pet = 'Cat' | 'Dog';
type FormData = {
  firstName: string;
  lastName: string;
  favorite: Pet;
};

export default function App() {
  const {
    register,
    handleSubmit,
    formState: { errors },
  } = useForm<FormData>();
  const onSubmit = handleSubmit((data) => console.log(data));

  return (
    <div>
      <form onSubmit={onSubmit}>
        <div>
          <label htmlFor="firstname">First name:</label>
          <input type="text" id="firstname" {...register('firstName')} />
        </div>

        <div>
          <label htmlFor="lastname">Last name:</label>
          <input type="text" id="lastname" {...register('lastName')} />
        </div>

        <div>
          <label htmlFor="favorite">Favorite pet:</label>
          <select id="favorite" {...register('favorite')}>
            <option value="cat">Cat</option>
            <option value="dog">Dog</option>
          </select>
        </div>

        <button>Submit</button>
      </form>
    </div>
  );
}

React Hook Forms 在使用方面,使用了一个 register 函数代替了我们为每个表单项管理状态的步骤。从写法上就可以看出,这个函数返回了我们的表单所需要的属性,以及其状态。

<input type="text" id="firstname" {...register('firstName')} />

在表单提交方面,handleSubmit 方法接受一个回调,其参数就是表单输入后的状态。

const onSubmit = handleSubmit((data) => console.log(data));

表单验证通过后,就可以成功调用这个函数,以实现我们的表单提交。

这是一段最基础的用法,没有表单验证提示,仅仅只是接受任何用户输入的数据。并且同样的组件也没有实现复用。

Input 组件

封装一个可复用的 Input 组件可能是再简单不过的事情了,对于其参数类型,主要部分还是来自于 HTMLInput 。我们只需要个别定义的属性,再利用剩余参数将其全部赋值给真正的 input

export type FormInputProps = {
  label?: string | undefined;
} & DetailedHTMLProps<InputHTMLAttributes<HTMLInputElement>, HTMLInputElement>;
const Input = ({ name, label, ...rest }: FormInputProps) => {
  return (
    <>
      <label htmlFor={name}>{label}</label>

      <S.Wrapper>
        <S.Input name={name} {...rest} />
      </S.Wrapper>
    </>
  );
};

用起来自然也是和常见的组件一样方便:

<div>
  <Input name="firstname" label="First name:" />
</div>

<div>
  <Input name="lastname" label="Last name:" />
</div>

但是如果仅仅只是这样,我们的组件还不能与 React Hook Forms 一起工作。因为其核心部分 register 函数还无法传递给我们的 Input 组件。也就是说我们的组件现在还是不可控的,这时候再尝试提交就会发现无法获取其状态。

无法获取其状态无法获取其状态

当然我们不能简单的将 register 函数塞给 Input 组件,因为它还没有合适的签名。register 函数会根据表单的数据签名和不同的表单项来实现自己的签名。

register 函数的签名中就可以看出,它接受一个泛型,该泛型就是对应的表单项类型。

register: <"firstName">(name: "firstName", options?: ...)

也就是 FormData 中的 firstName

type FormData = {
  firstName: string;
  lastName: string;
  favorite: Pet;
};

没错,要想正确的给组件中的 register 函数签名,我们就得给我们的函数式组件上个泛型。

泛型

在考虑给组件添加一个泛型之前,需要先简单的了解下泛型是如何工作的。

一个函数的泛型可以非常的简单,它代表了一个任意的类型值(当然也可以对其进行约束)。并根据指定的参数为泛型时,自动推断该类型值。

const logAndReturn = <T extends unknown>(target: T) => {
  console.log(target);
  return target;
};

// const logAndReturn: <42>(target: 42) => 42
logAndReturn(42);
// const logAndReturn: <"42">(target: "42") => "42"
logAndReturn('42');

类型别名中的泛型

类型别名中的泛型与函数不同的是,它需要手动传递一个函数的泛型值(或来自其他地方的泛型),并根据该泛型来决定其值。并且如果泛型有约束的话,还需要符合其约束。

例如,我们有一个描述个人的类型别名:

type Person<T extends number | string> = {
  name: string;
  age: number;
  favorite: T;
};

而我们需要编写一个函数,根据其 favorite 来决定打印的值。函数大概长这样:

const sayIt = <T extends number | string>(p: Person<T>) => {
  const type = typeof p.favorite;
  switch (type) {
    case 'string':
      console.log(`My favorite word is: ${p.favorite}`);
      return;
    case 'number':
      console.log(`My favorite number is: ${p.favorite}`);
      return;
  }
};

当指定参数为 p: Person<T> 时,就需要将函数的泛型传递给类型别名。且类型别名中的泛型约束在了 <T extends number | string> 之间,函数必须保证使其子类型。否则就会提示无法满足其类型。

未约束的泛型未约束的泛型

和参数类型,泛型也是向下兼容的,只要保证其类型是子类型即可。也就是说这样也是可以的 const sayIt = <T extends 42>(p: Person<T>) => {} 。数字 42 是 number 类型的子类型。

随后在调用函数时,就能发现泛型给我们带来的作用了。

传递数字给泛型传递数字给泛型 传递字符串给泛型传递字符串给泛型

React 中的泛型

我们的 React 函数组件也是一个函数,对于泛型的规则同样适用。

来看一个简单的小组件,该组件可以以一个常见的对象类型 Record<string, unknown> 来根据指定的 key 访问其值,并展示在 DOM 上。

import "./styles.css";
import Child from "./Child";

const testData = {
  name: "xfy",
  age: 18
};

export default function App() {
  return (
    <div className="App">
      <h1>Hello CodeSandbox</h1>
      <h2>Start editing to see some magic happen!</h2>

      <div>
        <Child data={testData} name="name" />
      </div>
    </div>
  );
}

例如,这样的一个值:

const testData = {
  firstName: 'xfy',
  lastName: 'xfyxfy',
};

当传递其对应的 key 时,我们的子组件就会展示对应的属性。也就是 data[key] ,这是再简单不过的一个属性访问方式了。

<Child data={testData} name="firstName" />

但不仅如此,我们还希望我们的子组件能够根据已经存在的值,推断出我们能够传递的 key。

类型推断类型推断

这正是泛型的作用。

首先,我们子组件的参数签名必然需要一个泛型。并且我们将泛型约束在为一个常见的对象 Record<string, unknown>,且不在乎属性值具体是什么类型(unknown)。

type Props<T extends Record<string, unknown>> = {
  name: keyof T;
  data: T;
};

这便是我们组件的参数具体的签名。还记得上述类型别名需要将函数的泛型传递给它吗?接下来就是要给函数式组件添加一个泛型,并将其传递给 Props

我们的组件也是一个标准的函数,所以接下来就简单多了。只需要将泛型正确的约束,并传递给别名即可。

const Child = <T extends Record<string, unknown>>({
  name,
  data,
}: Props<T>) => {};
const Child = <T extends Record<string, unknown>>({ name, data }: Props<T>) => {
  const [showName, setShowName] = useState<T[keyof T]>();
  const valid = () => {
    console.log(data[name]);
    setShowName(data[name]);
  };

  return (
    <>
      <div>{name}</div>
      <button onClick={valid}>Show {name}</button>

      <div>{JSON.stringify(showName)}</div>
    </>
  );
};

带有泛型的 Input 组件

register 函数对表单项的验证与上述较为类似,它也会根据表单项的 key 来决定传递对应的 name。为了满足 register 函数,可复用的 Input 组件就得需要一个泛型,用来接受不同的表单数据类型。

React hook forms 为我们提前准备好了适用于 register 函数的类型别名 UseFormRegister ,它会接受一个泛型,该泛型就是我们的表单数据类型。

所以 register 函数的签名看起来就像这样 register?: UseFormRegister<T>; 这里的 T 就是我们的表单类型。但是我们还不知道传入当前组件中的表单类型是什么,所以我们的组件参数签名也需要一个泛型。

所以这里我们的组件参数看起来是这样的:

export type FormInputProps<TFormValues> = {
  name: Path<TFormValues>;
  label?: string | undefined;
  rules?: RegisterOptions;
  register?: UseFormRegister<TFormValues>;
} & DetailedHTMLProps<InputHTMLAttributes<HTMLInputElement>, HTMLInputElement>;

值得注意的是,这里给 input 使用的 name 属性。因为 register 函数注册时使用的名称需要确保为表单类型的中的一个。所以这里需要使用 React hook forms 导出的 Path<TFormValues> 类型,以配合 register 函数。

这里就和上述泛型组件很相似了,接下来要做的就是将组件的泛型传递给参数签名:

const Input = <T extends Record<string, unknown>>({
  name,
  label,
  ...rest
}: FormInputProps<T>) => {};

这里给组件的泛型小小的约束一下,我们希望传递过来的表单类型是一个普通的对象结构 <T extends Record<string, unknown>>

不仅如此,还不能忘了 register 函数还需要注册在 DOM 上。

<S.Input
  err={!!errorMsg}
  name={name}
  {...(register && register(name, rules))}
  {...rest}
/>

得益于泛型的功劳,我们将 register 函数传递给 Input 组件时,我们的组件就知道了这次表单的类型。并且确定了 name 属性的类型。

类型推断类型推断

这是因为 register 函数本身的签名:const register: UseFormRegister<FormData> 。这才使得我们的组件成功接受到了泛型。

再添加一些 rules 以及验证未通过时的提示,这样一个可复用的 React hook form 组件就封装好了。

<Input
  name="lastName"
  label="Last name:"
  placeholder="Last Name"
  register={register}
  rules={{ required: true }}
  errorMsg={errors.lastName && 'Please input'}
/>
Loadingrua rua rua...