查看原文
其他

浅谈前端组件设计

The following article is from ELab团队 Author ELab.yangyuqin

为何要进行前端组件设计?

与仅承担数据处理逻辑的后端不同,前端需要负责界面渲染、数据处理、和接口调用,在框架诞生前,更多的是编写页面维度的顺序脚本代码。随着前端继续的持续发展,ES6推出了class语法糖,React提出了函数式组件,Vue则以模版语法的形式组织代码,前端代码逐渐从“平铺”转变到了“层级”结构,从“面向过程”进阶为“面向对象”,前端组件也成为了近几年来的热门议题。

“组件是对数据和方法的简单封装,是软件中具有相对独立功能、接口由契约指定、和语境有明显依赖关系、可独立部署、可组装的软件实体。”这段百科中摘取的组件定义,揭示了组件所需要具备的特性:功能独立、约定一致、可集成、服务于场景。

在软件工程中,软件设计是软件开发流程中的必要阶段,在需求分析后、软件开发前进行。软件复杂度是每一个项目演进的产物,随着需求和代码行数的增加,复杂度将持续提升。软件设计的优劣为对复杂度带来的影响是不同的,优雅、合理的设计使待开发的代码复杂度可控,而拙劣的设计将会给软件带来无序、偶然的复杂度变更。一个优秀的前端组件需要在满足需求的前提下,具备高易用性和良好的可扩展性,这是我们进行前端组件设计的目标。

💡如何提升组件易用性?

合理的组件封装

组件既生于页面,又能够独立于页面。我们不能将整个页面杂糅为一个组件,也不能将每一小块UI都封装为组件。前端组件按类型可以分为容器组件、功能组件和展示组件,一个优秀的组件应该保证:功能内聚、样式统一、并且与父元素仅通过Props通信。

组件的封装粒度并不是越小越好,很多时候一个组件是在其他一个或多个组件的基础上开发的,无法完全以功能点的数量衡量是否遵循单一职责原则,组件开发者需要根据组件功能和目标来确定组件封装粒度:

  1. 当该组件需要承载具体的额外功能时,相较于新增 API ,封装成独立的组件是更好的选择。

🌰:InputTag组件 在 Input、Tag 的基础上,增加了部分交互功能,API整合了两个组件的属性,作为一个全新的组件提供给开发者使用。相似的,InputNumber、AutoComplete、Mentions等组件也是基于单一职责原则封装的特定功能组件。

  1. 当组件中存在可能被单独使用、可以承载独立功能的子组件时,可以将其以内部组件的形式提供。

🌰:图片预览功能通常依托着图片组件使用,在实际系统中,唤起图片预览的触发器不一定是图片,可能是按钮或其他触发事件,因此预览组件需要单独提供给开发者使用。预览组件作为 Image 的内部组件,开发者能够以Image.Preview、Image.PreviewGroup的方式使用,并提供左右切换、图片缩放等功能,用户可以通过 srcListvisibleactionsscales等API来控制并定制化预览组件。

规范的API编写

一个易用的组件,使用者无需阅读文档或仅快速浏览文档即可上手使用,并且应当在使用过程中给予清晰的注释和代码提示。希望以下API编写建议能够给组件开发者一些参考:

  1. 减少必填的API项,尽可能多地提供默认值,降低组件的使用成本;

  2. 使用通用且有意义的API命名:

    1. onXXX:命名监听/触发方法
    2. renderXXX:命名渲染方法
    3. beforeXXX/afterXXX:命名前置/后置动作
    4. xxxProps:命名子组件属性
    5. 优先使用常见单词进行命名,如:value、visible、size、disabled、label、type等等
  3. 单独维护类型文件,并将其打包至组件产物包中,这样使用者在开发过程中能够实时看到对应的类型提示;

  4. 在类型文件中,为API编写注释;

[Slot] 与 [Props] 的选择

使用Props存在的问题?

当我们需要实现一个较为复杂的卡片需求组件时,为了最大程度地还原UI、减少用户的样式开发成本,首次设计时我们会设计出这样的API:

