控件设计指南

当设计一个控件时,应该考虑的事情。

目录结构

每个控件都应当被视作是独立的软件包、模块,所以它的各方面应该是完备的——除了实现控件的代码,还应有详尽的使用说明文档、可交互的在线 demo、完善的测试代码以及用来做一些自动化处理的元数据等。

上述材料相关文件的目录结构大体如下:

component
   ├── demo                       # 示例相关文件
   │   └── ...
   ├── test                       # 测试相关文件
   │   └── ...
   ├── style                      # 样式相关文件
   │   ├── _functions.scss        # Sass 函数(可选)
   │   ├── _properties.scss       # CSS 自定义属性(必需),风格组件的一部分,供外部运行时自定义主题风格
   │   ├── _variables.scss        # Sass 变量(必需),风格组件的一部分,供外部编辑时/编译时自定义主题风格
   │   ├── _mixins.scss           # Sass 混入(可选)
   │   └── _rules.scss            # CSS 规则(必需),视觉组件,具有约束结构的作用
   ├── typing                     # 类型相关文件
   │   ├── custom-properties.ts   # CSS 自定义属性配置项(必需),用于运行时生成 CSS 自定义属性
   │   ├── aliases.ts             # 类型别名(可选)
   │   ├── interfaces.ts          # 结构组件接口(必需)
   │   └── index.ts               # 类型统一导出
   ├── HeadlessComponent.ts       # 无头组件,控件与结构无关的逻辑
   ├── Component.vue              # 结构组件,受生成 HTML 的 JS 库/框架的源码、平台限定的视图结构描述语言影响
   ├── index.ts                   # 模块统一导出
   ├── changelog.md               # 控件变更记录
   ├── readme.md                  # 控件说明文档
   ├── metadata.yml
   └── package.json

命名约定

HTML & CSS class

基于组件开发(Component-based Development)的体系中,HTML & CSS class 应当是足够语义化的,让人在视图结构中一眼看到后就知道它是个什么东西,而不是长什么样。

因此,HTML & CSS class 的使用理应是 component-first 而非 utility-first——应用 CSS 组件(视觉组件),那些 utility class 作为辅助存在,也就是说,当 CSS 组件自带样式与实际需求有些许不符时,利用 utility class 进行「微调」,而不是在外部重写样式。

综上所述,HTML & CSS class 的命名遵循 BEM 的变体「SUIT CSS 命名约定」:

/* 控件 */
.ComponentName {}

/* 控件修饰符 */
.ComponentName--modifierName {}

/* 控件后代 */
.ComponentName-descendentName {}

/* 控件状态 */
.ComponentName.is-stateOfComponent {}

/* 辅助工具 */
.u-utilityName {}

控件基类 .ComponentName 及其后代 .ComponentName-descendentName 很好理解,它们之间天然具有层级关系,共同描述了一个控件的结构:

<!-- 用语义化 HTML 标签 -->
<article class="Article">
  <header class="Article-header">
    <h1 class="Article-title">文章标题</h1>
  </header>
  <section class="Article-section">
    <h2>章节标题</h2>
    <p>章节段落</p>
  </section>
  <footer class="Article-footer">一些其他信息</footer>
</article>

<!-- 用非语义化 HTML 标签,更能凸显出 class 命名语义化的作用 -->
<div class="Article">
  <div class="Article-header">
    <h1 class="Article-title">文章标题</h1>
  </div>
  <div class="Article-section">
    <h2>章节标题</h2>
    <p>章节段落</p>
  </div>
  <div class="Article-footer">一些其他信息</div>
</div>

而控件修饰符 .ComponentName--modifierName 和控件状态 .ComponentName.is-stateOfComponent 有时就不能很好地区分何时该用哪个了。就拿按钮控件来说,它的颜色、是否可用与尺寸,哪个该用修饰符?哪个算是状态?

一个比较简单的判断标准:如果是控件的特性,即不会因为什么条件而改变的,用修饰符;倘若会因某个条件满足与否而变化,那就是状态。

<!-- 用语义化 HTML 标签,大号(尺寸)的主要(功能色)操作按钮 -->
<button class="Button Button--primary Button--large">新增</button>

<!-- 用非语义化 HTML 标签,不可用(状态)的危险(功能色)操作按钮 -->
<span class="Button Button--danger is-disabled">批量删除</span>

应该注意的是,控件修饰符和控件状态都是直接加在控件的根节点上的,也就是要跟在控件基类的后面,不能用于控件后代上。假如一个控件后代需要程序化地改变它本身的样式,要用辅助工具类而不是状态类。当一个控件后代的结构、功能等变得复杂时,要将其封装成一个新的控件。

Sass 变量与 CSS 自定义属性

在这里,Sass 变量与 CSS 自定义属性的命名方式比较类似,它们大概都是 <namespace>-<component-name>[-descendent-name|-modifier-name][-state]-(variable-name|property-name) 的形式。其中,<namespace> 部分是 petals

Sass 变量是以 $__petals$petals 开头,与控件名之间用 -- 连接,前者是内部使用(私有)的,上层开发者无需关心,后者是供外部在编辑时/编译时定制用;CSS 自定义属性则用 --petals 开头,以 - 与控件名相连:

