不合时宜的 CSS:Tailwind CSS 引发的思考

最近时常在社交网络上看到 Tailwind CSS 被提起,自己前段时间出于兴趣也拿它来做了个项目。与其他很多技术不同,Tailwind CSS 自出现起就在网上有不小的争议。与网上很多文章不同,在这篇文章中,我并不想讨论它的技术细节,而是对目前原子 CSS 这一做法回潮的现象,以及前端样式写法的发展,表达一些个人的想法,也希望能为读者带来一些启发。

“前端三剑客”

即使在非专业人士中,前端的三大技术栈 —— HTML、CSS、JavaScript 也有不小的知名度。究其原因,一方面是因为它们是组成当今各种丰富多彩页面的基础,另一方面也是因为我们可以用很简单的语言解释其各自的用途,让人们自以为理解了。经典的解释是:HTML 决定了网页的骨架、内容;CSS 决定了网页的样式;而 JavaScript 决定了网页的行为。

看上去这三者各司其职,配合得十分美好。但它们真的是完全正交的吗?现实并不总是这么理想的。HTML 总是想插一脚样式的事情,且不说曾经的纯样式标签,例如 <b> 等,就算是现在的浏览器中,<body><h1>~<h6> 等基本的标签也会带默认的样式(这也是 CSS ResetNormalize.css 等存在的原因)。还有些 HTML 标签还自带了交互的能力,美其名曰,可以提高用户在禁用 JavaScript 时的使用体验。

而 CSS 也逐渐扩展着自己的能力边界。最早 CSS 只是为了附加一些简单的样式,但随着人们对样式和交互的需求不断复杂,旧有的能力已远远无法满足需要,因此新的能力不断被加入 CSS 标准中,也不断有各种 CSS 框架如雨后春笋般冒了出来。一开始它们只是增强原始 CSS 的语法,到后来逐步发展成了一套几乎图灵完备的闭环。经常能在网上看到一些纯 CSS 实现的酷炫交互效果,令人为之惊叹。

JavaScript 嘛就更不用说了,随着 DOM API 不断完善,没有什么 HTML 和 CSS 能做的事情是 JavaScript 不能做的。

但在这三者的能力都不断增强的时候,它们之间的互操作性却成了瓶颈所在。例如当你需要根据某些复杂的条件为不同的 HTML 元素附上不同的样式时,就需要用 JavaScript 写上胶水代码。并不是不能做,只是这一方面让 JavaScript 僭越了三者的分工,另一方面你的思维需要在三者之间切换,了解它们各自在对方的世界中暴露的 API,这一定程度上加重了你的心智负担。而有趣的是,这一点正好成为了 CSS 能力不足,需要进一步增强的理由,而增强后的 CSS 迫使你学习它引入的新语法,却只是做了与 JavaScript 能做的同样的事,进一步加重了你的心智负担。

既然这三者是你中有我,我中有你的关系,不知大家有没有想过为什么非得把这三者区分得这么死,而不是统一到一套体系中来呢?或许我们需要从其他不同的角度寻找答案。

Web 与其他 GUI 技术的发展史

虽说一切能用 JavaScript 的实现的东西最终都会被 JavaScript 实现,以至于前端开发者无所不能。但也不妨让我们跳出传统网页端这一亩三分地,以大前端的视角看一看其他 GUI 技术,它们分别代表了在它们出现的那个年代,开发者对于如何设计 GUI 开发方式的思考与设想。

Java Swing

Swing 是 Java 原生支持的跨平台 GUI 开发框架。由于 Java 本身是一个非常严格的面向对象语言,Swing 自然也继承了其面向对象的风格。具体来说,Swing 中的每一个组件都是一个对象,而组件的内容、样式等,就是这个对象的属性。对象之间通过组合的方式,共同构成了复杂的应用界面。

一个 Swing 程序的例子如下所示:

