本文是文章系列「聊聊前端 UI 组件」的第三篇。
在本系列的上篇文章《聊聊前端 UI 组件:组件特征》中,通过从关注点分离的角度进行前端 UI 组件的构成分析,并以较为抽象的视角对 UI 组件分门别类,以及描述了让组件间可以表现复用的继承关系,从而建立出前端 UI 组件的特征模型。
本文将以上篇文章中所得出的特征模型为基础,探讨下如何设计并建立一个前端 UI 组件体系。
在做组件体系设计的时候,最重要的一点就是——要真真正正地想着把 UI 组件弄成可复用的,就像制造业生产时所用的物料一样——构造可交换的 UI 组件。
由于 UI 组件构成元素的易变性对组件体系的设计有着很大的影响,为了方便查看,将上篇文章中的易变性及其影响因素的表格搬过来:
构成 | 易变性 | 影响因素 | |
结构 | 视觉结构 | 不易变 | 内容结构、布局类样式 |
内容结构 | 较易变 | 生成 HTML 的 JS 库/框架的源码、平台限定的视图结构描述语言 | |
表现 | 主题风格 | 很易变 | GUI 设计人员的审美和想法、非布局类样式、图标与图片 |
行为 | 交互逻辑 | 不易变 | 交互设计人员的想法 |
业务逻辑 | 很易变 | 业务规则 |
组件架构
表格中列出的 UI 组件构成元素都可以作为单独的组件存在。如果把 UI 组件看作是「最终产品」的话,那么 UI 组件构成元素所对应的那些组件就是「中间产品」。
在软件工程中,「组件(component)」一般是指软件的可复用块,好比制造业所使用的「构件」。这是一个比较宽泛的概念,它可以是软件包,可以是 web 服务,也可以是模块等。
但在前端眼里,「组件」通常是指页面上的视图单元,即「UI 组件」。可以说,「UI 组件」是「组件」的子集。
欧雷殿,公众号:Coding as Hobby聊聊前端 UI 组件:核心概念
鉴于上述原因,这里需要特别说明下:上文所说的「作为单独的组件存在」中的「组件」是指「软件的可复用块」,而不是「UI 组件」。
风格组件
在上篇文章中提到了「虚拟组件」的概念——
在继续往下之前,先引入一个「虚拟组件」的概念。正如它的名字所示,是一个虚拟的,实际不存在的,只是概念上的组件。它是几个主题风格属性的集合。
欧雷殿,公众号:Coding as Hobby聊聊前端 UI 组件:组件特征
与之相似,「风格组件」也是一些主题风格属性的集合,大概包括:
如果要用代码来体现的话,可以借助 CSS 预处理器中的变量。这里用 Sass 来举例:
// 主题色$sc--primary: #cce5ff !default;$sc--secondary: #e2e3e5 !default;$sc--info: #d1ecf1 !default;$sc--success: #d4edda !default;$sc--warning: #fff3cd !default;$sc--danger: #f8d7da !default;// 文本色$sc--text-primary: #303133 !default;$sc--text-secondary: #696c71 !default;$sc--text-heading: #2c405a !default;$sc--text-regular: #333 !default;$sc--text-placeholder: #c0c4cc !default;// 字体尺寸$sc--font-size: 14px !default;$sc--font-size-lg: 16px !default;$sc--font-size-sm: 12px !default;// 字体粗细$sc--font-weight-light: 300 !default;$sc--font-weight-normal: 400 !default;$sc--font-weight-bold: 700 !default;// 边框粗细$sc--border-width: 1px !default;// 边框颜色$sc--border-color: #dcdfe6 !default;// 边框圆角$sc--border-radius: 4px !default;$sc--border-radius-lg: 6px !default;$sc--border-radius-sm: 2px !default;
风格组件与表现复用的继承密切相关——
输入框组件、下拉列表组件等都属于表单控件(form control),它们都继承自「表单控件」这个虚拟组件,如果各自没有指定颜色、字体、边框等主题风格属性的话,将会按照虚拟组件中所设定的来显示。类似地,下拉列表组件、下拉菜单组件等都有弹出层(pop-up),它们都继承了「弹出层」这个虚拟组件。
想必你已经发现了,下拉列表组件同时继承了「表单控件」和「弹出层」这两个虚拟组件,这就是上面提到的「多重继承」。
那些所谓的「虚拟组件」,它们也遵循着同样的继承规则——如果自身没有指定特定的主题风格属性,则会按照父级所设定的显示。那么,虚拟组件的「父级」是啥呢?——是基础风格。
欧雷殿,公众号:Coding as Hobby聊聊前端 UI 组件:组件特征
上面示例中所定义的 Sass 变量就是「基础风格」。
以「表单控件」为例,一个继承了基础风格的虚拟组件用代码表示为:
$sc--form-control-font-size: $sc--font-size !default;$sc--form-control-font-size-lg: $sc--font-size-lg !default;$sc--form-control-font-size-sm: $sc--font-size-sm !default;$sc--form-control-height: 36px !default;$sc--form-control-height-lg: 40px !default;$sc--form-control-height-sm: 32px !default;$sc--form-control-color: $sc--text-regular !default;$sc--form-control-placeholder-color: $sc--text-placeholder !default;$sc--form-control-bg: #fff !default;$sc--form-control-box-shadow: none !default;$sc--form-control-border-width: $sc--border-width !default;$sc--form-control-border-color: $sc--border-color !default;$sc--form-control-border-radius: $sc--border-radius !default;$sc--form-control-border-radius-lg: $sc--border-radius-lg !default;$sc--form-control-border-radius-sm: $sc--border-radius-sm !default;
相应地,输入框组件的风格组件部分大致为:
$sc--input-font-size: $sc--form-control-font-size !default;$sc--input-font-size-lg: $sc--form-control-font-size-lg !default;$sc--input-font-size-sm: $sc--form-control-font-size-sm !default;$sc--input-height: $sc--form-control-height !default;$sc--input-height-lg: $sc--form-control-height-lg !default;$sc--input-height-sm: $sc--form-control-height-sm !default;$sc--input-color: $sc--form-control-color !default;$sc--input-placeholder-color: $sc--form-control-placeholder-color !default;$sc--input-bg: $sc--form-control-bg !default;$sc--input-box-shadow: $sc--form-control-box-shadow !default;$sc--input-border-width: $sc--form-control-border-width !default;$sc--input-border-color: $sc--form-control-border-color !default;$sc--input-border-radius: $sc--form-control-border-radius !default;$sc--input-border-radius-lg: $sc--form-control-border-radius-lg !default;$sc--input-border-radius-sm: $sc--form-control-border-radius-sm !default;
视觉组件
虽然 UI 组件的最终呈现需要内容结构作为骨架去支撑,但若仅仅是为了勾勒出 UI 组件视觉结构的轮廓,只用 CSS 就可以了。一系列模块化、可复用、可组合的 CSS 规则构成了「视觉组件」,也可叫做「CSS 组件」。
在视觉组件中,得用 BEM 之类的命名法为 CSS 类选择器命名。推荐使用由 BEM 衍生出来的这种:
/* 组件 */.ComponentName {}/* 组件后代 */.ComponentName-descendentName {}/* 组件修饰符 */.ComponentName--modifierName {}/* 组件状态 */.ComponentName.is-stateOfComponent {}
一个完整的视觉组件中已经包含了风格组件。拿按钮组件来举例的话,它的视觉组件大体是这样:
$sc--button-font-size: $sc--form-control-font-size !default;$sc--button-font-size-lg: $sc--form-control-font-size-lg !default;$sc--button-font-size-sm: $sc--form-control-font-size-sm !default;$sc--button-padding-y: 10px !default;$sc--button-padding-y-lg: 12px !default;$sc--button-padding-y-sm: 9px !default;$sc--button-padding-x: 20px !default;$sc--button-padding-x-lg: 20px !default;$sc--button-padding-x-sm: 15px !default;$sc--button-color: $sc--form-control-color !default;$sc--button-bg: $sc--form-control-bg !default;$sc--button-box-shadow: $sc--form-control-box-shadow !default;$sc--button-border-width: $sc--form-control-border-width !default;$sc--button-border-color: $sc--form-control-border-color !default;$sc--button-border-radius: $sc--form-control-border-radius !default;$sc--button-border-radius-lg: $sc--form-control-border-radius-lg !default;$sc--button-border-radius-sm: $sc--form-control-border-radius-sm !default;$sc--button-disabled-color: $sc--text-placeholder !default;$sc--button-disabled-bg: #eee !default;/* ----- 以上为风格组件部分 ----- */.Button { padding: $sc--button-padding-y $sc--button-padding-x; font-size: $sc--button-font-size; color: $sc--button-color; background-color: $sc--button-bg; border: $sc--button-border-width solid $sc--button-border-color; border-radius: $sc--button-border-radius; box-shadow: $sc--button-box-shadow; &-icon, &-text { display: inline-block; vertical-align: middle; } &-icon + &-text { margin-left: 5px; } // 大按钮 &--large { padding: $sc--button-padding-y-lg $sc--button-padding-x-lg; font-size: $sc--button-font-size-lg; border-radius: $sc--button-border-radius-lg; } // 小按钮 &--small { padding: $sc--button-padding-y-sm $sc--button-padding-x-sm; font-size: $sc--button-font-size-sm; border-radius: $sc--button-border-radius-sm; } // 按钮失效/禁用状态 &.is-disabled { color: $sc--button-disabled-color; background-color: $sc--button-disabled-bg; }}
从上面的代码示例中可以看出,按钮组件中包含了「图标」和「文本」这两个横向排列且垂直居中的「后代」,并有「常规」、「大」和「小」三种「规格」,以及有「正常」和「失效/禁用」两种「状态」——通过 CSS 描绘出了 UI 组件视觉上的基本结构与特性。
无头组件
「无头」这个词译自「headless」,在计算机领域中代表硬件或软件在使用或运行时不需要依赖 GUI 相关的设备或库。在这里,「无头组件」是指 UI 组件的交互逻辑,以及与之相融合的业务逻辑。
无头组件的职责是负责监听并接收事件系统的通知,提供处理 UI 组件自身状态、数据转换逻辑的函数或方法,它不应该关注和处理除了交互逻辑之外的事情。
在无头组件中,所监听并接收的并非是运行环境提供的真实事件,而是自定义的「代理事件」,它是真实事件的占位符。这么做的主要原因是,同一个行为虽然可能是由不同的真实事件触发的,但对 UI 组件而言其语义是相同的——通过代理事件来表达对 UI 组件有意义的真实语义。
就拿下拉菜单组件来说,它的弹出层的显示可以通过其所包含的按钮的 click
或 mouseover
这两个真实事件来触发,但对 UI 组件的真实语义是「弹出」而非「点击」或「悬停」,因而使用代理事件 pop-up
来替代。
上文说到无头组件是「UI 组件的交互逻辑及与之相融合的业务逻辑」,又说「不应该关注和处理除了交互逻辑之外的事情」,这两点乍看之下相互矛盾,然而并不——
业务逻辑对于一个网站、应用来说是十分必要且重要的,但对 UI 组件来说,它就没那么必要了,更谈不上重要。在前端的 GUI 层面,业务逻辑理应是交互逻辑的延伸。
欧雷殿,公众号:Coding as Hobby聊聊前端 UI 组件:组件特征
在 UI 组件中,业务逻辑与事件是息息相关的,不仅仅是 UI 事件,如点击按钮后发出 HTTP 请求;还有数据事件,如业务数据变化后更新显示的文本。因此,业务逻辑是交互逻辑的延伸。这就需要无头组件在处理交互逻辑时提供扩展点,比如「事件映射」,以使业务逻辑作为无头组件的扩展存在,而不是集成进去。
无头组件会根据代理事件去调用事件处理函数。在未指定的情况下,代理事件会默认指向一个真实事件,事件处理函数会执行一段默认的处理逻辑;事件映射的作用就是更改代理事件所指向的真实事件以及事件处理函数的逻辑。
无头组件的接口定义大概长这样:
// 代理事件type EventBroker = string;// 真实事件type EventName = string;// 事件处理函数type EventHandler = (params: any) => void;// 事件对象type EventObject = { name: EventName; handler: EventHandler };// 事件映射type EventMap = { [key: string]: EventName | EventHandler | EventObject };interface IHeadlessComponent { // 事件映射 setEventMap(map: EventMap): void; // 获取真实事件 getEventName(broker: EventBroker): EventName; // 获取事件处理函数 getEventHandler(broker: EventBroker): EventHandler;}
结构组件
顾名思义,「结构组件」是用来生成 UI 组件内容结构的,但它的作用不仅如此,还会负责对接视觉组件与无头组件。
如果单纯从最终的 HTML 结构上来看,它也算是不易变的,但在现代前端开发中,HTML 的结构基本是动态生成的,并且强依赖于 React、Vue 这类没有统一标准的 JS 库/框架。另外,还存在着像 WXML、AXML 这类平台限定的视图结构描述语言。由于写法不一致,这就使页面内容结构变得不那么稳定。
欧雷殿,公众号:Coding as Hobby聊聊前端 UI 组件:组件特征
如上所述,UI 组件的内容结构依赖于视图结构描述语法,视图结构描述语法又取决于平台或运行环境,这就导致了结构组件无法像风格组件、视觉组件和无头组件一样将变化隔离在外部,因而结构组件是构成 UI 组件的几个组件中易变性最强的,最容易被替换的。
用 Vue 2.x 版本的类组件写法来举例,下拉菜单组件的结构组件大致为:
<template> <div :class="$style.Dropdown"> <button :class="$style['Dropdown-trigger']" @[popUpEventName]="handlePopUp">显示弹出层button> <div :class="[$style['Dropdown-popup'], { [$style['is-shown']]: isPopUpShown }]">我是弹出层div> div>template><style lang="scss" src="./style.scss" module>style><script lang="ts">import { Vue, Component, Prop } from 'vue-property-decorator'; // 无头组件import DropdownHeadlessComponent from './headless';@Componentexport default class DropdownComponent extends Vue { // 事件映射配置外置 @Prop({ type: Object, default: () => ({}) }) private readonly eventMap!: { [key: string]: any }; private popUpEventName: string = ''; private isPopUpShown: boolean = false; private handlePopUp(): void { this.isPopUpShown = true; } // 初始化无头组件实例及与其相关的 private initHeadless(): void { const hc = new DropdownHeadlessComponent(); hc.setEventMap(this.eventMap); this.popUpEventName = hc.getEventName('pop-up'); } public created(): void { this.initHeadless(); }}script>
倘若要支持多技术栈、多平台,当前流行的主要有两种策略:在各技术栈、各平台下分别实现结构组件;利用 Taro、uni-app 这类工具进行转译。
可定制性
上文阐述的组件架构将一个原本很容易实现得鱼龙混杂的 UI 组件根据关注点拆分成了风格组件、视觉组件、无头组件和结构组件,这种架构会使各部分的可复用性得到很大的提升。除了易变性较强的结构组件之外,其他组件在达到一定成熟度之后就基本不会变动。假如需要更换技术栈或新支持一个平台,只需实现一遍结构组件即可,其他组件可以拿来就用。
不单是可复用性有所改善,可定制性也有所加强。根据定制代码/配置与程序结合时所处的程序生命周期阶段,将可定制性整理为下表:
可定制点 | 编辑时/编译时 | 运行时 |
主题风格 | ✓ | ✓ |
视觉结构 | ✓ | ✓ |
触发事件 | ✓ | ✓ |
业务逻辑 | ✓ | ✓ |
内容结构 | ✓ | ✗ |
如果风格组件的代码是像示例代码中写的那样,是不支持运行时定制的,得稍微改造一下:
// 未经改造$sc--font-size: 14px !default;// 利用 CSS 自定义属性改造后$sc--font-size: var(--sc-font-size, 14px) !default;
组件规范
每个 UI 组件都应当被视作是独立的软件包、模块,所以它的各方面应该是完备的——除了实现 UI 组件的代码,还应有详尽的使用说明文档、可交互的在线 demo、完善的测试代码以及用来做一些自动化处理的元数据等。
还是以下拉菜单组件为例,上述材料相关文件的目录结构大体如下:
dropdown ├── demo │ └── ... ├── test │ └── ... ├── changelog.md ├── headless.ts ├── index.ts ├── metadata.yml ├── package.json ├── readme.md ├── structure.vue └── style.scss
代码编写方面可以参考我总结并整理的代码风格指南:https://ntks.ourai.ws/guides/coding-style/。
另外,在结构组件中对接视觉组件时,要用 CSS Modules,以避免外部的样式代码所引起的非预期效果。
总结
本文基于本系列的上篇文章中得出的特征模型提出了一个以「构造可交换的 UI 组件」为目标的组件架构,主要由风格组件、视觉组件、无头组件和结构组件所构成,除了结构组件之外的组件可复用性都很高。
当一个 UI 组件是可交换的时,就可以围绕它做一些比较有趣且有价值的事情了。
最后的最后,文中的示例代码是为了帮助理解而写,仅供参考。;-)