跟着主流ui库学习css工程化技术选型(优化版)

261 阅读16分钟

1. css 工程化总述:

实现 css 工程化的方案很多,总的来说,css 工程化的核心目标,就是解决大型前端项目开发过程中的一系列痛点。总的来说是以下几个方面:

  1. 解决 css 类名冲突的问题。
  2. css 模块化的需求。
  3. 更加友好,更加高效的 css 编码需求。
  4. 更规范的团队编码协作,更小的 css 打包体积,更快速的传输效率。

而要实现以上这些需求,社区中已经有了很多较为成熟的解决方案,无外乎也是以下几个方面:

1. 为了解决css类名冲突的问题,最典型的就是两种方案:

vue技术栈:css scoped,vue 在编译每一个单文件组件的时候,都会为每一个组件的 css 设置作用域属性,组件所有的样式都被包裹在这个属性中。 image.png react技术栈:css module,当在js文件中以模块化的方式 import 样式的时候,会为每一个class生成一个唯一的 hash 值。

image.png

2. 为了更加优雅实现css模块化需求,最典型的就是以下两种思路:

  1. 增强css语言本身的模块化语法能力,最典型的就是老牌的预处理编译器:scss、less,它们本身的能力,基本上已经满足了大多数业务项目的css模块化需求。

image.png

当然,针对组件库这种类型的更加复杂的项目开发,如果团队成员能力足够强大,也可以考虑直接利用 postcss 强大的插件化能力,更大限度的增强 css 模块化能力。

  1. css-in-js 方案:这个实在 raect 生态被广泛使用的 css 模块化方案,它凭借 js 语言灵活且强大的模块化,完美的解决了 css 模块化的问题:
import { darken, lighten } from '../../core/utils'

直接利用 js 模块化定义css变量和公共函数,并且在任意模块进行导入。

3. 为了提高编码效率,社区中最典型的还是以下思路:

  1. 针对 css 语言本身进行更加优雅的语法扩展,最典型的就是 scss、less 这些 css 预处理器以及postcss 后处理器。比如定义各类灵活的混入指令以及css函数等。
  2. css-in-js,然后配合上 ts,在js语言灵活的基础上,提供强大的类型声明。
  3. 原子化css,最有名的就是 tailwind css 框架,完全让css开发直接起飞,而且因为框架本身优雅的设计,让它的自定义能力以及扩展性变得极其优雅。是目前业务开发几乎是最好的选择了。

4. 为了更最好的优化css打包体积,规范css编码,和 js 一样,无非也是三个方案:

  1. css treeshaking
  2. css 压缩
  3. style-lint

它们都依赖一个关键的技术:postcss。有了 postcss,我们可以基于抽象语法树对css代码任意的进行加工优化。

以上我们回顾完了整个css工程化核心的一些技术方案,下面我们就以组件库样式设计这一场,来看一下ui库在css技术选型的时候,应该进行哪些考量。

2. 组件库 css 技术选型方案

要设计 ui 库的 css 样式体系,首先要做的一个工作就是定义好 css 各类梯度变量。包括:颜色、字体粗细、边框粗细、边距大小... 。这个工作其实是很不简单的,依靠纯粹的前端工程师是很难完成,至少得和 ui 设计团队深度合作。关于这些内容怎样比较好的做到工业化,大家可以参考这篇文章:前端主题切换工程化实践方案前端主题切换是一个常见的需求,市面上的解决方案也很多,今天我们来一起透彻的探讨前端主题切换的解 - 掘金。我们在这里就假设现在已经有了梯度变量的设计搞,接下来在社区里面,常规则后续步骤就是利用变量,将这些梯度变量定义出来,而社区中最常见的方式,是以下几种:

1. 梯度变量的定义:

1. 利用 css 变量定义

这种方案很常见,也很简单:

--xxui-color-dark-0: #c9c9c9;
--xxui-color-dark-1: #b8b8b8;
--xxui-color-dark-2: #828282;
--xxui-color-dark-3: #696969;
--xxui-color-dark-4: #424242;
--xxui-color-dark-5: #3b3b3b;
--xxui-color-dark-6: #2e2e2e;
--xxui-color-dark-7: #242424;
--xxui-color-dark-8: #1f1f1f;
--xxui-color-dark-9: #141414;