// 创建一个窗口
JFrame frame = new JFrame("HelloWorld");
// 关闭窗口时就退出程序
frame.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
// 创建一个标签
JLabel label = new JLabel("Hello World");
// 设置标签的一些样式和属性
label.setIcon(new ImageIcon("path/to/your/image.png"));
label.setLocation(200, 100);
label.setIconTextGap(0); 
label.setBorder(null);
label.setOpaque(false);
// 把标签添加到窗口中
frame.getContentPane().add(label);
// 把窗口设成能包裹住内容的合适大小
frame.pack();
// 使窗口可见
frame.setVisible(true);

这是比较简单的场景,如果需要复杂的自定义布局,还可以拿底层 awt 的 Graphic 对象自己去绘制。虽然从代码示例上看还是比较清晰的,但在实际项目中,容易与逻辑代码混杂在一起,很难直观看出布局与样式。

Android

虽然 Android 有自己的一套 API,不过其设计与 Swing 类似,都是基于面向对象的思路。Android 的一大特点是引入了一套 XML 的布局方案,不知是不是从 HTML 中得到的启发。但 XML 本质上也只是对下层 API 的一个封装,用 XML 描述的布局使用 Java 代码也完全可以实现。在极端注重性能的场景下,甚至需要使用异步解析、或完全手写布局代码的方式来减小解析 XML 文本的开销。在布局或内容变化时,也需要通过命令式的语法,人工追踪变化的范围和过程,获取到控件对象进行操作。

一个 Android 布局的样例如下所示:

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:id="@+id/title_bar"
    android:layout_width="match_parent"
    android:layout_height="48dp"
    android:layout_gravity="center"
    android:background="@color/header_background"
    android:gravity="center" >
    <ImageView
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:paddingBottom="4dp"
        android:paddingLeft="4dp"
        android:paddingTop="4dp"
        android:src="@drawable/header_icon2" />
</LinearLayout>

与之相对应的 Java 写法如下(简单起见省略了部分代码):

LinearLayout layout = new LinearLayout(context);
layout.setId(R.id.title_bar);
layout.setBackgroundColor(...);
layout.setGravity(Gravity.CENTER);
ImageView image = new ImageView(context);
image.setPadding(4, 0, 4, 4);
image.setDrawable(...);
layout.addChild(image); 

XML 的出现解决了纯代码写法的一些问题。在面对大部分情况下不太改变的静态布局时,使用 XML 编写可以显著提高可读性,更为直观,同时也便于结合 IDE 实现布局的实时预览与拖动调整。不过它的本质并没有改变,在实际场景中仍然需要大量在代码中获取对象进行操作,在布局变化时也需要写代码对布局对象进行手动管理。

React

虽然 React 也是 Web 技术中的一种,但我觉得值得将其单独拿出来一说。以 React 为代表的新一代 Web 技术创造性地使用函数式编程思想,实现了声明式 UI 的写法。其核心思想是 UI = f(State)

在声明式 UI 的写法中,我们只需关注如何根据最新的状态渲染出 UI,而无需根据每次状态的变化去手动改变 UI 组件的状态,从而大大降低了代码编写的复杂度。而它的底层则使用一套通用的 Diff 算法,使得 UI 重渲染的过程尽可能高效。

React 的另一个特点就是 JSX 语法。JSX 看上去像 HTML,但实际上是 JavaScript 语法的扩充。之所以设计成 HTML 的样式,是为了让熟悉 HTML 的开发人员能够更快地上手。如果你愿意,只使用纯 JavaScript 的 API 来写也是可以的。

使用 JSX 而非 HTML 的好处是,由于它本质上是 JavaScript 语法糖,因此在编写布局时便可以与其他逻辑语句无缝衔接,这使得开发时的思路非常顺滑。例如,在 React 中,我们可以这样将一个数组渲染为列表元素:

function List({ data }) {
  return (
    <ol>
      {data.map(item => <li key={item.key}>{item.value}</li>)}
    </ol>
  )
}

面对 HTML、CSS、JavaScript 三分离导致的问题,React 给出了自己的答案。它将 HTML 与 JavaScript 相统一,彻底解决了两者交互不便的局面。而对于 CSS,React 也做了一层简单的封装,使得人们可以用一个 JavaScript 对象来表达一组样式。这层封装也使得 React 代码可以无障碍地迁移到其他没有 CSS 的平台,例如桌面和移动端上。

