跳到主要内容

从组件封装了解 typescript

· 阅读需 15 分钟

typescript 功能不过多介绍,本文属于 typescript 进阶教程,适合了解 ts 基础又想要进一步提升的人。

本文将实现一个对 ant-design form-item 组件二次封装的功能。

封装的目的

封装的目的是什么?

我们平时写业务是否会遇到以下的场景

const sexOptions = [
{ label: '男', value: 0 },
{ label: '女', value: 1 }
];

const educationOptions = [
{ label: '小学', value: 0 },
{ label: '初中', value: 1 },
{ label: '高中', value: 2 },
{ label: '中专', value: 3 },
{ label: '大专', value: 4 },
{ label: '本科', value: 5 },
{ label: '硕士', value: 6 },
{ label: '博士', value: 7 }
];

const form = (
<Form>
<Form.Item label="姓名" name="name" rules={[{ required: true, message: '请输入用户名' }]}>
<Input placeholder="姓名" />
</Form.Item>
<Form.Item label="年龄" name="age" rules={[{ required: true, message: '请输入年龄' }]}>
<InputNumber placeholder="年龄" />
</Form.Item>
<Form.Item label="性别" name="sex" rules={[{ required: true, message: '请选择性别' }]}>
<Radio.Group options={sexOptions}></Radio.Group>
</Form.Item>
<Form.Item label="学历" name="education">
<Select options={educationOptions}></Select>
</Form.Item>
<Form.Item label="出生日期" name="borndate" rules={[{ required: true, message: '请选择出生日期' }]}>
<DatePicker />
</Form.Item>
</Form>
);

这段代码看起来似乎正常,但是其实有 2 个问题:

  1. 每次手动引入 Form.Item 里面的不同的表单控件很麻烦
  2. 校验属性 rules 重复量太多

我们决定以另一种更优雅的方式使用控件,最终看起来像这样

<Form>
<Form.Item label="姓名" name="name" required type="input" />
<Form.Item label="年龄" name="age" required type="input-number" />
<Form.Item label="性别" name="sex" type="radio" />
<Form.Item label="学历" name="education" required type="select" />
<Form.Item label="出生日期" name="borndate" required type="date" />
</Form>

是不是精简了很多?直接开始扩展 Form.Item 组件

扩展 type 属性

import React, { FC } from 'react';
import { Form, Input, InputNumber, Switch, DatePicker } from 'antd';
import { FormItemProps } from 'antd/lib/form';

// 列举出所有 antd 的控件
type ControlTypes = 'input' | 'input-number' | 'switch' | 'date-picker' | 'checkbox' | 'radio' | 'select';

// 扩展 FormItem 的参数 FormItemProps
interface MyFormItemProps extends FormItemProps {
type: ControlTypes
}

const MyformItem: FC<MyFormItemProps> = props => {
// 取出我们自定义的参数,其余的全部原封不动的还给 `Form.Item`
// type: 用于我们判断外面传进来的控件类型我们再渲染好了直接生成出来
const { type, ...restProps } = props;

// 写一个自定义渲染的方法,用于我们生成特定的控件,大致是这样
const renderChildren = () => {
switch (type) {
case 'input':
return <Input />
case 'input-number':
return <InputNumber />
case 'switch':
return <Switch />
case 'date-picker':
return <DatePicker />
// ..
}
}


return (
<Form.Item {...restProps}>{renderChildren()}</Form.Item>
)
}

为了方便维护和写出可扩展性强的漂亮代码,决定对这个自定义渲染方法做一个优化,单独抽出来

import React, { FC } from 'react';
import { FormItemProps } from 'antd/lib/form';
import { Form, Input, InputNumber, Switch, DatePicker, Checkbox, Radio, Select } from 'antd';

type ControlTypes = 'input' | 'input-number' | 'switch' | 'date-picker' | 'checkbox' | 'radio' | 'select';

interface MyFormItemProps extends FormItemProps {
type: ControlTypes
}

export class ControlMap {
input() {
return <Input />;
}

'input-number'() {
return <InputNumber />;
}

switch() {
return <Switch />;
}

'date-picker'() {
return <DatePicker />;
}

checkbox() {
return <Checkbox.Group />;
}

radio() {
return <Radio.Group />;
}

select() {
return <Select />;
}
}

const MyformItem: FC<MyFormItemProps> = props => {
// 取出我们自定义的参数,其余的全部原封不动的还给 `Form.Item`
// type: 用于我们判断外面传进来的控件类型我们再渲染好了直接生成出来
const { type, ...restProps } = props;

const controlMap = new ControlMap(props);

return (
<Form.Item {...restProps}>{controlMap[type]()}</Form.Item>
)
}

现在通过只传入一个 type 属性

<MyformItem type="input" />

就能最终生成如下代码

<Form.Item>
<Input />
</Form.Item>

无需再手动导入控件组件了,并且拥有 ts 的类型校验和提示