以上我们就使用 css 变量定义除了黑色的梯度色值。

2. 利用 js 模块化定义变量

如果我们使用的是 css-in-js 方案,我们完全可以使用js模块来定义梯度变量:

export const xxxUiColorDark1 = "#828282";
export const xxxUiColorDark2 = "#b8b8b8";

3. 我们也可以利用 scss、less 这些预处理器的变量来定义:

$red1: rgb(255, 0, 0);
$red2: rgb(255, 0, 1);

这三种方案在最终的效果上都可以实现梯度变量的定义,而且都比较方便。但是有一个很重要的区别需要大家稍微注意一下:

js 变量因为可以直接利用 esm 模块化导出和导入,所以在最终打包的时候,可以直接享受 js 摇树优化,直接做到很好的打包结果的控制。

scss以及less 这些变量其实是编译时才会存在的,最终编译的结果会直接被原地替换掉,所以最终的运行时,根本就不会有变量存在。

css变量目前已经被绝大多数浏览器直接支持,所以一般在打包的时候不会直接处理掉,而是直接会被浏览器直接请求和加载。这就导致,如果我们对css的体积特别看重的时候,如果css梯度变量存在比较多没有使用的时候,会导致额外的打包体积冗余,会对最终运行时有一定的影响。那么这个时候就可以考虑利用 postcss,对 css 变量进行摇树优化。

一个扩展思路:

如果大家看过 前端主题切换工程化实践方案前端主题切换是一个常见的需求,市面上的解决方案也很多,今天我们来一起透彻的探讨前端主题切换的解 - 掘金 这篇文章的话,就会知道,我在文章最后设计了一条 css 主题切换工程化流水线方案。如果我们有了梯度变量管理平台,那么梯度变量是会交给ui设计师直接去进行定义的。每一个团队ui设计师利用平台进行各类主题色值以及变量的配置之后,最终就可以在数据库中存储下本团队的一份css梯度变量表,类似于下面这样:

red: {
  color1: "xxx1",
  color2: "xxx2"
}

然后ui设计师在进行主题色值定义的时候,就可以直接使用这个配置表中定义的梯度变量进行设置了。过多内容我在这里就不详细展开了。

2. 定义好梯度变量之后,我们就可以开始设置主题切换的方案了:

社区中组件库css主题切换方案特别多,我们在这里举几个最有代表性的方案供大家参考:

1. element-plus 为代表的常规方案:

这类方案最常见,也最好理解和实施,主要就是以下 3 步

1. 利用css变量在宏观上定义各个主题对应的颜色:

:root {
  --el-border-color: xxx1;
}


:root[dark] {
  --el-border-color: xxx2;
}

2. 每一个主题都对应一个 css 文件:

theme-color.css


:root {
  --el-border-color: xxx1;
}

theme-color-dark.css


:root[dark] {
  --el-border-color: xxx2;
}

3. 在组件开发的时候直接使用统一的css变量来定义组件的颜色:

button.vue:

.el-button {
  border-color: var(--el-border-color);
}
4. 业务项目在使用的时候可以按需导入对应主题颜色的配置:

如果业务项目需要支持暗黑模式,那么可以在项目的根目录下直接导入暗黑模式对应的颜色配置,并且切换根元素的属性就可以了:

在业务项目根目录导入暗黑模式颜色配置:

import "element-plus/style/theme-color-dark";

在合适的时机切换根元素上的开关就可以了。并且因为是挂载在根元素上的css变量的缘故,业务项目自定义组件中也可以直接使用这些变量来统一整个项目的风格。

而且,业务项目需要扩展其他更多的主题也是非常轻松的,只需要编写自定的主题 css 配置,覆盖掉组件默认的配置就可以了,这也是 css 变量最大的优势之一。

5. css 变量为组件开发带来的便利:

举一个最常用的例子,el-icon 组件,提供的设置后代具体的图标组件的尺寸、颜色,提供了 size 以及 color 这些 props,用户设置之后,在组件内部就会直接将这些 prop 动态设置到 el-icon 根元素上的css变量中。后续所有具体的 icon 组件就可以很方便的通过集成根元素上的 css 变量来方便的控制尺寸的统一以及切换了。特就是在:

  1. 后代尺寸、颜色的统一设置
  2. 后代尺寸、颜色的批量切换

这些需求上,css变量控制是一个非常好的解决方案。

mantine-ui 主题切换方案:

这个组件库的css总体上是使用的 css module + 动态设置类名 的方案来进行管理的。这部分内容后文会有详细拆解。我们先来看一下它组件主题切换的方案: 它和element-plus很本质的不同是,它所有组件的主题控制不是通过全局变量来去进行控制的,而是和el-icon 比较类似,是在每一个组件的根元素上注入当前组件需要的css变量以及主题配置来去进行控制的,类似于下面这样:

.input-wrapper {
  --input-bg: xxx1;
 @mixin light {
    --input-bg: xxx1;
        &[data-variant='default'] {
      --input-bd: var(--mantine-color-gray-4);
      --input-bg: var(--mantine-color-white);
      --input-bd-focus: var(--mantine-primary-color-filled);
    }

    &[data-variant='filled'] {
      --input-bd: transparent;
      --input-bg: var(--mantine-color-gray-1);
      --input-bd-focus: var(--mantine-primary-color-filled);
    }
    .
  }
  @mixin dark {
    --input-bg: xxx2;
    // ...
  }
  
  // 在后续组件具体业务场景中,消费该变量
  
  .input-border {
    border-color: var(--mantine-primary-color-filled)
  }
}

这样设计一个非常大的好处肯定是,每一个组件可以对本组件的统一样式进行更加精细化的控制。但是它也会带来一个小问题就是,因为很难做到像 element-plus 一样将每一个主题的样式变量按照具体的主题文件拆分开,所以导致最终的打包结果会包含所有主题的样式。那么如果使用该组件的业务组件并不需要主题切换的功能,无疑会造成样式冗余。所以这就需要业务项目在使用的时候需要按需进行 css 摇树优化。 另外一点,如果业务组件需要在总体上全量定制和扩展其他的自定义主题,相比于 element-plus那种方案会相对比较麻烦。

css-in-js 方案:

既然我们所有的css的内容都是通过js来编写的,那么编写主题对应的css变量就贼方便了,我们完全可以创建出类似于这样的样式配置:

export const buttonStyle = {
  bgColor: {
    default: "xxx1",
    dark: "xxx2"
  }
}

在使用的时候特别简单:


// 导入 button 样式配置

import { buttonStyle } from 'xxx'

// 获取全局主题状态

const theme = useTheme()

// 生成根据状态计算主题样式的函数

const getThemStyle = useGetThemeFn(buttonStyle, theme.value)


// 在jsx 上直接使用该函数来设置计算的样式

<button
style={ boColor: getThemStyle('bgColor') }
></button>

在组件内部通过 useTheme 来直接获取到用户在组件根元素上 provide 下来的组件主题配置全局状态 。在 getThemStyle 函数中就会自动根据当前的主题以及样式 token 配置来自动计算出对应的样式进行设置。

至于具体细节代码大家可以自行去进行设计,这里我们来总结一下这种方案的一些优劣:

好处:使用ts编写,毫无疑问极其的灵活,而且编码体验极佳。并且这种方案天然和jsx这种高度灵活的语言相得益彰。这也是 css-in-js 在react里面比较流行的原因。

坏处:特比较明显,就是所有的css都是通过执行js运行时设置上去的,这样在对首屏渲染性能要求比较高的场景以及服务端渲染的场景会比较难优化。并且,直接使用这种方案用户如果想要自定义主题也相对比较困难,需要组件库进一步进行插件化设计,提供方便的接口来供用户自定义主题样式插件。这些优化内容大家可以自行参考and以及社区react组件库的详细设计方案。

原子化设计方案