Flutter

Flutter 的主要思想与如今流行的声明式 UI 做法相同,但它作为一门全新的技术,没有那么多历史包袱,且整个框架及其实现语言 Dart 都控制在 Google 手中,因此可以从语法、工具链等层面直接支持想要的特性。这也使得它们在运行时更加高效,而语法看上去也更为激进:

class MyApp extends StatelessWidget {
  
  Widget build(BuildContext context) {
    return new Container(
      decoration: new BoxDecoration(color: Colors.white),
      child: new Center(
        child: new Text('Hello World',
            textDirection: TextDirection.ltr,
            style: new TextStyle(fontSize: 40.0, color: Colors.black87)),
      ),
    );
  }
}

可以看到,使用 Flutter 开发的应用程序,无论布局、样式还是逻辑,全部都由 Dart 实现,使得它们之间的互操作毫无障碍。对于一些布局和样式,Flutter 直接为它们提供了单独的组件,鼓励大家多多使用组合的方式,而不必过多纠结于复杂的布局规则与嵌套深度。在构建布局时也只要像构建普通对象那样直接 new 就可以了,并不需要学习一门其他语言或 DSL。也不用担心由此引发的嵌套过深以及临时对象过多等问题,底层运行时自有优化的方式。

回到 Web 端

我们从时间维度列举了几个我自己略有了解的具有代表性的 GUI 技术,从中可以一窥技术发展的趋势。一方面是在编写界面布局时,逐渐采用声明式的方式来写,这确实为我们减轻了不少心智负担。而另一方面是,大家几乎都不认为布局、样式和逻辑三者应当被严格分离,而是应该统一在一起,由同一个体系进行描述。

那么为什么偏偏是 Web 端成为了异类,分裂出了 HTML、CSS、JavaScript 这三种独立的技术呢?我想这可以从历史的角度给出解答。

HTML 的出现最早,早于上面的所有其他 GUI 技术。HTML 的全程是 HyperText Markup Language ,可以看出它的初衷只是想更好地描述一个文档,因此需要对普通的纯文本文档进行一些增强。

CSS 出现在 HTML 之后,本意只是对 HTML 的展示效果做一些美化,且美化的结果可以因人而异、因浏览器而异(这也为不同浏览器 CSS 一致性问题埋下了伏笔)。另外 CSS 有层次之分,例如可以让用户指定的样式覆盖浏览器的默认样式,这也是为什么 CSS 叫做层叠 样式表。HTML 不应该依赖于 CSS,即使没有 CSS 也要保证网页的内容能够正常显示。出于这样的设计目的,将 HTML 与 CSS 分开是很合理的。

JavaScript 的出现则更晚了,且一开始也只是为了做一些简单的表单与交互效果,在发明它时大家完全没有想到会发展到如今这样一个庞大的生态。在我印象中,若干年之前大家还会提到的一个最佳实践是用户会出于安全的考虑禁用 JavaScript,而被禁用之后的网页也应尽量保证内容的正常展示,因此 JavaScript 能不用就不用。不过在 SPA 开始流行之后就渐渐没有这种声音了。

Web 技术和上面提到的其他技术最大的区别在于,Web 起初仅仅是想被用作文档的展示,而其他技术在各自的平台上都是为了做一个完整的应用。一个应用的功能和交互逻辑远比文档展示要复杂,这就需要在代码层面上,交互层与表现层需要尽可能方便地互操作。但 Web 发展到现在已经远远偏离了它被发明的初衷。Web 已经事实上应用化了。 因此我们需要从应用的角度去重新审视 Web。这也是为什么我们会觉得 Web 的三分离架构已经开始拖我们的后腿。

“分层架构”

分层是软件开发领域十分常见的做法,随着代码库规模不断扩大,分层可以强迫开发者理清楚代码中各个模块的依赖关系,避免代码变成一团浆糊。但具体到如何分层,也是有一番讲究的。

