项目级组件封装指南
相信在开发过程中经常会出现这种情况,需求要求的内容已经开发好了,但是开发过程中制作了某个组件看起来有一定的通用潜力。
这时候你就陷入了纠结,一方面是把这个组件抽离出来能够提升后续相似场景的开发效率,另一方面又担心这种公共组件设计得不好,下次做需求时自己都不愿意复用,变成了给屎山"添砖加瓦"。
而本文则会以一个实际例子出发,带你一步步设计封装一个项目级的公共组件,让你能够学会一个项目组件到底应该怎么设计,以及领悟组件设计的相关思想。
本文章还有以下配套资源,欢迎访问:
- 演示代码仓库:github.com/RJiazhen/pr...
- 幻灯片:project-level-component-slidev.rjiazhen.top
- 示例项目:project-level-component-demo.rjiazhen.top
业务组件

首先是场景,现在有一个表单,其中有一个表单项,包含一个选择器和输入框。由于是使用的antd,所以非常自然地,封装了一个接收value
、onChange
和id
(为了简化,id
在下文中省略)参数的受控组件,以便实现和antd的<Form/>
组件的配合。
jsx
/**
* 页面专用的业务组件 文件
*/
import { Col, Input, Row, Select } from 'antd';
import React, { useMemo } from 'react';
/** 页面专用的,带有下拉框的输入框组件 */
export const InputWithSelect = ({ value, onChange }) => {
/** 下拉框的选项 */
const options = [
{
label: '姓名',
value: 'name',
},
{
label: '身份证号',
value: 'id',
},
{
label: '手机号',
value: 'phone',
},
];
/** 输入框的placeholder */
const inputPlaceHolder = useMemo(() => {
const selectLabel = options?.find(
(item) => item.value === value?.select,
)?.label;
return `请输入${selectLabel}`;
}, [value?.select]);
return (
<Row gutter={8}>
<Col span={10}>
<Select
value={value?.select}
options={options}
onChange={(selectValue) => {
onChange?.({
select: selectValue,
input: value?.input,
});
}}
></Select>
</Col>
<Col span={14}>
<Input
placeholder={inputPlaceHolder}
value={value?.input}
onChange={(e) => {
onChange?.({
select: value?.select,
input: e.target.value,
});
}}
allowClear
/>
</Col>
</Row>
);
};
代码写好,看看效果。

一点问题都没有,可以交差了,但看到剩下的开发时间还很充裕,再想想这个项目的整体设计,估计未来很多地方都会用到类似的组件,那就一不做,二不休,把这个抽离成公共组件吧,反正看起来也没多难。
初步抽离成公共组件
做起来也非常简单的,把文件移动到公共组件目录,然后把option
变量修改为通过组件参数传入:
jsx
/**
* 简单抽离的,带有下拉框的输入框公共组件
*/
import { Col, Input, Row, Select } from 'antd';
import React, { useMemo } from 'react';
/** 简单抽离的,带有下拉框的输入框公共组件 */
export const SimplyExtractedInputWithSelect = ({
value,
onChange,
options,
}) => {
/** 输入框的placeholder */
const inputPlaceHolder = useMemo(() => {
const selectLabel = options?.find(
(item) => item.value === value?.select,
)?.label;
return `请输入${selectLabel}`;
}, [value?.select, options]);
return (
<Row gutter={8}>
<Col span={10}>
<Select
value={value?.select}
options={options}
onChange={(selectValue) => {
onChange?.({
select: selectValue,
input: value?.input,
});
}}
></Select>
</Col>
<Col span={14}>
<Input
placeholder={inputPlaceHolder}
value={value?.input}
onChange={(e) => {
onChange?.({
select: value?.select,
input: e.target.value,
});
}}
allowClear
/>
</Col>
</Row>
);
};
页面启动,一切正常。
不过这就结束了吗?刚好同一个需求在另一个页面也需要使用这个组件,但是需要把选择器和输入框的间隙调大一点,这下怎么办,是新加个gutter
参数来控制吗?那以后再有些新的需求岂不是要不停地往上加参数?
实现参数透传
在动手做之前,让我们先看看ProComponents是怎么做的。
ProComponent这是一个由antd团队开发的、基于antd组件进行二次封装的重型组件库,其中的很多设计可以说是组件二次封装的范例,正好我们可以参考。
以表单项组件为例,ProCompoennts提供了很多使用数据录入组件和ProForm.Item
组件的简单封装的组件,而这些组件都考虑到了传递参数给布局用的 Row
和 Col
组件的情况,所以提供了两个通用参数:rowProps
和 colProps
。

