谈谈原子类

2021-11-24

There are only two hard things in Computer Science: cache invalidation and naming things. – Phil Karlton

从定义开始

Atomic CSS is the approach to CSS architecture that favors small, single-purpose classes with names based on visual function.

原子化 CSS 是一种让 CSS 架构偏好更细粒度、更单一职责、按视觉命名类的方法论

原子类(Atomic CSS)这个词,由 Thierry Koblenz 在文章 Challenging CSS Best Practices 中首次提出和定义。按照定义可以知道,原子类是一种以命名(Naming)为手段,构建 CSS 架构的方法论

一个原子类方法定义的类名有这些特点:

  • 细粒度
  • 单一职责
  • 按视觉效果命名

实践的例子

一个最简单的例子:

.bgr-blue { 
  background-color: #357edd; 
}

.bgr-blue 类只有一个目的:把背景色变为蓝色,其命名也按照视觉上的作用,所以是一个原子类。

基本思想

Atomic CSS offers a straightforward, obvious, and simple methodology. Classes are immutable – they don’t change. This makes the application of CSS predictable and reliable as classes will always do exactly the same thing.
– Callum Jefferies

让 CSS 变得直接了当、明显、简单。类名变得更静态、可预测。

高度复用的样式

我想从一个例子入手来介绍原子类如何做到样式的复用。

我们现在要做一个小组件展示猫的名称以及猫入驻的时间:

image.png

传统的 CSS “最佳实践”

我们遵循关注点分离的原则,先编写结构:

关注点分离(Separation of concerns,SoC),是将计算机程序分隔为不同部分的设计原则。每一部分会有各自的关注焦点。

<div class="cat">
  <img class="figure" width="60" src="https://images.unsplash.com/photo-1529778873920-4da4926a72c2?&auto=format&fit=crop&w=200&h=200"></img>
  <div class="name">可爱的猫猫</div>
  <div class="time">一周以前</div>
</div>

然后我们引入一个针对这个组件的样式表,来负责这个组件的样式实现:

.cat {
  display: inline-block;
	/** for BFC */
	overflow: hidden;
}

.cat .figure {
  margin-right: 16px;
  border-radius: 8px;

  float: left;
}

.cat .name {
  font-size: 16px;
  color: black;
}

.cat .time {
  font-size: 12px;
  color: gray;
}

css 的每个 class 都是由元素的语义决定的,比如猫的名称就叫 cat-name,猫的图片就叫cat-figure,这种方法让 html 只关注结构,css 根据 html 定义的结构实现对应的样式。

原子类方法

使用原子类的思想,我们编写一个相同的组件:

<div class="inline BFC">
  <img class="float-start bdrs-md mr-md" width="60" src="https://images.unsplash.com/photo-1529778873920-4da4926a72c2?&auto=format&fit=crop&w=200&h=200"></img>
  <div class="text-primary">可爱的猫猫</div>
  <div class="text-secondary">一周以前</div>
</div>

同样我们有一个样式表,差别是这个样式表应该对全局生效

.BFC {
  overflow: hidden;
}

.bdrs-md {
  border-radius: 8px;
}

.mr-md {
  margin-right: 16px;
}

.float-start {
  float: left;
}

.text-primary {
  font-size: 16px;
  color: black;
}

.text-secondary {
  font-size: 12px;
  color: gray;
}

.inline-block {
  display: inline-block;
}

对比传统关注点分离方法,这里的 CSS 的每个类是根据它的视觉效果命名,并且拆分出了一些更小的类,让每个类实现的样式保持单一的职责

这两种方法在这个阶段编写的代码量是差不多的,也都能实现目标的组件 UI:

image.png

当变更发生

继续,我们现在需要一个尺寸更大的组件,以便更好的展示猫:

image.png

对于传统的关注点分离方法,我们重新建立一个结构,并使用 class 为 html 结构中的各个元素命名