/* 实际形式:<namespace>-<component-name>-(variable-name|property-name) */
$__petals--button-font-size: --petals-button-font-size;
$__petals--button-line-height: --petals-button-line-height;

/* 实际形式:<namespace>-<component-name>-<modifier-name>-<state>-(variable-name|property-name) */
$petals--button-primary-focus-color: var($__petals--primary-active-color, $petals--primary-active-color) !default;
$petals--button-primary-focus-bg: var($__petals--primary-active-bg, $petals--primary-active-bg) !default;

控件属性与事件

事件命名的基本形式是「on-」后接动词,如:onClickonChange。但有时只接一个动词无法确切表达,需在动词前加名词用作补充,如:onSelectionChangeonVisibilityChange

设计原则

顺其「自然」

「控件」是什么?可以认为它是一个返回视图结构的函数,而控件的属性(prop)和事件(event)就是这个「函数」的参数。属性是控件的外部与其内部进行主动通信的数据,事件则是进行被动通信的回调函数。

一个封装得好的函数,它的参数应尽可能少,要想明白每个参数的语义,且必须确实有其存在的意义——控件的属性和事件的设计也该如此。

在设计控件的属性时,先思考下要加的这个属性是不是属于这个控件本身的特性?若不是,那要加的属性的值所对应的控件的特性是什么?如果这两个问题都没有得到答案,那么这个属性可以不用加了。

控件的属性只应与其本身的特性有关,与业务意义无关——自身特性是自然特性,业务意义是附加特性。

比如,一个按钮控件通常会有「主要」、「次要」和「危险」这几种多少与业务沾边的语义,那么控件的属性该如何设计来满足这种需求呢?

Ant Design 和 Element 的做法是将其作为 type 属性的值或独立成一个属性:

<Button type="primary">Ant Design 中的主要按钮</Button>
<Button>Ant Design 中的次要(默认)按钮</Button>
<Button danger>Ant Design 中的危险按钮</Button>

<el-button type="primary">Element 中的主要按钮</el-button>
<el-button>Element 中的次要(默认)按钮</el-button>
<el-button type="danger">Element 中的危险按钮</el-button>

按照上面说的控件属性设计原则来看,「主要」、「次要」和「危险」作用到按钮控件上的表现主要是颜色发生了变化,所以应该去用表示按钮的自然特性「颜色」的 color 属性来满足同样的需求:

<button color="primary">主要按钮</button>
<button>次要(默认)按钮</button>
<button color="danger">危险按钮</button>

<!-- 还可以扩展出其他任意多颜色的按钮 -->
<button color="f00">红色按钮</button>
<button color="yellow">黄色按钮</button>
<button color="blue">蓝色按钮</button>

若控件的某组特性是二元对立的,如「禁用」与「启用」,则选择默认不生效的那个作为属性,且属性值是布尔型,默认值为 false

还是拿按钮控件来举例:如果默认是「禁用」,那就设计一个代表「启用」的 enabled 属性,其默认值是 false,只要控件在被使用时传入了 enabled,就变成了「启用」状态;反之亦然。

另外,控件的属性值尽可能是简单数据类型,也就是数字、字符串等。

如果控件需要处理具有业务、配置语义的东西,该怎么弄?先来拿人做类比,帮助理解下——

人有头、躯干、四肢、脑、五脏六腑等组成部分,经过脑活动可以进行交流、创造等——这些是人的自然特性。人的职业、角色、身份等是自然特性吗?当然不是!这些是人脑的运作机制在特定的环境、上下文中对接收到的信息处理后所形成的结果。

由此可见,人的自然特性是有限的,而由自然特性所衍生出来职业、角色、身份等则是无限的。鉴于此,倘若把控件的非自然特性设计为属性,其数量将多如牛毛。那些具有业务、配置语义的东西,理应作为环境、上下文被控件内部起到人脑作用的程序所「理解」,并做出相应的「反应」或「动作」。

在 Petals 中,作为「人脑」存在的是控件配置的 setter 与 getter、每个控件的无头组件及结构组件的基类。

受控结构生成

什么是「受控结构」?在这里是指「可被使用者自定义的受控件本身控制的内部结构」。它有别于子控件,会被渲染到控件内部预先设置的「指定坑位」——Vue 中具名插槽的内容就是。

具名插槽是个很是便利的机制,即便如此,作为一个跨环境的控件体系,要么在每个环境中都实现具名插槽机制,要么在 Vue 中舍弃具名插槽而另辟蹊径——显然后者是更明智的选择。

不考虑具名插槽,生成受控结构主要有两个手段:一是通过控件的属性传入数据结构或返回数据结构的函数,如所谓的「render prop」,常用于比较简单的结构;二是设计专门的控件,如 Ant Design 中的 TabPane 之于 Tabs,主要在结构较为复杂时使用。

在 Ant Design 中,控件属性大量使用虚拟 DOM 节点,这违背了「属性值尽可能是简单数据类型」的原则,在 Petals 里是反模式。