但是同时目前写法也锁死了控件自身,无法再传递控件的属性,比如 options,比如 placeholder disabled allowClear 这些通用属性

扩展控件的属性

import React, { FC } from 'react';
import { FormItemProps } from 'antd/lib/form';

type ControlTypes = 'input' | 'input-number' | 'switch' | 'date-picker' | 'checkbox' | 'radio' | 'select';

export interface MyFormItemProps extends FormItemProps {
type: ControlTypes;
/** 支持 options 的控件如 select checkbox radio 等,非必填 **/
options?: {
label: string;
value: any;
disabled?: boolean;
}[];
/** 控件内部属性,非必填 **/
innerProps?: object;
}

export class ControlMap {
props: MyFormItemProps;

constructor(props: MyFormItemProps) {
this.props = props;
}

get innerProps() {
// 控件统一属性的合并
return {
...this.props.innerProps,
children: this.props.children
// disabled: true,
// allowClear: true
} as object;
}

input() {
return <Input {...this.innerProps} />;
}

'input-number'() {
return <InputNumber {...this.innerProps} />;
}

switch() {
return <Switch {...this.innerProps} />;
}

'date-picker'() {
return <DatePicker {...this.innerProps} />;
}

checkbox() {
return <Checkbox.Group options={this.props.options} {...this.innerProps} />;
}

radio() {
return <Radio.Group options={this.props.options} {...this.innerProps} />;
}

select() {
return <Select options={this.props.options} {...this.innerProps} />;
}
}

const MyformItem: FC<MyFormItemProps> = props => {
// 取出我们自定义的参数,其余的全部原封不动的还给 `Form.Item`
// type: 用于我们判断外面传进来的控件类型我们再渲染好了直接生成出来
const { type, options, ...restProps } = props;

// 传入 ControlMap 需要的 props
const controlMap = new ControlMap(props);

return <Form.Item {...restProps}>{controlMap[type]()}</Form.Item>;
};

使用

  • 我们可以通过传入 options 属性到支持 optionstype 组件上
<MyFormItem type="select" options={educationOptions} />
  • 也可以通过自定义 children 的方式写入
import { Select } from 'antd';

<MyFormItem type="select">
<Select.Option value={1}>选项1</Select.Option>
<Select.Option value={2}>选项2</Select.Option>
</MyFormItem>;
  • 对于控件的自身属性,我们可以通过传入 innerProps 属性到对应的控件上
<MyFormItem type="input" innerProps={{ maxLength: 10, disabled: true }} />

如此一来就极大的增加了控件的自由性

但是还有一个问题:innerProps 的提示和属性类型校验缺失。原因是因为我们 innerProps 给了个 object 类型,仅仅只是校验它必须为对象,可以说是和 any 没什么区别了。

完善 innerProps 的类型

我们可以对 type 和 对应控件的 Props 类型做一个映射关系,根据外层组件传进来的 type 返回不同的 innerProps 类型

import { CheckboxProps } from 'antd/lib/checkbox';
import { DatePickerProps } from 'antd/lib/date-picker';
import { FormItemProps } from 'antd/lib/form';
import { InputProps } from 'antd/lib/input';
import { InputNumberProps } from 'antd/lib/input-number';
import { RadioGroupProps } from 'antd/lib/radio';
import { SelectProps } from 'antd/lib/select';
import { SwitchProps } from 'antd/lib/switch';

type InnerProps {
'input': InputProps;
'input-number': InputNumberProps;
'switch': SwitchProps;
'date-picker': DatePickerProps;
'checkbox': CheckboxProps;
'radio': RadioGroupProps;
'select': SelectProps<any>;
}

 但是按照目前的 MyFormItemProps 的类型定义,我们无法知道当前传进来 type 是什么,所以接下来要用到 泛型MyFormItemProps 进行改造

泛型

在像 C#和 Java 这样的语言中,可以使用泛型来创建可重用的组件,一个组件可以支持多种类型的数据。 这样用户就可以以自己的数据类型来使用组件

export interface MyFormItemProps<T extends ControlTypes = ControlTypes> extends FormItemProps {
type: T;
/** 支持 options 的控件如 select checkbox radio 等,非必填 **/
options?: {
label: string;
value: any;
disabled?: boolean;
}[];
/** 控件内部属性,非必填 **/
innerProps?: InnerProps[T];
}

InnerProps[T]的语法可以视作 js 获取对象属性

extends

extends 在泛型中只是表示约束,或者通过三目运算表示判断,在泛型之外则是继承(<> 尖括号内都是泛型的作用域)

<T extends ControlTypes = ControlTypes>T 就是参数,extends ControlTypes 表示参数只能是联合类型 ControlTypes 的类型中的一种,= 等号可以看做函数的参数默认值


type InnerProps 中,有多少个控件,我们就要 import 多少个对应的类型,看上去不太灵活,我们能否自己推导出控件的类型呢?当然可以!