<div class="cat-card">
  <img class="cat-figure" width="160" src="https://images.unsplash.com/photo-1529778873920-4da4926a72c2?&auto=format&fit=crop&w=200&h=200"></img>
  <div class="cat-info">
    <div class="label">猫的名称</div>
    <div class="value">可爱的猫猫</div>
    <div class="label">入住日期</div>
    <div class="value">一周以前</div>
  </div>
</div>

这样的变化使得样式几乎要完全重写,代码可以参考 sandbox

类按照 HTML 结构命名,使用 css 选择器按照 html 嵌套结构来编写。一旦结构发生变化,原来的类就会失效。

而原子类方法呢?

<div class="bdrs-md BFC inline-block border-card">
  <img class="block mb-md" width="160" src="https://images.unsplash.com/photo-1529778873920-4da4926a72c2?&auto=format&fit=crop&w=200&h=200"></img>
  <div class="pl-lg pb-lg">
    <div class="text-secondary mt-sm mb-xs">猫的名称</div>
    <div class="text-primary">可爱的猫猫</div>
    <div class="text-secondary mt-sm mb-xs">入住日期</div>
    <div class="text-primary">一周以前</div>
  </div>
</div>

可以看到复用了很多之前的类,需要新增的类很少,代码可以参考 sandbox

margin、padding 类命名方式参考 emmet cheat sheet。比如 mb-* 代表 margin-bottom:*

设计规范先行

前端项目的样式通常由设计师画出设计稿,然后前端同学来实现。

对原子类方法常见一个困惑是:“要求开发者把 margin-bottom:16px这种原本很自由的 css 语句,写成mb-md这种类,会降低编写样式的自由度呀。如果设计稿上出现 15px、17px、18px 这种没有包含在原子类中的边距怎么办呢?“

有这种疑惑,说明很多前端同学可能对设计规范不太了解。以间距为例几个例子。

设计是崇尚规范的

从两个非常流行的前端组件库中看他们的设计规范如何定义:

  • material design 作为业界标杆,定义了一套基于栅格的间距规范,非常细致和明确。

    image.png

  • 国内流行的 ant-design 的设计规范,明确定义了纵向间距为:小 8px,中 16px,大 24px。

    image.png

从这些例子可以看出,其实设计是推崇规范的。如果原子类的定义是建立在设计和开发对设计规范的共识上,就不会出现本段最开始担忧的问题。换种说法,原子类应该是设计规范的落地,是设计规范先行。

设计推崇规范很大程度来源于有广泛认同的 Robin Williams 的设计四大基本原则: 亲密、对齐、重复、对比。

设计的重复原则指出:设计的某些方面需要在整个作品中重复。 重复元素可能是一种粗字体、 一条粗线、 某个项目符号、 颜色、 设计要素、 某种格式、 空间关系等。 读者能看到的任何方面都可以作为重复元素。

设计的对齐原则指出:任何元素都不能在页面上随意安放。 每一项都应当与页面上的某个内容存在某种视觉联系。应当找一条明确的对齐线, 并用它来对齐。

这也是原子类的优点,当我们要求开发者使用原子类实现样式时,也能很大程度上避免因为看错、写错导致的样式不还原。毕竟写错 mb-md 比写错 margin-bottom: 16px 难得多。

原子类的定义来自设计规范

从另一个方面说明什么是设计规范先行的观点,我们对比理想情况下原子类和常规方案编写组件的过程:

image.png

左侧为传统方案,右侧为原子类方案

理想情况下,原子类方案将定义样式类放到了编写 HTML 结构之前。原子类的定义不按照设计稿,而是按照设计规范,设计师再按照定好的规范进行设计,自然也能恰好落在原子类中。

我们都用过微软的文字编辑器 word,可以打个比方:

  • 原子类就相当于段落格式——先定义后使用,每一段的样式都是在已定义的格式之一。

    image.png

  • 语义类相当于更自由的格式编辑——我们可以先编写文章内容,最后再来排版

    image.png

