本文为来自 字节跳动-国际化电商-S 项目团队 成员的文章,已授权 ELab 发布。
为何要进行前端组件设计?
与仅承担数据处理逻辑的后端不同,前端需要负责界面渲染、数据处理、和接口调用,在框架诞生前,更多地是编写页面维度的顺序脚本代码。随着前端继续的持续发展,ES6推出了class语法糖,React提出了函数式组件,Vue则以模版语法的形式组织代码,前端代码逐渐从“平铺”转变到了“层级”结构,从“面向过程”进阶为“面向对象”,前端组件也成为了近几年来的热门议题。
“组件是对数据和方法的简单封装,是软件中具有相对独立功能、接口由契约指定、和语境有明显依赖关系、可独立部署、可组装的软件实体。”这段百科中摘取的组件定义,揭示了组件所需要具备的特性:功能独立、约定一致、可集成、服务于场景。
在软件工程中,软件设计是软件开发流程中的必要阶段,在需求分析后、软件开发前进行。软件复杂度是每一个项目演进的产物,随着需求和代码行数的增加,复杂度将持续提升。软件设计的优劣为对复杂度带来的影响是不同的,优雅、合理的设计使待开发的代码复杂度可控,而拙劣的设计将会给软件带来无序、偶然的复杂度变更。一个优秀的前端组件需要在满足需求的前提下,具备高易用性和良好的可扩展性,这是我们进行前端组件设计的目标。
💡如何提升组件易用性?
合理的组件封装
组件既生于页面,又能够独立于页面。我们不能将整个页面杂糅为一个组件,也不能将每一小块UI都封装为组件。前端组件按类型可以分为容器组件、功能组件和展示组件,一个优秀的组件应该保证:功能内聚、样式统一、并且与父元素仅通过Props通信。
组件的封装粒度并不是越小越好,很多时候一个组件是在其他一个或多个组件的基础上开发的,无法完全以功能点的数量衡量是否遵循单一职责原则,组件开发者需要根据组件功能和目标来确定组件封装粒度:
- 当该组件需要承载具体的额外功能时,相较于新增 API ,封装成独立的组件是更好的选择。
🌰:InputTag组件 在 Input、Tag 的基础上,增加了部分交互功能,API整合了两个组件的属性,作为一个全新的组件提供给开发者使用。相似的,InputNumber、AutoComplete、Mentions等组件也是基于单一职责原则封装的特定功能组件。
- 当组件中存在可能被单独使用、可以承载独立功能的子组件时,可以将其以内部组件的形式提供。
🌰:图片预览功能通常依托着图片组件使用,在实际系统中,唤起图片预览的触发器不一定是图片,可能是按钮或其他触发事件,因此预览组件需要单独提供给开发者使用。预览组件作为 Image 的内部组件,开发者能够以Image.Preview、Image.PreviewGroup的方式使用,并提供左右切换、图片缩放等功能,用户可以通过 srcList、visible、actions、scales等API来控制并定制化预览组件。
规范的API编写
一个易用的组件,使用者无需阅读文档或仅快速浏览文档即可上手使用,并且应当在使用过程中给予清晰的注释和代码提示。希望以下API编写建议能够给组件开发者一些参考:
- 减少必填的API项,尽可能多地提供默认值,降低组件的使用成本;
- 使用通用且有意义的API命名:
- onXXX:命名监听/触发方法
- renderXXX:命名渲染方法
- beforeXXX/afterXXX:命名前置/后置动作
- xxxProps:命名子组件属性
- 优先使用常见单词进行命名,如:value、visible、size、disabled、label、type等等
- 单独维护类型文件,并将其打包至组件产物包中,这样使用者在开发过程中能够实时看到对应的类型提示;
- 在类型文件中,为API编写注释;
[Slot] 与 [Props] 的选择
使用Props存在的问题?
当我们需要实现一个较为复杂的卡片需求组件时,为了最大程度地还原UI、减少用户的样式开发成本,首次设计时我们会设计出这样的API:
可以看到,这个业务卡片组件是由多个不同组件组合而成,其承载了渲染和操作(选中操作、图片切换和弹层操作),这个设计的缺陷是显而易见的:
- 需要编写很多分散的JSX代码,无论是写在Props中还是定义成单独的组件,其可读性都不高;
- 需要在Card组件中杂糅许多额外的Props,例如triggerProps和descriptionsProps,增加了该组件的学习成本;
如果以插槽的方式对Card组件进行改造,通过内部组件间的组合来实现需求,避免了大量组件Props的堆砌,层次清晰、可读性高,这样的组件结构明显易用性更高。
什么是插槽(Slot)?
Slot是Vue框架提出的概念,可以理解为临时占位,可以用其他组件进行填充,Slot能够实现父组件向子组件分发内容的功能。Vue框架中提供了
- 使用 props.children 获取子组件,若需要区分使用不同子组件,只能通过数组下标读取。
- 使用 Props 传递 ReactNode 元素。
- 将组件划分为多个内部组件,交由开发者自行组装。
Slot(内部组件)的使用时机?
- 布局类组件优先使用Slot,为开发者提供更灵活的使用方式,参考Typography、Layout、Card等组件,开发者可以随意地在这些组件内部插入自定义实现。上文提到的业务卡片组件,实质上也是一个封装了多图预览功能的布局组件,因此更适合使用Slot来组织代码。
- 内容复杂、定制化程度高的组件更适合使用Slot
- 功能类组件中,以Props传递ReactNode的方式来接管内部元素,尽量避免传递基础类型元素进行展示。
💡如何提升组件可扩展性?
开闭原则:对扩展开放,模块的行为可以被扩展;对修改关闭,模块中的源代码不应该被修改
将DOM交予用户接管
在前端组件中,应该提供对应的API属性或方法来支持额外的功能,给予开发者更充分的扩展空间,而不是有部分需求无法满足时放弃使用组件。以 Cascader组件 为例(以下例子若无特殊说明,均来自Arco组件库),在通常情况下,仅需要使用左侧的基础选择器即可满足需求。
此时若开发者需要在级联选择器底部添加操作按钮或文字展示,可能会直接修改组件源代码、甚至放弃使用该组件。作为组件开发者,为了提升组件可扩展性,在此处增加了 dropdownRender属性,接收一个返回 ReactNode 的函数,并将此 ReactNode 渲染在级联选择器的特定位置。
与此类似的,还有 renderFooter、renderOption、renderFormat 等API,这些API实现难度并不高,一定程度上将DOM元素的掌控权交予组件使用者,作为通用组件,为开发者提供了部分功能和样式的可扩展性。我们在设计前端组件时,多多留意组件中能够接管给使用者的渲染逻辑和操作逻辑,并将这些逻辑暴露出去。
设计可扩展的API
组件开发前,整理组件所需实现的功能,并以功能为维度设计组件API。以下是一个设计移动端选择器的例子,这个选择器需要支持单选、多选和时间选择,于是这样写了第一版API👇,它可以满足我们当前的选择器需求。
在后续迭代中发现还有地区选择和级联选择的需求,选择器需要进行优化更新,在以上API的基础上只能通过添加cascader、region两个布尔值字段用于标识不同选择需求。这样做的缺陷是很明显的,每当我们新增不同类型的选择功能,都需要新增一个API字段,并且这些字段还是互斥关系。理清问题后,更新了第二版API👇。在组件库中很多API都设计为常量枚举值的形式,即使其只有两个取值,这样扩展性相较于布尔值类型更好。
除上述例子外,还可以利用ts的泛型和可选类型来实现API扩展,例如 Table 组件的pagination、border等字段,既可以直接设置为true/false,也能够以对象的形式进行更详细的配置。
极致的扩展性——Headless UI
首次接触headless概念是在chrome浏览器中,在headless模式下用户无需看到网页界面即可进行网页操作,现在广泛用于web自动化测试和爬虫场景中。与之相似的,Headless UI 是基于 React Hooks 的组件开发设计理念,强调只负责组件的状态及交互逻辑,不关注组件的样式实现。其本质思想其实就是关注点分离,将组件的“状态及交互逻辑”和“UI 展示层”实现解耦。
Headless UI目前有两种主流实现方式,其一是将组件划分为多个原子组件,使用者可以通过填充组件或修改样式的方式来实现自己的需求,其二是以Hooks的方式暴露内置交互功能的子组件属性,使用者可以将这些属性应用于任意组件上,由于没有将样式封装到组件中,Headless组件实现了最大程度的视图层可扩展性。
由于Headless组件的抽象程度较高,所需的设计成本也更高,因此并不适用于所有前端场景。其更适合用于开发横跨多个不同业务或跨端的通用组件,开发者需要在开发底层Headless组件和开发多套组件中衡量成本、作出抉择。
Reference
https://arco.design/react/docs/start
https://juejin.cn/post/7160223720236122125
https://juejin.cn/post/6844904032700481550