Skip to content

项目级组件封装指南

相信在开发过程中经常会出现这种情况,需求要求的内容已经开发好了,但是开发过程中制作了某个组件看起来有一定的通用潜力。

这时候你就陷入了纠结,一方面是把这个组件抽离出来能够提升后续相似场景的开发效率,另一方面又担心这种公共组件设计得不好,下次做需求时自己都不愿意复用,变成了给屎山“添砖加瓦”。

而本文则会以一个实际例子出发,带你一步步设计封装一个项目级的公共组件,让你能够学会一个项目组件到底应该怎么设计,以及领悟组件设计的相关思想。

本文章还有以下配套资源,欢迎访问:

业务组件

image.png

首先是场景,现在有一个表单,其中有一个表单项,包含一个选择器和输入框。由于是使用的antd,所以非常自然地,封装了一个接收valueonChangeid(为了简化,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>
  );
};

代码写好,看看效果。

Jun-22-2025 21-44-25.gif

一点问题都没有,可以交差了,但看到剩下的开发时间还很充裕,再想想这个项目的整体设计,估计未来很多地方都会用到类似的组件,那就一不做,二不休,把这个抽离成公共组件吧,反正看起来也没多难。

初步抽离成公共组件

做起来也非常简单的,把文件移动到公共组件目录,然后把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组件的简单封装的组件,而这些组件都考虑到了传递参数给布局用的 RowCol 组件的情况,所以提供了两个通用参数:rowPropscolProps

image.png

接下来答案其实就很明显了,通过暴露透传参数,让使用者可以最大程度地自定义内部组件

所以,接下来就是添加rowPropsselectColPropsselectPropsinputColPropsinputProps 五个参数,分别用来透传参数给内部的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元素的参数类型),完全不用自己手写类型,直接使用组件原先定义好的类型即可。

image.png

然后在使用时就可以直接看到相关的代码提示了,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>
  );
};

光标悬浮时出现完整的组件注释:

image.png

为了和 Select 组件用法保持一致,我这里还添加了泛型的透传,不过不添加也无伤大雅。

总结

总结来说,项目的公共组件的封装虽然没有像组件库组件那样要求这么高,但是封装得不好也非常影响后续的使用和维护,而只要做到以下几点就可以非常简单地封装一个优秀的项目内组件:

  1. 使用透传参数暴露足够多的组件(包括普通HTML元素)接口;
  2. 使用框架提供的泛型工具获取组件参数类型,快速完成类型声明;
  3. 书写完整的 JSDoc 注释,便于后续的使用和维护。

当然还有如何对参数进行合并、归一化处理,默认参数如何设计等等

思考——公共方法封装

经过上面这一遍封装公共组件的流程,相信大家也感觉到了,其中很多的思想是可以直接应用于公共方法的设计和封装的。

像是上面的三条组件封装思想就可以转换为:

  1. 使用透传参数暴露足够多的底层方法的接口
  2. 使用泛型工具获取底层方法参数类型,快速完成类型声明;
  3. 书写完整的 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();
};