export type CardProps = {
  // 底部信息展示
  infoProps?: {
     title?: ReactNode;
     content?: ReactNode;
  };
  // 弹层信息展示
  moreInfoProps?: {
     info?: ReactNode;
     triggerProps?: TriggerProps;
     descriptionsProps?: DescriptionsProps;
  };
  className?: string;
  style?: CSSStyleSheet;
  width?: number | string;
  imageProps?: {
    srcList?: Array<SrcList>; // 图片url数组
    afterImgs?: React.ReactNode; // 插槽,在图片dom节点中
    aspectRatio?: string; // 宽高比  默认3:4
    buttonProps?: ButtonProps;
    current?: number; // 受控展示图
    defaultCurrent?: number; // 默认展示图
    onChangeCurrent?: (current: number) => undefined; // 设置current
    PreviewGroupProps?: ImagePreviewGroupProps;
    src?: string;
  };
  children?: React.ReactNode;
} & CardCheckboxProps;

可以看到,这个业务卡片组件是由多个不同组件组合而成,其承载了渲染和操作(选中操作、图片切换和弹层操作),这个设计的缺陷是显而易见的:

  1. 需要编写很多分散的JSX代码,无论是写在Props中还是定义成单独的组件,其可读性都不高;
  2. 需要在Card组件中杂糅许多额外的Props,例如triggerProps和descriptionsProps,增加了该组件的学习成本;

如果以插槽的方式对Card组件进行改造,通过内部组件间的组合来实现需求,避免了大量组件Props的堆砌,层次清晰、可读性高,这样的组件结构明显易用性更高。

<Card type="verticle" {...cardProps}>
  <CardImage {...ImageProps} />
  <CardContent {...InfoProps} >
      <div className="card-title">title</div>
      <div className="card-content">content</div>
      <Tag>Tag</Tag>
  </CardContent>
  <CardTrigger {...triggerProps}>
      <Description {...descriptionProps}/>
  </CardTrigger>
</Card>

什么是插槽(Slot)?

Slot是Vue框架提出的概念,可以理解为临时占位,可以用其他组件进行填充,Slot能够实现父组件向子组件分发内容的功能。Vue框架中提供了

  1. 使用 props.children 获取子组件,若需要区分使用不同子组件,只能通过数组下标读取。
  2. 使用 Props 传递 ReactNode 元素。
  3. 将组件划分为多个内部组件,交由开发者自行组装。

Slot(内部组件)的使用时机?

  • 布局类组件优先使用Slot,为开发者提供更灵活的使用方式,参考Typography、Layout、Card等组件,开发者可以随意地在这些组件内部插入自定义实现。上文提到的业务卡片组件,实质上也是一个封装了多图预览功能的布局组件,因此更适合使用Slot来组织代码。
  • 内容复杂、定制化程度高的组件更适合使用Slot
  • 功能类组件中,以Props传递ReactNode的方式来接管内部元素,尽量避免传递基础类型元素进行展示。
// ❌ 扩展性低
type CardProps {
    title?: string;
    tags?: string[];
}
// ✅ 为开发者提供对应的“插槽”
type CardProps {
    title?: string | ReactNode;
    tag?: string[] | ReactNode;
}

💡如何提升组件可扩展性?

开闭原则:对扩展开放,模块的行为可以被扩展;对修改关闭,模块中的源代码不应该被修改

将DOM交予用户接管

在前端组件中,应该提供对应的API属性或方法来支持额外的功能,给予开发者更充分的扩展空间,而不是有部分需求无法满足时放弃使用组件。以 Cascader组件 为例(以下例子若无特殊说明,均来自Arco组件库),在通常情况下,仅需要使用左侧的基础选择器即可满足需求。

此时若开发者需要在级联选择器底部添加操作按钮或文字展示,可能会直接修改组件源代码、甚至放弃使用该组件。作为组件开发者,为了提升组件可扩展性,在此处增加了 dropdownRender属性,接收一个返回 ReactNode 的函数,并将此 ReactNode 渲染在级联选择器的特定位置。

<Cascader
    options={options}
    dropdownRender={(menu) => {
      // menu 为所有选择器元素
      return (
        <div>
          {menu}
          <Divider style={{ margin: 0 }} />
          <div style={{ margin: 4 }}>
            The footer content
          </div>
        </div>
      );
    }}
  />

