Files
DefectingCat.github.io/content/posts/generic-component-encapsulate-reusable-component.mdx
DefectingCat 3c001e0028 Fix name
2023-01-29 10:13:55 +08:00

358 lines
12 KiB
Plaintext
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

---
title: 组件泛型实例-封装可复用的表单组件
date: '2022-08-12'
tags: [TypeScript, React]
---
当前很多 UI 库都会为我们集成可复用的 Form 组件,并且是开箱即用。但有时候我们往往可能需要为自己的组件集成 Form。单纯的手动管理所有的状态可能不是件理想的活尤其是表单验证。
[React Hook Forms](https://react-hook-form.com/) 为我们提供了完善的状态管理,并且可以集成到任何组件中去。
你可能会问,如今已经有了像是 MUI、Ant Design 等此类优秀的组件库,为什么还需要使用 React Hook Forms 来管理表单。
[MUI: The React component library you always wanted](https://mui.com/zh/)
虽然一些优秀的成熟组件库会为我们提供良好的表单解决方案,但它终究需要与组件库一起使用。而并非只是提供表单的状态管理,并没有完全的与组件库解耦合。
同时,当我们使用诸如 [Daisyui](https://daisyui.com/) 等此类的 CSS 组件时,它们是与状态完全解耦合的。我们需要自己为其维护状态。
## Hook our form
对于一个表单来说,提供的表单项越多,所需要的状态管理就越繁琐。不仅仅是状态管理,后续的表单验证才是一个表单的核心所在。
React Hook Forms 对 TypeScript 支持良好,有了 TypeScript 我们就可以在开发时验证表单类型。而表单的数据类型也是后续封装通用组件较为繁琐的一个地方。
<RUASandpack
template="react-ts"
files={{
'/App.tsx':
sandpack['generic-component-encapsulate-reusable-component'].hookApp,
}}
customSetup={{
dependencies: {
'@emotion/react': '^11.10.0',
'@emotion/styled': '^11.10.0',
'react-hook-form': '^7.34.0',
},
}}
/>
React Hook Forms 在使用方面,使用了一个 `register` 函数代替了我们为每个表单项管理状态的步骤。从写法上就可以看出,这个函数返回了我们的表单所需要的属性,以及其状态。
```tsx
<input type="text" id="firstname" {...register('firstName')} />
```
在表单提交方面,`handleSubmit` 方法接受一个回调,其参数就是表单输入后的状态。
```tsx
const onSubmit = handleSubmit((data) => console.log(data));
```
表单验证通过后,就可以成功调用这个函数,以实现我们的表单提交。
这是一段最基础的用法,没有表单验证提示,仅仅只是接受任何用户输入的数据。并且同样的组件也没有实现复用。
## Input 组件
封装一个可复用的 `Input` 组件可能是再简单不过的事情了,对于其参数类型,主要部分还是来自于 `HTMLInput` 。我们只需要个别定义的属性,再利用剩余参数将其全部赋值给真正的 `input`
```tsx
export type FormInputProps = {
label?: string | undefined;
} & DetailedHTMLProps<InputHTMLAttributes<HTMLInputElement>, HTMLInputElement>;
```
```tsx
const Input = ({ name, label, ...rest }: FormInputProps) => {
return (
<>
<label htmlFor={name}>{label}</label>
<S.Wrapper>
<S.Input name={name} {...rest} />
</S.Wrapper>
</>
);
};
```
用起来自然也是和常见的组件一样方便:
```tsx
<div>
<Input name="firstname" label="First name:" />
</div>
<div>
<Input name="lastname" label="Last name:" />
</div>
```
但是如果仅仅只是这样,我们的组件还不能与 React Hook Forms 一起工作。因为其核心部分 `register` 函数还无法传递给我们的 `Input` 组件。也就是说我们的组件现在还是不可控的,这时候再尝试提交就会发现无法获取其状态。
<Image
src={
'/images/p/generic-component-encapsulate-reusable-component/Untitled.png'
}
alt="无法获取其状态"
width="493"
height="478"
/>
当然我们不能简单的将 `register` 函数塞给 `Input` 组件,因为它还没有合适的签名。`register` 函数会根据表单的数据签名和不同的表单项来实现自己的签名。
从 `register` 函数的签名中就可以看出,它接受一个泛型,该泛型就是对应的表单项类型。
```tsx
register: <"firstName">(name: "firstName", options?: ...)
```
也就是 `FormData` 中的 `firstName`
```tsx
type FormData = {
firstName: string;
lastName: string;
favorite: Pet;
};
```
没错,要想正确的给组件中的 `register` 函数签名,我们就得给我们的函数式组件上个泛型。
## 泛型
在考虑给组件添加一个泛型之前,需要先简单的了解下泛型是如何工作的。
一个函数的泛型可以非常的简单,它代表了一个任意的类型值(当然也可以对其进行约束)。并根据指定的参数为泛型时,自动推断该类型值。
```ts
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');
```
### 类型别名中的泛型
类型别名中的泛型与函数不同的是,它需要手动传递一个函数的泛型值(或来自其他地方的泛型),并根据该泛型来决定其值。并且如果泛型有约束的话,还需要符合其约束。
例如,我们有一个描述个人的类型别名:
```ts
type Person<T extends number | string> = {
name: string;
age: number;
favorite: T;
};
```
而我们需要编写一个函数,根据其 `favorite` 来决定打印的值。函数大概长这样:
```tsx
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>` 之间,函数必须保证使其子类型。否则就会提示无法满足其类型。
<Image
src={
'/images/p/generic-component-encapsulate-reusable-component/Untitled-1.png'
}
alt="未约束的泛型"
width="516"
height="131"
/>
和参数类型,泛型也是向下兼容的,只要保证其类型是子类型即可。也就是说这样也是可以的 `const sayIt = <T extends 42>(p: Person<T>) => {}` 。数字 42 是 `number` 类型的子类型。
随后在调用函数时,就能发现泛型给我们带来的作用了。
<Image
src={
'/images/p/generic-component-encapsulate-reusable-component/Untitled-2.png'
}
alt="传递数字给泛型"
width="416"
height="158"
/>
<Image
src={
'/images/p/generic-component-encapsulate-reusable-component/Untitled-3.png'
}
alt="传递字符串给泛型"
width="504"
height="156"
/>
### React 中的泛型
我们的 React 函数组件也是一个函数,对于泛型的规则同样适用。
来看一个简单的小组件,该组件可以以一个常见的对象类型 `Record<string, unknown>` 来根据指定的 key 访问其值,并展示在 DOM 上。
<RUASandpack
template="react-ts"
files={{
'/App.tsx':
sandpack['generic-component-encapsulate-reusable-component'].genericApp,
'/Child.tsx':
sandpack['generic-component-encapsulate-reusable-component'].genericChild,
}}
/>
例如,这样的一个值:
```tsx
const testData = {
firstName: 'xfy',
lastName: 'xfyxfy',
};
```
当传递其对应的 key 时,我们的子组件就会展示对应的属性。也就是 `data[key]` ,这是再简单不过的一个属性访问方式了。
```tsx
<Child data={testData} name="firstName" />
```
但不仅如此,我们还希望我们的子组件能够根据已经存在的值,推断出我们能够传递的 key。
<Image
src={
'/images/p/generic-component-encapsulate-reusable-component/Untitled-4.png'
}
alt="类型推断"
width="743"
height="118"
/>
这正是泛型的作用。
首先,我们子组件的参数签名必然需要一个泛型。并且我们将泛型约束在为一个常见的对象 `Record<string, unknown>`且不在乎属性值具体是什么类型unknown
```tsx
type Props<T extends Record<string, unknown>> = {
name: keyof T;
data: T;
};
```
这便是我们组件的参数具体的签名。还记得上述类型别名需要将函数的泛型传递给它吗?接下来就是要给函数式组件添加一个泛型,并将其传递给 `Props` 。
我们的组件也是一个标准的函数,所以接下来就简单多了。只需要将泛型正确的约束,并传递给别名即可。
```tsx
const Child = <T extends Record<string, unknown>>({
name,
data,
}: Props<T>) => {};
```
```tsx
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` 就是我们的表单类型。但是我们还不知道传入当前组件中的表单类型是什么,所以我们的组件参数签名也需要一个泛型。
所以这里我们的组件参数看起来是这样的:
```tsx
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` 函数。
这里就和上述泛型组件很相似了,接下来要做的就是将组件的泛型传递给参数签名:
```tsx
const Input = <T extends Record<string, unknown>>({
name,
label,
...rest
}: FormInputProps<T>) => {};
```
这里给组件的泛型小小的约束一下,我们希望传递过来的表单类型是一个普通的对象结构 `<T extends Record<string, unknown>>` 。
不仅如此,还不能忘了 `register` 函数还需要注册在 DOM 上。
```tsx
<S.Input
err={!!errorMsg}
name={name}
{...(register && register(name, rules))}
{...rest}
/>
```
得益于泛型的功劳,我们将 `register` 函数传递给 `Input` 组件时,我们的组件就知道了这次表单的类型。并且确定了 `name` 属性的类型。
<Image
src={
'/images/p/generic-component-encapsulate-reusable-component/Untitled-5.png'
}
alt="类型推断"
width="623"
height="230"
/>
这是因为 `register` 函数本身的签名:`const register: UseFormRegister<FormData>` 。这才使得我们的组件成功接受到了泛型。
再添加一些 `rules` 以及验证未通过时的提示,这样一个可复用的 React hook form 组件就封装好了。
```tsx
<Input
name="lastName"
label="Last name:"
placeholder="Last Name"
register={register}
rules={{ required: true }}
errorMsg={errors.lastName && 'Please input'}
/>
```
<RUACodeSandbox url="https://codesandbox.io/s/reusable-input-o7e4jt?file=/src/App.tsx" />