接下来答案其实就很明显了,通过暴露透传参数,让使用者可以最大程度地自定义内部组件。
所以,接下来就是添加rowProps
、selectColProps
、 selectProps
、 inputColProps
和 inputProps
五个参数,分别用来透传参数给内部的5个组件:
jsx
/**
* 完善了参数的,带下拉选择框的输入框
*/
import { Col, Input, Row, Select } from 'antd';
import React, { useMemo } from 'react';
/**
* 完善了参数的,带下拉选择框的输入框
*/
export const CompletedParameterInputWithSelect = ({
defaultValue,
selectProps,
selectColProps,
inputProps,
inputColProps,
value,
onChange,
options,
rowProps,
}) => {
/** 合并后的options */
const mergedOptions = useMemo(() => {
if (selectProps?.options) {
return selectProps.options;
}
return options;
}, [options, selectProps?.options]);
/** 输入框的placeholder */
const inputPlaceHolder = useMemo(() => {
const selectLabel = mergedOptions?.find(
(item) => item.value === value?.select,
)?.label;
return `请输入${selectLabel}`;
}, [value?.select, mergedOptions]);
return (
<Row
gutter={8}
{...rowProps}
>
<Col
span={10}
{...selectColProps}
>
<Select
defaultValue={defaultValue?.select}
value={value?.select}
options={mergedOptions}
onChange={(selectValue) => {
onChange?.({
select: selectValue,
input: value?.input,
});
}}
{...selectProps}
></Select>
</Col>
<Col
span={14}
{...inputColProps}
>
<Input
defaultValue={defaultValue?.input}
placeholder={inputPlaceHolder}
value={value?.input}
onChange={(e) => {
onChange?.({
select: value?.select,
input: e.target.value,
});
}}
allowClear
{...inputProps}
/>
</Col>
</Row>
);
};
由于 option
参数非常常用,所以除了使用 selectProps.option
进行透传,所以也还是保留了直接传递的形式。为了保证两者都传递时不出问题,需要对其进行合并(代码中的 mergedOptions
变量)
添加类型声明
如果是不常封装公共组件的同学,可能到这里就已经结束了。但是别忘了,添加类型声明可以帮助使用者获得足够的代码提示,特别是透传参数,没有类型声明的话使用者很难知道到底要传什么。
但是也不用担心类型声明难写,本身 React 和 antd 就已经提供了足够多的类型工具,让我们能够快速编写类型声明:
tsx
/**
* 带下拉选择框的输入框,可以用于Form.Item组件中,有完整的类型声明
*/
import { Col, Input, Row, Select } from 'antd';
import { ComponentProps, useMemo } from 'react';
type Value = {
select?: ComponentProps<typeof Select>['value'];
input?: ComponentProps<typeof Input>['value'];
};
type DefaultValue = {
select?: ComponentProps<typeof Select>['defaultValue'];
input?: ComponentProps<typeof Input>['defaultValue'];
};
type Props = {
rowProps?: ComponentProps<typeof Row>;
selectProps?: ComponentProps<typeof Select>;
selectColProps?: ComponentProps<typeof Col>;
inputProps?: ComponentProps<typeof Input>;
inputColProps?: ComponentProps<typeof Col>;
options?: ComponentProps<typeof Select>['options'];
defaultValue?: DefaultValue;
value?: Value;
onChange?: (value: Value) => void;
};
/**
* 带下拉选择框的输入框,可以用于Form.Item组件中,有完整的类型声明
*/
export const TypedInputWithSelect = ({
defaultValue,
selectProps,
selectColProps,
inputProps,
inputColProps,
value,
onChange,
options,
rowProps,
}: Props) => {
/** 合并后的options */
const mergedOptions = useMemo(() => {
if (selectProps?.options) {
return selectProps.options;
}
return options;
}, [options, selectProps?.options]);
/** 输入框的placeholder */
const inputPlaceHolder = useMemo(() => {
const selectLabel = mergedOptions?.find(
(item) => item.value === value?.select,
)?.label;
return `请输入${selectLabel}`;
}, [value?.select, mergedOptions]);
return (
<Row
gutter={8}
{...rowProps}
>
<Col
span={10}
{...selectColProps}
>
<Select
defaultValue={defaultValue?.select}
value={value?.select}
options={mergedOptions}
onChange={(selectValue) => {
onChange?.({
select: selectValue,
input: value?.input,
});
}}
{...selectProps}
></Select>
</Col>
<Col
span={14}
{...inputColProps}
>
<Input
defaultValue={defaultValue?.input}
placeholder={inputPlaceHolder}
value={value?.input}
onChange={(e) => {
onChange?.({
select: value?.select,
input: e.target.value,
});
}}
allowClear
{...inputProps}
/>
</Col>
</Row>
);
};
这里可以看到,通过使用 React.ComponentProps
泛型工具,可以非常简单地获取组件的参数类型(传入字符串可以直接获取HTML元素的参数类型),完全不用自己手写类型,直接使用组件原先定义好的类型即可。

