项目级组件封装指南

项目级组件封装指南

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

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

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

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

业务组件

首先是场景,现在有一个表单,其中有一个表单项,包含一个选择器和输入框。由于是使用的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>
  );
};

代码写好,看看效果。

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

初步抽离成公共组件

做起来也非常简单的,把文件移动到公共组件目录,然后把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

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

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

然后在使用时就可以直接看到相关的代码提示了,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 组件用法保持一致,我这里还添加了泛型的透传,不过不添加也无伤大雅。

总结

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

  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();
};
相关推荐
duanyuehuan22 分钟前
Vue 组件定义方式的区别
前端·javascript·vue.js
veminhe26 分钟前
HTML5简介
前端·html·html5
洪洪呀27 分钟前
css上下滚动文字
前端·css
搏博1 小时前
基于Vue.js的图书管理系统前端界面设计
前端·javascript·vue.js·前端框架·数据可视化
掘金安东尼2 小时前
前端周刊第419期(2025年6月16日–6月22日)
前端·javascript·面试
bemyrunningdog2 小时前
AntDesignPro前后端权限按钮系统实现
前端
重阳微噪2 小时前
Data Config Admin - 优雅的管理配置文件
前端
Hilaku2 小时前
20MB 的字体文件太大了,我们把 Icon Font 压成了 10KB
前端·javascript·css
fs哆哆2 小时前
在VB.net中,文本插入的几个自定义函数
服务器·前端·javascript·html·.net
专注VB编程开发20年2 小时前
C# .NET多线程异步记录日声,队列LOG
java·开发语言·前端·数据库·c#