与此类似的,还有 renderFooterrenderOptionrenderFormat 等API,这些API实现难度并不高,一定程度上将DOM元素的掌控权交予组件使用者,作为通用组件,为开发者提供了部分功能和样式的可扩展性。我们在设计前端组件时,多多留意组件中能够接管给使用者的渲染逻辑和操作逻辑,并将这些逻辑暴露出去。

设计可扩展的API

组件开发前,整理组件所需实现的功能,并以功能为维度设计组件API。以下是一个设计移动端选择器的例子,这个选择器需要支持单选、多选和时间选择,于是这样写了第一版API👇,它可以满足我们当前的选择器需求。

type PickerProps {
    dataSource?: PickerData[] | PickerData[][];
    multiple?: boolean; // 是否多选
    time?: boolean; // 是否时间选择
    value?: (string|number)[];
    onChange?: (value: (string|number)[]) => void;
    ...
}

在后续迭代中发现还有地区选择和级联选择的需求,选择器需要进行优化更新,在以上API的基础上只能通过添加cascader、region两个布尔值字段用于标识不同选择需求。这样做的缺陷是很明显的,每当我们新增不同类型的选择功能,都需要新增一个API字段,并且这些字段还是互斥关系。理清问题后,更新了第二版API👇。在组件库中很多API都设计为常量枚举值的形式,即使其只有两个取值,这样扩展性相较于布尔值类型更好。

type PickerProps {
    dataSource?: PickerData[] | PickerData[][];
    type?: "single" | "multiple" | "cascader" | "region" | "time"; // 选择器类型
    value?: (string|number)[];
    onChange?: (value: (string|number)[]) => void;
    ...
}

除上述例子外,还可以利用ts的泛型和可选类型来实现API扩展,例如 Table 组件的pagination、border等字段,既可以直接设置为true/false,也能够以对象的形式进行更详细的配置。

极致的扩展性——Headless UI

首次接触headless概念是在chrome浏览器中,在headless模式下用户无需看到网页界面即可进行网页操作,现在广泛用于web自动化测试和爬虫场景中。与之相似的,Headless UI 是基于 React Hooks 的组件开发设计理念,强调只负责组件的状态及交互逻辑,不关注组件的样式实现。其本质思想其实就是关注点分离,将组件的“状态及交互逻辑”和“UI 展示层”实现解耦。

Headless UI目前有两种主流实现方式,其一是将组件划分为多个原子组件,使用者可以通过填充组件或修改样式的方式来实现自己的需求,其二是以Hooks的方式暴露内置交互功能的子组件属性,使用者可以将这些属性应用于任意组件上,由于没有将样式封装到组件中,Headless组件实现了最大程度的视图层可扩展性。

<NumberInput>
  <NumberInputField />
  <NumberInputStepper>
    <NumberIncrementStepper />
    <NumberDecrementStepper />
  </NumberInputStepper>
</NumberInput>
// chakra-ui 提供的数字增减框组件
function HookUsage() {
  const { getInputProps, getIncrementButtonProps, getDecrementButtonProps } =
    useNumberInput({
      step: 0.01,
      defaultValue: 1.53,
      min: 1,
      max: 6,
      precision: 2,
    })

  const inc = getIncrementButtonProps()
  const dec = getDecrementButtonProps()
  const input = getInputProps()

  return (
    <HStack maxW='320px'>
      <Button {...inc}>+</Button>
      <Input {...input} />
      <Button {...dec}>-</Button>
    </HStack>
  )
}

由于Headless组件的抽象程度较高,所需的设计成本也更高,因此并不适用于所有前端场景。其更适合用于开发横跨多个不同业务或跨端的通用组件,开发者需要在开发底层Headless组件和开发多套组件中衡量成本、作出抉择。

Reference

https://arco.design/react/docs/start

https://juejin.cn/post/7160223720236122125

https://juejin.cn/post/6844904032700481550

您可能也对以下帖子感兴趣

文章有问题?点此查看未经处理的缓存