如何开发一个人人爱的组件?

16,884 阅读9分钟

组件,是前端最常打交道的东西,对于 React、Vue 等应用来说,万物皆组件毫不为过。

有些工作经验的同学都知道,组件其实也分等级的,有的组件可以被上万开发者复用,有些组件就只能在项目中运行,甚至挪动到自己的另外一个项目都不行。

如何考察一个前端的水平,首先可以看看他有没有对团队提供过可复用的组件,一个前端如果一直只能用自己写的东西,或者从没有对外提供过可复用的技术,那么他对于一个团队的贡献一定是有限的。

所以开始写一个能开放的组件应该考虑些什么呢?🤔

本篇文章类似一个菜谱,比较零碎的记录一些组件设计的内容,我分别按照 1~5 ⭐️ 区分其重要性。

意识

首先在意识层面,我们需要站在使用组件的开发者角度来观察这个组件,所以下面几点需要在组件开发过程中种在意识里面:

  1. 我应该注重 TypeScript API 定义,好用的组件 API 都应该看上去 理所应当绝不多余
  2. 我应该注重 README 和 Mock ,一个没有文档的组件 = 没有,最好不要使用 link 模式去开发组件
  3. 我不应引入任何副作用依赖,比如全局状态(Vuex、Redux),除非他们能自我收敛
  4. 我在开发一个开放组件,以后很有可能会有人来看我的代码,我得写好点

接口设计

好的 Interface 是开发者最快能搞清楚组件入参的途径,也是让你后续开发能够有更好代码提示的前提。

type Size = any; // 😖 ❌

type Size = string; // 🤷🏻‍♀️

type Size = "small" | "medium" | "large"; // ✅