假如有个 Card,它的内部从上到下被划分为 header、body 和 footer 三个部分:header 中是标题、图标和一些操作;子控件被放在 body 内;footer 中则可以是操作或其他附加信息。默认情况下,header 和 footer 中是没内容的,所以连它们本身都不生成。

这要设计一个字符串类型的 title 属性用来设置标题,在使用者给这个属性传值后会生成 header。当使用者想在标题前显示图标,或在 Card 的右上角显示一些操作时,该怎么办?

Ant Design 的做法是 title 属性接收虚拟 DOM 节点,这就可以传入一个视图结构了;同理,又弄了个接收虚拟 DOM 节点的 extra 属性去控制 Card 右上角的结构。

在这里,符合原则的做法是——设计一个专门的控件,姑且叫做 CardHeader,作用就是让使用者能够完全自定义 header 中的内容;当检测到 CardHeader 时就忽略 title 属性。

为什么不是给 Card 添加 icon 属性去控制图标的显示,设计 actions 属性传入对象数组去生成右上角的操作呢?

首先,如果把 iconactions 这两个属性加到 Card 上,没看过使用手册的使用者在看到它们时,第一反应会想到它们实际是影响 header 部分吗?

若是加到 Card 上,它们既可以被理解成在 header 里,又可以被认为是在 body 或 footer 里,因为这两个词不具备唯一性,不像 title;反而加到 CardHeader 上比较合适。

其次,要是非要直接通过 Card 来控制,就得加上限定词让人更容易理解,如:headerIconheaderActions。但这样一来,结构控制都集中到了 Card 上,它的属性将越来越多,这就又违背了「控件属性和事件应尽可能少」的原则。

再者,如上面所说,用复杂数据类型做属性值是要尽量避免的,即用数字、字符串等简单数据类型做属性值。虽然如此,但有时传对象或数组却可能会更合适些。

比如,有些控件会包含一些可自定义的操作,这种情况下再让使用者手动拼个视图结构的话,未免太不贴心了;这时若能通过传入一组代表操作配置的数据结构去生成一堆按钮,应该会更好些。需要注意的是,这个配置应当是(一定范围内)规范化的对操作(动作)的抽象。

总而言之,受控结构的生成优先考虑设计一个专门的控件,让属性列表尽可能简洁与整洁,把视图结构放回到它该待的地方;「不得已」时可以采用一个接收具有配置语义的数据结构的属性,如可能泛化的操作配置。

样式

在结构组件中对接视觉组件时,要用 CSS Modules,以避免外部的样式代码所引起的非预期效果。

API 索引

设计控件 API 时经过思考抉择所沉淀的「词汇表」,在进行后续的控件设计时优先使用这里罗列的 API。

控件属性

语义 属性名 值类型/可选值 说明
自定义类名 className 视情况而定 全控件通用
自定义样式 style object 全控件通用
数据源 dataSource object | object[] 视图类控件使用
当前值 value 视情况而定 字段类控件使用
默认值 defaultValue 视情况而定 字段类控件使用
可清除 clearable boolean 字段类控件使用
可搜索 searchable boolean 字段类控件使用
唯一标识 flag string | number 列表条目使用,默认生成时以数组索引为依据,而不是「第几个」
当前激活项标识 activeFlag string | number 列表使用,默认生成时是数组索引下标 0,而不是「第一个」的 1
列表条目被选中 selected boolean -
单选框、多选框被选中 checked boolean 若单选框、多选框是在列表条目中,使用 selected 而非 checked
条目铺展方向 direction 'horizontal' | 'vertical' 「列表」类控件对子控件的铺展方向进行控制
尺寸 size 'large' | 'medium' | 'small' -
形状 shape 视情况而定 -
位置 placement 视情况而定 有的控件可以指定出现(放置)的「位置」,这种场景用 placement 而不用 position
弹出层触发方式 trigger 视情况而定 -
弹出层类名 popupClassName 视情况而定 -
标题 title string  
内容 content string 主要性质的文本,一般较为详细
文本标签 label string  
描述 description string 辅助性质的文本,一般较为简洁
对齐方式 alignment 视情况而定 文本或布局的对齐方式
密度 density 'high' | 'medium' | 'low' 内容的密集程度,主要用于「列表」类视图

控件事件

语义 事件名 回调函数 说明
弹出层可见性变化 onVisibleChange (visible: boolean) => void -
选取列表中的条目 onSelect ((flag: string | number) => void) | ((flags: (string | number)[]) => void) -
树形结构节点展开状态变化      

为何是 A 非 B

记录在做 API 设计时的抉择。

A = operationText,B = operations

在自定义 Transfer 的操作文案时,Ant Design 和 iView 的相关 API 命名为 operations,Element 为 button-texts

由于「操作」在视觉上未必是「按钮」的形态,选择语义更为泛化一些的 operationbutton 合适。

并且,可自定义的只有文案,单纯的 operation 在语义上所包含的内容更多一些,因此要加上 text 去限制住理解上的「可自定义的范围」。

另外,在计算机相关场景中,text 是不可数名词。

目录