然后在使用时就可以直接看到相关的代码提示了,ts的类型校验也能够正常地工作。
添加注释
既做了参数透传,又做了类型声明总算是可以了吧?
确实差不多了,就差了最后一步,这可能也是很多人进行开发时会忽略的地方------"注释"。
现在这个组件没有使用方法文档,代码内也几乎没有注释,非常不利于后续的维护。
虽然这只是个项目内的公共组件,不需要专门制作一个文档网站,但是基本的说明还是要有的,同时注意,需要使用JSDoc + Markdown 语法,这样 VScode 等 IDE 就能在光标悬浮时出现美观的提示。
最终效果:
tsx
/**
* 带下拉选择框的输入框,可以用于Form.Item组件中,有完整的注释以及类型声明
*/
import { Col, Input, Row, Select } from 'antd';
import { BaseOptionType, DefaultOptionType } from 'antd/es/select';
import { ComponentProps, useMemo } from 'react';
/** 组件值类型 */
type Value<SelectValue = any> = {
select?: SelectValue;
input?: string;
};
/** 组件参数类型 */
// 注意这里为了让Select相关参数类型被正确推导,参考原Select的类型声明,添加了泛型
type Props<
SelectValueType = any,
SelectOptionType extends BaseOptionType = DefaultOptionType,
> = {
/** 透传给Row组件的参数 */
rowProps?: ComponentProps<typeof Row>;
/** 透传给Select组件的参数 */
selectProps?: ComponentProps<
typeof Select<SelectValueType, SelectOptionType>
>;
/** 透传给包裹Select的Col组件的参数 */
selectColProps?: ComponentProps<typeof Col>;
/** 透传给Input组件的参数 */
inputProps?: ComponentProps<typeof Input>;
/** 透传给包裹Input的Col组件的参数 */
inputColProps?: ComponentProps<typeof Col>;
/** 下拉选项 */
options?: ComponentProps<
typeof Select<SelectValueType, SelectOptionType>
>['options'];
/** 默认值 */
defaultValue?: Value<SelectValueType>;
value?: Value<SelectValueType>;
onChange?: (value: Value<SelectValueType>) => void;
};
/**
* 带下拉选择框的输入框,可以用于Form.Item组件中,有完整的注释以及类型声明
* @param props
* @param props.rowProps 透传给Row组件的参数
* @param props.selectProps 透传给Select组件的参数
* @param props.selectColProps 透传给包裹Select的Col组件的参数
* @param props.inputProps 透传给Input组件的参数
* @param props.inputColProps 透传给包裹Input的Col组件的参数
* @param props.options 下拉选项
* @param props.defaultValue 默认值
* @param props.value 当前值
* @param props.onChange 值变化时的回调
*
* @example
*
* 直接在`Form.Item`或`ProFormItem`中使用:
*
* ```tsx
* <Form.Item name="inputWithSelect" label="带下拉选择框的输入框">
* <InputWithSelect />
* </Form.Item>
* ```
*
* 单独作为受控组件使用:
* ```tsx
* <InputWithSelect
* options={[
* { value: '1', label: '选项1' },
* { value: '2', label: '选项2' },
* ]}
* inputProps={{
* placeholder: '请输入',
* }}
* selectColProps={{
* span: 10,
* }}
* inputColProps={{
* span: 14,
* }}
* defaultValue={{
* select: '1',
* input: '选项1',
* }}
* value={{
* select: '1',
* input: '选项1',
* }}
* onChange={(value) => {
* console.log(value);
* }}
* />
* ```
*/
export const CompleteInputWithSelect = <
SelectValueType = any,
SelectOptionType extends
| BaseOptionType
| DefaultOptionType = DefaultOptionType,
>({
defaultValue,
selectProps,
selectColProps,
inputProps,
inputColProps,
value,
onChange,
options,
rowProps,
}: Props<SelectValueType, SelectOptionType>) => {
/** 合并后的options */
const mergedOptions = useMemo(() => {
if (selectProps?.options) {
return selectProps.options;
}
return options;
}, [options, selectProps?.options]);
/** 输入框的placeholder */
const inputPlaceHolder = useMemo(() => {
const selectLabel = mergedOptions?.find(
(item) => item.value === value?.select,
)?.label;
return `请输入${selectLabel}`;
}, [value?.select, mergedOptions]);
return (
<Row
gutter={8}
{...rowProps}
>
<Col
span={10}
{...selectColProps}
>
<Select<SelectValueType, SelectOptionType>
defaultValue={defaultValue?.select}
value={value?.select}
options={mergedOptions}
onChange={(selectValue) => {
onChange?.({
select: selectValue,
input: value?.input,
});
}}
{...selectProps}
></Select>
</Col>
<Col
span={14}
{...inputColProps}
>
<Input
defaultValue={defaultValue?.input}
placeholder={inputPlaceHolder}
value={value?.input}
onChange={(e) => {
onChange?.({
select: value?.select,
input: e.target.value,
});
}}
allowClear
{...inputProps}
/>
</Col>
</Row>
);
};
光标悬浮时出现完整的组件注释:

为了和 Select
组件用法保持一致,我这里还添加了泛型的透传,不过不添加也无伤大雅。
总结
总结来说,项目的公共组件的封装虽然没有像组件库组件那样要求这么高,但是封装得不好也非常影响后续的使用和维护,而只要做到以下几点就可以非常简单地封装一个优秀的项目内组件:
- 使用透传参数暴露足够多的组件(包括普通HTML元素)接口;
- 使用框架提供的泛型工具获取组件参数类型,快速完成类型声明;
- 书写完整的 JSDoc 注释,便于后续的使用和维护。
当然还有如何对参数进行合并、归一化处理,默认参数如何设计等等
思考------公共方法封装
经过上面这一遍封装公共组件的流程,相信大家也感觉到了,其中很多的思想是可以直接应用于公共方法的设计和封装的。
像是上面的三条组件封装思想就可以转换为:
- 使用透传参数暴露足够多的底层方法的接口;
- 使用泛型工具获取底层方法参数类型,快速完成类型声明;
- 书写完整的 JSDoc 注释,便于后续的使用和维护。
以此思想为基础,对fetch进行二次封装时,就可以这么做:
ts
/**
* 项目公共请求方法,基于fetch封装
* @param url 请求地址
* @param options 请求配置,可以用来覆盖默认配置,并且会透传给fetch
* @param config 请求配置,可以用来覆盖默认配置
* @returns 请求结果
*/
export const myFetch = async <T>(
url: Parameters<typeof fetch>[0],
options?: Parameters<typeof fetch>[1],
config?: {
/** 请求地址前缀 */
baseUrl?: string;
},
): Promise<T> => {
/** 默认fetch配置 */
const DEFAULT_OPTIONS: RequestInit = {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
};
/** 合并后的fetch配置 */
const mergedOptions = {
...DEFAULT_OPTIONS,
...options,
headers: {
...DEFAULT_OPTIONS.headers,
...options?.headers,
},
};
/** 默认方法额外配置 */
const DEFAULT_CONFIG = {
baseUrl: 'http://localhost:3000',
};
/** 合并后的配置 */
const mergedConfig = {
...DEFAULT_CONFIG,
...config,
};
const response = await fetch(`${mergedConfig.baseUrl}${url}`, mergedOptions);
return response.json();
};