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 个问题:
- 每次手动引入
Form.Item
里面的不同的表单控件很麻烦 - 校验属性
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
属性到支持options
的type
组件上
<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
当成一个一元一次方程,R
在 infer
关键字的组合下,它就是方程式中的 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>;
}
考虑到上述列举出来的控件并不是 antd 控件的全部,比如 文本域是 <Input.TextArea />
,可以扩展类型为
'input-textrea': GetRCPropsType<typeof Input['TextArea']>;
,另外记得在所有涉及控件的代码中添加对应的扩展代码
完全自定义
以上封装可以覆盖 99% 的业务处理,但还有一种情况是用不到任何控件,需要自定义 Form.Item
,比如需要放一张图片,一段文字等,这一类就不适合再封装到组件中,我们选择给组件使用者完全自定义的办法
具体做法是:当 type
为空的时候,Form.Item
的 children 内容就直接展示
- 把 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];
}
- 修改
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
Omit
是 typescript
的原生泛型接口,意思是排除,第一个参数是目标类型,第二个参数是目标类型上的属性,支持联合类型排除多个
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% 的需求覆盖率,如果有,欢迎评论补充