保持可读性

原子类的可读性问题

需要承认的一点是,使用原子类编写的HTML结构,确实没有像传统方法命名整洁,元素写在类里的语义被丢掉了。许多对于原子类的讨论也指出这一点,甚至有指责原子类破坏了可读性。

以前文猫猫组件为例,可以对比两种方法的差异:

image.png

<!--按元素语义命名的传统方法-->
<div class="cat">
   <img class="figure" width="60" src="https://images.unsplash.com/photo-1529778873920-4da4926a72c2?&auto=format&fit=crop&w=200&h=200"></img>
   <div class="name">可爱的猫猫</div>
   <div class="time">一周以前</div>
</div>
<!--按元素视觉表现命名的原子类方法-->
<div class="inline BFC">
  <img class="float-start bdrs-md mr-md" width="60" src="https://images.unsplash.com/photo-1529778873920-4da4926a72c2?&auto=format&fit=crop&w=200&h=200"></img>
  <div class="text-primary">可爱的猫猫</div>
  <div class="text-secondary">一周以前</div>
</div>

替代方案

讨论这个问题首先必须指出的是在类中添加语义这种做法并不是 HTML 标准推荐的。事实上几乎没有屏幕阅读器、搜索引擎会按照类名来判断元素语义。而更标准、更推荐的做法是使用 HTML 语义元素ARIA 属性

继续之前的例子,我们完全可以使用语义元素来为组件增加可读性和语义:

<figure class="inline BFC">
  <img class="float-start bdrs-md mr-md" width="60" src="https://images.unsplash.com/photo-1529778873920-4da4926a72c2?&auto=format&fit=crop&w=200&h=200"></img>
  <figcaption class="text-primary">可爱的猫猫</figcaption>
  <mark data-label="入住时间" class="text-secondary">一周以前</mark>
</figure>

如果考虑兼容性,也可以采用 ARIA 属性:

<div role="figure" aria-labelledby="caption" class="inline BFC">
  <img class="float-start bdrs-md mr-md" width="60" src="https://images.unsplash.com/photo-1529778873920-4da4926a72c2?&auto=format&fit=crop&w=200&h=200"></img>
  <div id="caption" class="text-primary">可爱的猫猫</div>
  <div role="mark" aria-label="入住时间" class="text-secondary">一周以前</div>
</div>

关于 ARIA,我之前也写过一篇 ARIA 介绍和实践的文章,不太了解的同学可以读读

语义类名不是银弹

语义类名是在 jQuery 时代前端开发需要用类名来划分组件、执行 dom 操作的技术背景下总结出来的一套规范。当时人们编写的组件是这样的:

<button class="ui-button ui-widget ui-corner-all">
    Button with icon on the right <span class="ui-icon ui-icon-gear"></span>
</button>

<script>
$(
  function(){
    $("button.ui-button").on("click", function(event){
      console.log(event)
    })
  }
)
</script>

用这种方法编写组件,选择一个合适的类名是刚需。而现在组件的划分已经是 React、Vue 等等前端框架的职责了,甚至还出现了很多 css-in-js 的自动生成类名的方案。语义类名确实很漂亮,但在目前的技术下已经不是必要的。

同时,给每个元素找到合适的语义命名是一种思想负担。很多时候找不到合适的元素名称,只能取名叫”container”、”wrap” 之类的没有意义的类名。

原子类库实践 —— DIY

前面分析了原子类的特点,接下来我们就看看如何自建搭建一套原子类库。我们实际也做了一套,负责一套业务组件库,以及相关的几个业务系统的支撑:

接下来讲几个在 DIY 原子类库的思考

高效生成类

原子类通常会包含很多按照一定的规则生成的的类,而 CSS 是没有很好的方法去做这件事。我们在实践中采用了预处理器 SASS 来做这件事。