写一个泛型,我们传入 React 组件,它返回组件的 props 类型

infer

函数式组件

type GetFunctionPropsType<T> = T extends (props: infer R) => any ? R : any;

(props: infer R) => any是一个整体, 它是一个函数的类型,react 函数式组件也是一个函数,R 是函数的参数类型

可以把 (props: infer R) => any 当成一个一元一次方程,Rinfer 关键字的组合下,它就是方程式中的 x 未知数,infer 能帮我们推导出这个参数 R 的类型

通过三目运算,整体解释就是:T 如果是函数,那就返回这个函数的参数,不是就返回 any

class 组件

考虑到 antd 还存在 class 组件,所以还需要对 class 进行 props 解析

type GetClassPropsType<T> = T extends React.ComponentClass<infer R> ? R : any;

最终合并两个类型

type GetRCPropsType<T> = T extends (props: infer R) => any ? R : T extends React.ComponentClass<infer R> ? R : any;

RC 一般指 React Component 简写

重写 InnerProps 的类型

type InnerProps {
'input': GetRCPropsType<typeof Input>;
'input-number': GetRCPropsType<typeof InputNumber>;
'switch': GetRCPropsType<typeof Switch>;
'date-picker': GetRCPropsType<typeof DatePicker>;
'checkbox': GetRCPropsType<typeof Checkbox>;
'radio': GetRCPropsType<typeof Radio>;
'select': GetRCPropsType<typeof Select>;
}
&nbsp;

考虑到上述列举出来的控件并不是 antd 控件的全部,比如 文本域是 <Input.TextArea />,可以扩展类型为

'input-textrea': GetRCPropsType<typeof Input['TextArea']>;

,另外记得在所有涉及控件的代码中添加对应的扩展代码

完全自定义

以上封装可以覆盖 99% 的业务处理,但还有一种情况是用不到任何控件,需要自定义 Form.Item,比如需要放一张图片,一段文字等,这一类就不适合再封装到组件中,我们选择给组件使用者完全自定义的办法

具体做法是:当 type 为空的时候,Form.Item 的 children 内容就直接展示

  1. 把 type 的类型改为非必填
export interface MyFormItemProps<T extends ControlTypes = ControlTypes> extends FormItemProps {
type?: T;
/** 支持 options 的控件如 select checkbox radio 等,非必填 **/
options?: {
label: string;
value: any;
disabled?: boolean;
}[];
/** 控件内部属性,非必填 **/
innerProps?: InnerProps[T];
}
  1. 修改 Form.Item children 渲染方式,如果没传 type,就显示传进来的 children 元素
<Form.Item {...restProps}>
{type ? controlMap[type]() : props.children}
</Form.Item>

优化 rules 属性

开头有说到 rules 属性很冗余,我们优化的方式决定通过 只传一个 required 属性就确定它是必填,虽然 Form.Item 原本就支持 required 属性,但是它只是根据默认规则生成英文的提示

这里使用外层提供的 label 通过组件内部拼接成校验信息,如

<Form.Item label="姓名" name="name" required />

生成

<Form.Item label="姓名" name="name" rules={[{ required: true, message: `请输入${'姓名'}` }]} />

同时为了用户自定义必填提示文案,支持字符串类型的 required,但是 Form.Item 自带的 required 属性并不支持字符串类型,在 ts 中这是不允许的。

因此我们需要重写 FormItemProps 这个 antd 提供的类型中的 required 属性

Omit

Omittypescript 的原生泛型接口,意思是排除,第一个参数是目标类型,第二个参数是目标类型上的属性,支持联合类型排除多个

Omit<FormItemProps, 'required'>
export interface MyFormItemProps<T extends ControlTypes = ControlTypes> extends Omit<FormItemProps, 'required'> {
type?: T;
/** 支持 options 的控件如 select checkbox radio 等,非必填 **/
options?: {
label: string;
value: any;
disabled?: boolean;
}[];
/** 控件内部属性,非必填 **/
innerProps?: InnerProps[T];
/** 是否必填,支持自定义必填文案 **/
required?: string | boolean;
}
const MyFormItem: FC<MyFormItemProps> = props => {
const { type, required, innerProps, rules: userRules, ...rest } = props;

// 重写 rules 生成规则
const rules = useMemo(() => {
// 如果设置了 rules 属性,说明用户需要完全自定义 rules,不仅仅是必填
if (userRules) return userRules;

// 如果设置了 required 属性
if (required) {
if (typeof required === 'boolean') {
return [{ required: true, message: `请输入${props.label}` }];
}
// 自定义 required 文案
else if (typeof required === 'string') {
return [{ required: true, message: required }];
}
}
}, [required, userRules, props.label]);

return (
<Form.Item {...restProps} rules={rules}>
{type ? controlMap[type]() : props.children}
</Form.Item>
);
};

现在,目前的扩展可以达到 99% 的需求覆盖率,如果有,欢迎评论补充