传统上我们提到分层架构时,都是指横向分层,例如在前后端还未分离的时代大家言必提及的 MVC 架构。在真实的项目中,我们还可以进一步将其分为 entity、dao、biz、action 等细分层级。而在纯粹的前端领域,HTML、CSS 与 JavaScript 也正好构成了结构层、样式层、交互层这三个相互独立的层级,这也使得它非常清晰明了,易于被人们所接受。

HTML、CSS 与 JavaScript

但随着时代渐渐发展,开发者们发现单纯这样横向分层,对于愈发复杂的业务场景及它们组成的大而全系统,越来越力不从心了。很难再用一套普适的方案去解决各种各样错综复杂的业务问题。因此,与其关注如何从技术功能上将它们分层,不如更加关心如何从业务模块的角度将它们分离开来。

这方面的典型就是后端的 SOA 架构。我们将业务逻辑抽象为一个个相对独立的服务,服务与服务之间通过暴露接口的方式调用。而在服务内部就可以自行选择实现的方式,只要保持接口不变,无论内部代码怎么划分、怎么重构,对外部调用方都是黑盒。这也体现了高内聚低耦合的原则。更进一步地,我们发展出了微服务架构,以更好适应云计算和分布式的发展。在微服务架构中,服务与服务之间通过网络 API 通信,甚至可以让不同的服务采用不同的技术栈来实现。

业界很喜欢创造很多新的名词来指代每一次微小的技术革新。抛开这些名词中细枝末节的定义区别,将它们简单化来说,其实也可以概括成分层架构。只是不同于传统的横向分层,它做的是纵向分层,将整个应用以不同的业务模块分割,而每个模块内部,视业务的复杂程度可以包含横向的一层或多层,或 whatever,随开发者喜欢。

两种不同的分层方式

在前端领域也发生着这样的改变。随着前端工程化的逐渐成熟,开发者不再需要操作原始的 HTML、CSS 与 JavaScript,而是将它们视为最终的编译产物。在此之上,前端组件化悄然兴起。我们不再将 HTML、CSS 与 JavaScript 视为三个泾渭分明的层级,而是将整个项目划分为各个大大小小的业务组件,把三者打散到每个组件之中。React All-in-Js 自不必说,即使是沿用了三分离的 Vue 也需要使用单文件组件,把各自的碎片封装在同一个文件里。紧跟后端发展的步伐,对标微服务,就成了现在方兴未艾的微前端架构。说不定未来就会开始出现“微全栈架构”呢,让我们拭目以待吧。

其实近年来代码组织结构的变化和开发者观念的转变也是容易理解的。这并不是单纯技术层面的问题,更是人性的问题。代码的分层,按照技术层面横向来分,还是按照业务模块和组件纵向来分,本身只是观察同一个问题的不同角度,并没有对错之分。但是随着项目规模越来越大,业务越来越复杂,没有人能够完全掌握整个项目的所有细节,这就必须考虑如何多人协作。而与人打交道可比与代码打交道麻烦多了。

如果多人协作时,有人专门负责页面内容,有人专门负责交互,有人专门负责样式,那么一方面每个人都要了解所有需求的业务逻辑,另一方面他们必然会花很多时间在各自的耦合部分上对接撕逼,这无疑会极大降低开发效率。因此正常情况下我们都是让每个人单独负责一整个独立的业务需求。而项目人员组织结构的变化,最终也将反映在代码结构之上。

此外,每个人的个性不同,写代码的思路也不同。虽然我们可以用一些代码规范、格式化工具等,使大家写的代码看上去相似,并避免一些公认为不好的写法,但解决问题的思路仍然是不可能做到完全一致的。这也是为什么读别人的代码是最痛苦的。因此我们尽可能地追求代码的高内聚低耦合,这样只要保证对外接口正常,模块内部自己想怎么干就可以怎么干。

前端组件化与 class 的没落

在前端组件化之后,我们就要重新思考曾经的做法放到现在是否仍然成立。