这里给大家介绍一个新型的组件库集成解决方案:Shadcn-ui。这个组件库在react生态中比较流行。它的核心设计理念其实就是三点:

  1. 集成的组件库解决方案,提供了专门的脚手架来一键创建自定义组件库项目。
  2. 原子化css设计,既然是组件库解决方案,那么就必须要非常方便的让用户快速自定义组件库样式主题,最大限度提供用户自定义样式的效率。
  3. 插件化设计,插件化接入各类ui组件,提供高效配置并且生成样式配置插件的平台。

关于它的详细细节我在这里就不一一深究了,感兴趣可以参考它的官网:shadcn/ui中文文档 | shadcn/ui中文网

我在这里只想深入探讨一个点,就是原子化css方案选型必须考虑的一点:原子化css适合解决静态样式的设置。如果你的组件,比如通用ui库组件,可能需要根据组件的状态切换,大量的切换许多动态样式的话,这种场景不适合直接使用原子化类名设置这些动态样式,那样,控制起来会非常难受,而且也会一定程度影响样式切换效率。像通用组件库比较好的搭配是:大量纯静态样式适合用 tailwind css,一些核心的动态样式配合动态类名以及其他css方案解决。

3. 两个细节补充:

1. Css module 工程化解决方案:

我们首先回到 css module 的核心原理,其实很简单,它会将导入的所有的 css 样式编译成为一个对象,对象的key就是设置的原始class,对象的 value 就是编译之后生成的唯一的 hash 值,类似于下面这样:

{
  class1: 'xxshshshshs'
}

为了实现这一点 Mantine-ui 在工程化处理的时候,进行了如下的处理:

  1. 首先在 rollup 中配置了 postcss 插件:
   postcss({
      extract: true,
      modules: { generateScopedName },
    }),

modules 实际上就是告诉postcss需要支持 css module 的编译并且通过制定 generateScopedName 来生唯一的 hash 类名。这样之后呢,rollup 在打包的时候就会将 css 转化为一个js文件,并且该js文件会导出一个对象,对象的内容就是转换之后的 css 类名。然后我们在js文件中就可以顺利将其导入了。

  1. 因为目前的rollup打包并不会生成最终的 css 样式文件,所以 Mantine-ui 还会自定义一个 css 生成脚本,通过执行这个脚本来,调用 postcss 以及它的插件来编译生成最终的css文件。

我们可以看到它还专门为组件库打包定制了一个postcss 插件集合。这也是postcss比普通的与编译器强大灵活的地方。

经过上面的处理,如果是普通的业务项目,实际上css module 工程化就已经实现了。但是如果是通用ui库,那么还会遇到一个非常恶心的问题:

我们生成到 input 上面的是一个随机字符串样式,是没有任何语义可言的。如果使用组件的人需要覆盖这个样式,自定义样式,那怎么搞?总不可能让人家在业务项目中直接编写这个类名吧,这样业务开发肯定不干呀,那就必须解决,怎么解决呢?很简单,在生成随机类名的同时,自动生成一个语义化的类名:

我们可以看到 input 组件所有的随机类名后面都会带上固定格式的语义化类名。Mantine-ui专门基于 clsx 这个动态类名拼接库自研了一个动态类名生成器函数。具体实现大家可以去自行参考。

至此我们就总体上看完了企业级css module 实现工程化方案。大家可以借鉴落地到自己的项目中去。

2. And 组件分层架构设计浅析:

分层架构其实是我们解决大部分复杂问题的一个最常见的解决方案之一,and的组件设计就是这一架构的最佳体现,它将任何一个ui组件的实现都分为连个层次:

  1. 基础组件:不包含任何业务逻辑,纯粹的基础ui和交互。
  2. 业务ui组件:在基础组件之上添加业务逻辑以及自定义样式。

我们以 input 组件为例:

首先导入了基础input组件,这里面就已经封装了input相关的通用的逻辑以及事件处理等。

然后在它之上根据各类场景添加各种自定义样式以及交互:

分层架构在复杂通用组件设计的时候非常常用,往往可以解决很多非常难以设计的问题,而且它一方面可以最大限度的复用基础组件的能力,另一方面又可以最大限度扩展基础组件的使用场景。

一个复杂的通用组件库的设计还需要考虑特别多的问题,css 工程化只是它复杂工程化处理的一个切面。关于它的其它内容,我们在后续的文章中可以继续探讨。