SASS 语法可以参考SASS 官网

举个例子,要做一套 flex 布局的原子类,justify-start 代表 justify-content: start 等等,使用 sass 可以会非常高效:

@mixin justify($name, $value) {
  .justify-#{$name} {
    display: flex;
    justify-content: $value;
  }
}

@mixin align($name, $value) {
  .align-#{$name} {
    display: flex;
    align-items: $value;
  }
}

$flex-align: (
  'start': flex-start,
  'end': flex-end,
  'between': space-between,
  'around': space-around,
  'center': center,
  'stretch': stretch,
  'baseline': baseline,
);

// 循环出所有 14 种 flex 布局
@each $name, $val in $flex-align {
  @include justify($name, $val);
  @include align($name, $val);
}

编译结果为:

.justify-start {
	display: flex;
	justify-content: start;
}

.align-start {
	display: flex;
	align-items: start;
}

.justify-end {
	display: flex;
	justify-content: end;
}

.align-end {
	display: flex;
	align-items: end;
}

...

再举个例子,我们还提供了基于 grid 的多列布局,使用 sass 可以这样写:

@mixin divide-cols($repeat) {
  grid-template-columns: repeat(#{$repeat}, 1fr);
}

@for $i from 1 through 9 {
  .grid-cols-#{$i} {
    display: grid;
    @include divide-cols($i)
  }
}

编译结果为:

.grid-cols-1 {
		display: grid;
    grid-template-columns: repeat(1, 1fr);
}

.grid-cols-2 {
		display: grid;
    grid-template-columns: repeat(2, 1fr);
}

.grid-cols-3 {
		display: grid;
    grid-template-columns: repeat(3, 1fr);
}

...

与设计师共建

之前也讨论过原子类和设计的关系——原子类是设计规范的落地。在项目初期,可能设计本身也没有很多的规范,这就需要开发和设计合作。建议几个可以在项目前期首先确定的方向,比如:

  • 主题色盘的规范
  • 标题级别的规范
  • 间距的规范
  • 卡片的规范
  • 成功、失败、警告、超链接、禁用等功能文字的规范
  • 分割线的规范
  • 投影级别的规范

实际可以和设计师一起参考调研业界的一些成熟方案。

原子类库实践 —— 开源库

目前业界有很多基于原子类思想的通用开源库方案。比如:

这些开源库都是开箱即用的,安装就能为项目添加一整套原子类库,同时也提供了一些配置的方法。具体用法各家都不相同,可以查看文档,不一一介绍了。

DIY vs 开源库

DIY 的好处

  • 更好地控制原子类库的规模

    开源的原子类库因为面向通用场景,往往是大而全。 tailwindCSS 全量大小有 3.7Mb,使用的时候需要搭配 purgecss 等 css shaking 工具。DIY 原子类库可以根据项目实际需求做实现。

  • 通用的开源原子类库粒度都比较小,而 DIY 的原子类库可以根据实际情况改变。

    以段落为例,如果设计规范中一级标题是 1.25rem bold 深灰色,使用 tailwindcss 不配置的情况下会这样写:

      <div class="text-xl font-bold text-gray-900">我是一级标题</div>
    

    DIY 的情况下完全可以编写更大粒度、语义更好的类:

      <div class="ui-h1">我是一级标题</div>
    
  • 最终产物是通用的 css,而不是像开源库的 js 配置文件,方便迁移

使用开源库的好处

  • 通过配置生成原子类库,实现更快
  • 很多业界共识的良好的命名实践,学习成本低
  • 文档完善,方便查询
  • 生态更完善,插件、编辑器拓展很多
  • 提供了一些私有的 postcss 语法糖

总结

原子类库是一种搭建 CSS 架构的方法,偏好更细粒度、单一职责、按视觉命名的类。使用原子类方法可以让样式更可复用、更符合设计规范,但同时也要面对可读性降低的问题。