这里就不得不提到 HTML 的 class 属性。现在大家提到 class 属性的用途,基本上只是用来配合 CSS 给元素附加上样式。但是 class 设计之初可不是只有这样的用途。这一点从名字上也看的出来,class 意为“类名”,本意只是给元素增加一个(可以重复的)名字。

在 HTML 诞生之初,支持的标签名还很少,而且整个 HTML 都是一个单文件,内容一多可读性就会变得比较差。这时我们给一些元素增加人类可读的名字,可以有效提升代码的观感。也就是说 class 并不是单纯服务于 CSS 的,这也是从传统来说大家都推荐给 class 起一个有意义名字的原因之一。

HTML5 为 HTML 定义了更多的语义化标签,用来描述很多网站中共通的部分。它也起到了标准化的作用,更有助于 SEO,但也在一定程度上替代和削弱了 class 的作用。

而在如今组件化的时期,开发者拥有了将各种原始标签封装成业务组件的能力,而在上层界面拼装时,只需将业务组件进行组合。这等同于我们拥有了创造新标签的能力,可以用标签的名字来表达这个组件的意义。这使得 class 的表意作用进一步被削弱。而配合声明式 UI 的写法,我们也甚少再去利用 class 操作原生 DOM 对象了。

终于,class 只剩下了用于附加 CSS 样式的功能。

但是,如果用 class 只是为了附加样式,是不是给人一种脱了裤子放屁的感觉呢?程序员都知道命名是最困难的事情之一。如果只是为了附加样式用那么一次,却需要你想这么多名字,甚至催生出了 BEM 这种繁琐的解决方案,这一切值得吗?另外,将 HTML 与 CSS 的代码分离开也导致开发和阅读代码时你的注意力需要反复横跳,窗口反复翻页,无形中也破坏了开发思路的连贯性。

那么,该使用 inline style 吗?相信受过专业训练的大家一下子就会对这个词产生本能的反感,并列举出种种不该这么做的理由。嘛,inline style 的代码给人的观感确实丑、繁琐,毋庸置疑。不过其他理由在今天是否还成立呢?这是一个值得商榷的问题。

反对 inline style 的一个理由是 inline style 破坏了 CSS 的层次关系,因为行内样式的优先级最高,这使得项目中的其他地方失去了能够覆盖样式的能力,这违背了层叠 样式表设计的本意。但覆盖样式的能力真的值得使用吗?我想大概很少有人能在不借助参考资料的情况下短时间回忆起各种 CSS 选择器的优先级规则吧。如果想把 CSS 的覆盖样式能力作为 feature 大规模使用在项目中,长此以往只会使逻辑更加混乱和难以维护,最终只能 !important 完事。

可能会有人希望将这一特性用于组件样式自定义的接口。例如,有一个通常情况下是蓝色的按钮,但在特定的场景下,可以通过覆盖该按钮的样式将颜色改为红色。但请注意,这不是一个单纯 CSS 的问题,与其简单地使用 CSS 覆盖,我们更应该做的是去思考在什么业务场景下需要改变按钮的颜色,并给这个按钮组件提供一个属性来区分不同的场景。另外,随着组件样式越来越丰富,往往需要很多 CSS 样式共同呈现出完整的视觉效果,这种时候指望简单覆盖一个 CSS 就可以实现改变颜色的效果是不现实的。

Element UI 中的 button

反对 inline style 的另一个理由(也许是最重要的理由)就是使用 class 可以对样式进行复用。在前组件化时代这一点确实非常重要,因为它是复用样式的唯一手段。但组件化出现之后,我们不禁面临一个尴尬的问题:是使用 class 复用样式,还是使用组件封装复用样式呢?不难意识到,当然是通过组件来复用了,毕竟采用组件的形式,什么都可以复用,而 class 只能用于复用样式。组件是完全的上位替代。

另外,组件化的出现也一定程度导致使用 class 复用样式变得困难。组件化出现之前,无论你的 CSS 再复杂,终究全局共享同一张样式表,只要 class 名字相同,就可以复用样式。但在组件化出现之后,为了避免不同组件间 class 名字的冲突,我们往往会使用 CSS modulesScoped CSS 等方式规避,这反而对不同组件间复用样式造成了阻碍。