`

DOM 属性(⭐️⭐️⭐️⭐️⭐️)

组件最终需要变成页面 DOM,所以如果你的组件不是那种一次性的,请默认顺手定义基础的 DOM 属性类型。className 可以使用 classnames 或者 clsx 处理,别再用手动方式处理 className 啦!

export interface IProps {
  className?: string;
  style?: React.CSSProperties;
}

对于内部业务来说,还会有 data-spm 这类 dom 属性,主要用于埋点上报内容,所以可以直接对你的 Props 类型做一个基础封装:

export type CommonDomProps = {
  className?: string;
  style?: React.CSSProperties;
} & Record<`data-${string}`, string>

// component.tsx
export interface IComponentProps extends CommonDomProps {
	...
}

// or

export type IComponentProps = CommonDomProps & {
	...
}
类型注释(⭐️⭐️⭐️)
  1. export 组件 props 类型定义
  2. 为组件暴露的类型添加 规范的注释
export type IListProps{
  /**
   * Custom suffix element.
   * Used to append element after list
   */
  suffix?: React.ReactNode;
  /**
   * List column definition.
   * This makes List acts like a Table, header depends on this property
   * @default []
   */
  columns?: IColumn[];
  /**
   * List dataSource.
   * Used with renderRow
   * @default []
   */
  dataSource?: Array<Record<string, any>>;
}

上面的类型注释就是一个规范的类型注释,清晰的类型注释可以让消费者,直接点击进入你的类型定义中查看到关于这个参数的清晰解释。

同时这类符合 jsdoc 规范的类型注释,也是一个标准的社区规范。利用 vitdoc 这类组件 DEMO 生成工具也可以帮你快速生成美观的 API 说明文档。

Tips:如果你非常厌倦写这些注释,不如试试著名的 AI 代码插件:Copilot ,它可以帮你快速生成你想要表达的文字。

以下是 ❌ 错误示范:

  toolbar?: React.ReactNode; // List toolbar.

  // 👇🏻 Columns
  // defaultValue is "[]"
  columns?: IColumns[];
组件插槽(⭐️⭐️⭐️)

对于一个组件开发新手来说,往往会犯 string 类型替代 ReactNode 的错误。

比如要对一个 Input 组件定义一个 label 的 props ,许多新手开发者会使用 string 作为 label 类型,但这是错误的。

export type IInputProps = {
  label?: string; // ❌
};

export type IInputProps = {
  label?: React.ReactNode; // ✅
};

遇到这种类型时,需要意识到我们其实是在提供一个 React 插槽类型,如果在组件消费中仅仅是让他展示出来,而不做其他处理的话,就应当使用 ReactNode 类型作为类型定义。

受控 与 非受控(⭐️⭐️⭐️⭐️⭐️)

如果要封装的组件类型是 数据输入 的用途,也就是存在双向绑定的组件。请务必提供以下类型定义:

export type IFormProps<T = string> = {
  value?: T;
  defaultValue?: T;
  onChange?: (value: T, ...args) => void;
};

并且,这类接口定义不一定是针对 value, 其实对于所有有 受控需求 的组件都需要,比如:

export type IVisibleProps = {
  /**
   * The visible state of the component.
   * If you want to control the visible state of the component, you can use this property.
   * @default false
   */
  visible?: boolean;
  /**
   * The default visible state of the component.
   * If you want to set the default visible state of the component, you can use this property.
   * The component will be controlled by the visible property if it is set.
   * @default false
   */
  defaultVisible?: boolean;
  /**
   * Callback when the visible state of the component changes.
   */
  onVisibleChange?: (visible: boolean, ...args) => void;
};

具体原因请查看: 《受控组件和非受控组件》 消费方式推荐使用:ahooks useControllableValue

表单类常用属性(⭐️⭐️⭐️⭐️)

如果你正在封装一个表单类型的组件,未来可能会配合 antd/ fusionForm 组件来消费,以下这些类型定义你可能会需要到:

export type IFormProps = {
  /**
   * Field name
   */
  name?: string;

  /**
   * Field label
   */
  label?: ReactNode;

  /**
   * The status of the field
   */
  state?: "loading" | "success" | "error" | "warning";

  /**
   * Whether the field is disabled
   * @default false
   */
  disabled?: boolean;

  /**
   * Size of the field
   */
  size?: "small" | "medium" | "large";

  /**
   * The min value of the field
   */
  min?: number;

  /**
   * The max value of the field
   */
  max?: number;
};
选择类型(⭐️⭐️⭐️⭐️)

如果你正在开发一个需要选择的组件,可能以下类型你会用到:

export interface ISelection<T extends object = Record<string, any>> {
  /**
   * The mode of selection
   * @default 'multiple'
   */
  mode?: "single" | "multiple";
  /**
   * The selected keys
   */
  selectedRowKeys?: string[];
  /**
   * The default selected keys
   */
  defaultSelectedRowKeys?: string[];
  /**
   * Max count of selected keys
   */
  maxSelection?: number;
  /**
   * Whether take a snapshot of the selected records
   * If true, the selected records will be stored in the state
   */
  keepSelected?: boolean;
  /**
   * You can get the selected records by this function
   */
  getProps?: (
    record: T,
    index: number
  ) => { disabled?: boolean; [key: string]: any };
  /**
   * The callback when the selected keys changed
   */
  onChange?: (
    selectedRowKeys: string[],
    records?: Array<T>,
    ...args: any[]
  ) => void;
  /**
   * The callback when the selected records changed
   * The difference between `onChange` is that this function will return the single record
   */
  onSelect?: (
    selected: boolean,
    record: T,
    records: Array<T>,
    ...args: any[]
  ) => void;
  /**
   * The callback when the selected all records
   */
  onSelectAll?: (
    selected: boolean,
    keys: string[],
    records: Array<T>,
    ...args: any[]
  ) => void;
}

另外,单选与多选存在时,组件的 value 可能会需要根据下传的 mode 自动变化数据类型。

比如,在 Select 组件中就会有以下区别:

mode="single" -> value: string | number
mode="multiple" -> value: string[] | number[]

所以对于需要 多选、单选 的组件来说,value 的类型定义会有更多区别。

对于这类场景可以使用 Merlion UI - useCheckControllableValue 进行抹平。

组件设计

服务请求(⭐️⭐️⭐️⭐️⭐️)

这是一个在业务组件设计中经常会遇到的组件设计,对于很多场景来说,或许我们只是需要替换一下请求的 url ,于是便有了类似下面这样的 API 设计:

export type IAsyncProps {
	requestUrl?: string;
	extParams?: any;
}

后面接入方增多后,出现了后端的 API 结果不符合组件解析逻辑,或者出现了需要请求多个 API 组合后才能得到组件所需的数据,于是一个简单的请求就出现了以下这些参数:

export type IAsyncProps {
	requestUrl?: string;
	extParams?: any;
	beforeUpload?: (res: any) => any
	format?: (res: any) => any
}

这还只是其中一个请求,如果你的业务组件需要 2 个、3 个呢?组件的 API 就会变得越来越多,越来越复杂,这个组件慢慢的也就变得没有易用性 ,也慢慢没有了生气。

对于异步接口的 API 设计最佳实践应该是:提供一个 Promise 方法,并且详细定义其入参、出参类型。

export type ProductList = {
  total: number;
  list: Array<{
    id: string;
    name: string;
    image: string;
    ...
  }>
}
export type AsyncGetProductList = (
  pageInfo: { current: number; pageSize: number },
  searchParams: { name: string; id: string; },
) => Promise<ProductList>;


export type IComponentProps = {
  /**
   * The service to get product list
   */
  loadProduct?: AsyncGetProductList;
}

通过这样的参数定义后,对外只暴露了 1 个参数,该参数类型为一个 async 的方法。开发者需要下传一个符合上述入参和出参类型定义的函数。

在使用时组件内部并不关心请求是如何发生的,使用什么方式在请求,组件只关系返回的结果是符合类型定义的即可。

这对于使用组件的开发者来说是完全白盒的,可以清晰的看到需要下传什么,以及友好的错误提示等等。

Hooks(⭐️⭐️⭐️⭐️⭐️)

很多时候,或许你不需要组件!

对于很多业务组件来说,很多情况我们只是在原有的组件基础上封装一层浅浅的业务服务特性,比如:

  • Image Uploader:Upload + Upload Service
  • Address Selector: Select + Address Service
  • Brand Selector: Select + Brand Service
  • ...

而对于这种浅浅的胶水组件,实际上组件封装是十分脆弱的。

因为业务会对 UI 有各种调整,对于这种重写成本极低的组件,很容易导致组件的垃圾参数激增。

实际上,对于这类对服务逻辑的状态封装,更好的办法是将其封装为 React Hooks ,比如上传:

export function Page() {
  const lzdUploadProps = useLzdUpload({ bu: "seller" });

  return <Upload {...lzdUploadProps} />;
}

这样的封装既能保证逻辑的高度可复用性,又能保证 UI 的灵活性。

Consumer(⭐️⭐️⭐️)

对于插槽中需要使用到组件上下文的情况,我们可以考虑使用 Consumer 的设计进行组件入参设计。

比如 Expand 这个组件,就是为了让部分内容在收起时不展示。

对于这种类型的组件,明显容器内的内容需要拿到 isExpand 这个关键属性,从而决定索要渲染的内容,所以我们在组件设计时,可以考虑将其设计成可接受一个回调函数的插槽:

export type IExpandProps = {
  children?: (ctx: { isExpand: boolean }) => React.ReactNode;
};

而在消费侧,则可以通过以下方式轻松消费:

export function Page() {
  return (
    <Expand>
      {({ isExpand }) => {
        return isExpand ? <Table /> : <AnotherTable />;
      }}
    </Expand>
  );
}

文档设计

package.json(⭐️⭐️⭐️⭐️⭐️)

请确保你的 repository是正确的仓库地址,因为这里的配置是很多平台溯源的唯一途径,比如: github.com

请确保 package.json 中存在常见的入口定义,比如 main \ module \ types \ exports,以下是一个标准的 package.json 的示范:

{
  "name": "xxx-ui",
  "version": "1.0.0",
  "description": "Out-of-box UI solution for enterprise applications from B-side.",
  "author": "yee.wang@xxx.com",
  "exports": {
    ".": {
      "import": "./dist/esm/index.js",
      "require": "./dist/cjs/index.js"
    }
  },
  "main": "./dist/cjs/index.js",
  "module": "./dist/esm/index.js",
  "types": "./dist/cjs/index.d.ts",
  "repository": {
    "type": "git",
    "url": "git@github.com:yee94/xxx.git"
  }
}
README.md(⭐️⭐️⭐️⭐️)

如果你在做一个库,并希望有人来使用它,请至少为你的库提供一段描述,在我们的脚手架模板中已经为你生成了一份模板,并且会在编译过程中自动加入在线 DEMO 地址,但如果可以请至少为它添加一段描述。

这里的办法有很多,如果你实在不知道该如何写,可以找一些知名的开源库来参考,比如 antd \ react \ vue 等。

还有一个办法,或许你可以寻求 ChatGPT 的帮助,屡试不爽😄。