再考虑一个很现实的因素。在实际项目中,不同的需求总是会有定制化的东西,你辛辛苦苦设计了复用体系,一旦产品改个需求,就一朝回到解放前了。这导致我们实际能够复用的样式总归是很局限的。例如我们设计了这样一个可复用的样式,一切看上去都很美好:

<div class='container'>container1</div>
<div class='container'>container2</div>
<div class='container'>container3</div>
<style>
.container {
  display: flex;
  margin: 10px 0;
  padding: 10px;
}
</style>

但随着产品的不断迭代和调整,慢慢地它变成了这样:

<div class='container1'>container1</div>
<div class='container2'>container2</div>
<div class='container3'>container3</div>
<style>
.container1 {
  display: flex;
  margin: 10px 0;
  padding: 10px;
}

.container2 {
  display: flex;
  margin: 10px 0;
  padding: 0;
}

.container3 {
  display: flex;
  margin: 10px 20px 0 5px;
  padding: 0 10px;
}
</style>

面对这样不能复用,但又不是完全不能复用的代码,你十分难受。痛定思痛之后,你意识到如此粗粒度地复用 CSS 是没有前途的。于是你把样式代码按各自的功能进行更加细粒度地划分,直至可以复用的最小单元,改写成了这种模样:

<div class='container1 flex'>container1</div>
<div class='container2 flex'>container2</div>
<div class='container3 flex'>container3</div>
<style>
.container1 {
  margin: 10px 0;
  padding: 10px;
}

.container2 {
  margin: 10px 0;
  padding: 0;
}

.container3 {
  margin: 10px 20px 0 5px;
  padding: 0 10px;
}

.flex {
  display: flex;
}
</style>

那么恭喜你,此时的你已经在发明 Tailwind CSS 了。

Tailwind CSS 为我们做了什么

CSS 在 Web 发展的初期很好地履行了自己用于样式美化的职责。但随着前端网页逐渐从简单的文档流变得复杂化、应用化,CSS 与 HTML、JavaScript 三分离的特点事实上阻碍了开发效率的提升。在前端工程化和组件化兴起之后,人们对前端软件工程的观念也有所转变和发展。原先我们在学习 CSS 时耳熟能详的各类原则和最佳实践,已经不那么正确且有用了。大人,时代变了。

但 Web 长久以来的发展,已经造就了一个无比庞大的互联网生态圈。纵观技术的发展史,一个成功的、活的久远的技术,其成功的秘诀往往并不是技术本身。比技术方案更重要的是先发优势,以及能不能形成足够庞大活跃的生态。因此,即使 Web 技术有着诸多现在看来不甚合理的地方,但这些历史包袱也是无法被轻易抛弃的。我们能做的,只是将不好的一面尽可能封装,使之变得更为好用罢了。

在这一点上 Tailwind CSS 就做的恰到好处。它不像 Sass/Less,试图发明一个“更好的” CSS,也不像 Flutter for Web,试图让大家忘记 CSS 和其他的一切,重塑整个 Web 生态,也不像 CSS in JS,虽然拥抱了组件化,但依然要让繁琐的 CSS make your hands dirty。它用原子类的封装,为我们温柔地隔离了 CSS 的世界。它敏锐地发现了 class 属性的逐渐变味与没落,并利用它,使得开发者完全不需学习新的语法就能轻易看懂与上手。

Tailwind CSS 的出现也是历史发展的必然结果。正如我在上一节中举的例子,其实在平时开发过程中我们也已经有意无意间使用了原子类来解决 CSS 细粒度复用的问题。而 Tailwind CSS 将这一切规范化、工程化了。

当然 Tailwind CSS 还提供了一些其他的能力,例如对项目中各类边距、颜色、单位等的标准化。这也是相当值得称道的点,可以为我们的页面美观程度保持一定的下限。不过这些都没那么重要了,在合适的时间出现解决合适的问题,它必定会是当今这一时代最值得使用的 CSS 框架。

TAGS:  CSSTailwind CSS
正在加载,请稍候……