1. css 工程化概述:
css 工程化总述:
css 工程化和 js 工程化的目的一致,实际上都是为了解决日益复杂的前端开发过程中的 css 研发痛点。css 开发痛点总的来说无外乎以下几点:
- 工程复杂之后,css 类名冲突的问题。
- 工程复杂之后,css 模块化的需求。
- 更加友好,更加智能的 css 编码要求。
- 更小的css打包体积,更加快捷的css传输效率。
各类css工程化方案盘点:
而总体上来说,css 工程化主要是朝着以下几个方向在发力:
-
为了解决css类名冲突的问题,目前社区中最流行的其实是以下三种方案:
-
vue 单文件组件支持 css scoped,总的来说就是每一个vue单文件组件都可以定义自己的 css 模块,而不同组件的css模块在经过编译之后,类名的最外层都会自动带上唯一的 hash 属性,从而解决类名冲突的问题。
-
React 以及vue非单文件组件开发的时候,主要会使用 css module。主要思路就是在 js 中导入 css 模块,构建工具在处理导入的css模块的时候,会使用 css loader 将其转化为一个对象的格式:
const className = { "原始类名a": "唯一的hash值", "原始类名b": "唯一的hash值" } ``` 我们要将编译之后的hash值设置到对应的元素上的时候,只需要:
<div class={ { [className.xxx]: true, [className.xxx2]: true } } ></div> ```
采用类似的方式设置上去就可以了。因此最终浏览器中渲染的时候,元素上的 class 都是一些唯一的hash值,自然就比较好的解决了css 类名冲突的问题。
- css-in-js 方案
在react组件开发的时候一个很常见的 styled-components 这个解决方案最终也会自动为用户编写的样式生成一个动态类名,然后自动设置到 react 元素上面,具体的例子我们马上就会举例。
-
-
为了更好的支持 css 模块化,目前社区流行的是以下几个方案:
- css-in-js 方案,这个是一个非常好的实现 css 模块化的一个方案,因为在利用这套方案编写 css 的时候,我们编写的本来就不是普通的 css 代码,而是 js 代码,所以我们在使用 css-in-js 的时候,完全可以利用 EsModule 这些js模块化的方案来抽离公共样式,定义公共变量,封装公共函数以及模块。比如下面这样:
import styled, { css } from 'styled-components' // 导入其他模块定义的公共尺寸变量 import { sizeCtx } from '@/xxx/utils' const ButtonVarsCss = css` --button-height-xs: sizeCtx.xxx1; --button-height-sm: sizeCtx.xxx2; --button-height-md: sizeCtx.xxx3; --button-height-lg: sizeCtx.xxx4; --button-height-xl: sizeCtx.xxx5; ` export const StyledButton = styled.button<{ variant?: ButtonVariant }>` ${ButtonVarsCss} ` ``` 以上我们就将整个项目中的公共尺寸数据定义到了一个独立的模块之中,然后可以方便的在其他组件开发的时候直接使用 es 模块化进行导入,从而进行使用。因为 css-in-js 可以直接利用 js 模块化,所以个人认为这个是实现 css 模块化最好的一个方案。而且其他的目前常见的 css 模块化方案的思想其实还是借鉴了这个思想而实现的。
-
第二种思路就是在css编写上就支持类似于js的模块化导入导出方案。然后定义特定的处理器将其编译和转化为原始的 css 代码。比如在以前很流行的 scss 以及 less 就是这种做法:
@use 'mixins/mixins' as *;
@use 'mixins/var' as *;
@use 'common/var' as *;
在 scss 中我们可以利用 @use 等各种强大的指令来导入其他的 scss 模块,也可以在其他的 scss 模块中灵活的导出指定的内容,从而实现 css 的模块化开发。
不过在目前随着postcss社区的日益壮大以及因为postcss本身灵活强大的插件化机制,我们在对css模块化有更高要求的时候,完全可以通过自定义postcss 插件来实现更加灵活和方便的 css 模块化机制。
-
为了实现更加优雅灵活的css编码体验,那这个五花八门的方案就极其分丰富了:
-
老牌的scss和less编译器,它们本身提供了不少比较灵活和强大的 css 扩展语法,在开发一般业务项目的时候,尤其是在vue生态中目前用的还是很多的。但是 scss和less毕竟它的生态是封闭的,如果我们在开发的过程中,尤其是在开发通用组件库的时候,因为本身开发者的整体开发能力达到了一定的水准之后,我们可能需要更加高效的css编码体验,并且可以持续升级,那么就完全可以基于 postcss,利用其强大的生态以及可自定义能力来不断的提升编码的便捷性。
-
css-in-js,这个就不必说了,js语言这种真正的编程语言的灵活性就不是css这种命令式语言可以比拟的,再加上 ts 的加持,编码中各种语法智能提示是非常棒的。
-
原子化编程能力,像 tailwind css 这种原子化 css 框架出现之后,css 的开发效率基本上就是火箭一样了。
-
-
而要实现更加小的打包体积,其实和js类似,无外乎就是朝两方面着手:
- css-tree-shaking:js可以通过摇树实现按需打包和按需打包,那么css同样可以通过摇树实现按需打包,尤其是在使用 tailwind css 这种框架的时候是必选项。
- css 压缩,也是优化 css 打包体积的重要手段。
而要实现上述需求,借助 postcss 极其插件就是一个非常好的选项。为什么我一直反复强调 postcss,因为在目前想要对css源代码动手术,postcss是出场率最高的大杀器。
我们在上面已经总体上完成了目前社区最常见的css工程化解决方案的盘点,下面我们就进入正题,我们开始来思考这么多css工程化解决方案,那么我们在进行项目架构的时候,如何根据当前的情况来进行 css 工程化技术选型呢?怎么样选取最适合我们本团队本项目场景的方案呢?下面我们就以一个角度,那就是通用ui库的设计的角度来看一下各大国内外主流ui库它们在进行css工程化设计的时候需要考虑哪些问题,它们又是怎么进行技术选型的。
2. 主流ui库css技术选型样式体系设计
下面,我们就将我们的身份转换成一个大型ui库的css样式体系的设计者,来逐步思考以下设计一个 ui 库的css样式体系需要做哪一些事情:
1. 需要定义出当前ui库的各类梯度变量:
其中最主要的,当然是颜色的梯度。当然,这个过程除了前端工程师之外,还需要很多其他的角色一起参与进行研讨和制定,比如ui设计。各类梯度样式标准定下来之后,接下来就是将其具象化输出。这个工作一般是由前端工程师来进行。当然如果我们团队内部有一个css工程化基建平台,那么这个工作也可以直接交给ui设计同学去直接去进行定义和标准化。详细的内容可以参考:juejin.cn/post/741473… 这篇文章。我们在这里就统一暂定还是由前端同学来进行定义。而定义梯度变量,肯定需要使用到变量,现代css已经原生支持了变量,当然如果我们在项目中使用的是 scss 或者 less 这种预处理器的话,也可以直接利用这些预处理器的的变量语法来进行梯度变量的定义。
直接通过 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 变量定义出来了不同程度的暗色的色值。
如果通过 scss 或者 less 变量来进行定义可能类似于下面这样:
@gray-1: #f5f5f5;
@gray-2: #f0f0f0;
@gray-3: #d9d9d9;
@gray-4: #bfbfbf;
@gray-5: #8c8c8c;
@gray-6: #595959;
@gray-7: #434343;
@gray-8: #262626;
@gray-9: #1f1f1f;
@gray-10: #141414;
总之最终的结果,都会产生适配本组件库ui规范的梯度值配置。但是如果我们对项目的打包体积吹毛求疵的话,使用 css 变量直接定义梯度变量有一个问题就是,如果我们定义的很多梯度变量暂时没有在项目中使用到的话,而它默认情况下依赖会出现在最终的打包结果中。要解决这个问题就需要借助 postcss 以及相关的css摇树插件将没有用到的css变量暂时从最终的打包结果中去除掉。而 scss 或者 less 在编译的时候本质上就是将使用变量的地方原地替换为真实的 css 值,所以不会存在这个问题。
除了上面这两种方案之外,如果我们的css解决方案选择的是 css-in-js,那么我们其实可以完全使用 js 来定义 css 梯度变量,类似于下面这样:
export const dark1 = "#c9c9c9"
export const dark2 = "#b8b8b8"
然后再使用的时候可以这样:
import { dark1 } from '@/xxx/utils'
// 使用导入的 js 变量直接设置
而我们再定义变量的模块完全可以使用 css Module 的具名导出,再使用的使用使用具名导入,这样再最终打包的时候就可以完全利用 js 摇树优化来干掉多余的变量了。而以上这种方案其实也可以和 tailwind css 很好的结合起来:
Tailwind css 配置文件:
import { dark1 } from '@/xxx/utils'
export default {
colors: {
"dark1": dark1
}
}
直接可以在它的配置文件中消费js定义的梯度变量,还是非常灵活和优雅的。
1. 利用梯度变量定义各种模式的css样式:
目前支持主题切换已经成为了组件库设计和开发的刚需了。而要常用的主题切换方案我在 juejin.cn/post/741473… 这篇文章中有比较详细的盘点,我再这里就直接带大家一起看一下各类主流ui框架的设计方案:
1. 以 element-plus 为代表的方案:
这种也是目前最常见的设计,首先在 root 根元素下挂载常规模式下的主要颜色变量:
:root {
--xxx-color-scheme: xxx;
// ...
}
然后定义其他模式的css变量:
:root[theme=dark] {
--xxx-color-scheme: xxx2;
// ...
}
而真正要使用的时候,可以直接这样编写:
.el-button {
color: var(--xxx-color-scheme);
}
而要切换主题的话,只需要通过切换根元素的的属性选择器来实现就可以了。而且这种方案,最终的打包结果就是不同的模式的定义会被打包到单独的文件中,用户可以按需使用。
2. Mantine-ui 为代表的解决方案:
Mantine-ui css 方案总体上使用的是 css module。并且利用 post css 自定义插件,自定义了一套css语法。我们来大致看一下它的实现方案:
1. 首先每一个组件都会定义一个 css module 样式:
Mantine-ui 会专门编写一个脚本来编译和生成 module css,并且利用 postcss 来处理自定义 css 语法,
这一点上,它比 element-plus(直接使用 scss)要更加灵活一些,依托 postcss 强大的生态,Mantine-ui
可以最大限度自定义强大的css语法,增强css编码效率。但是同时,这也对开发者能力提出更高的要求。
团队能力比较强的话,可以借鉴它的方案。
2. 接着再每一个组件对应的 css module 内部定义本组件的 css 主题状态:
在每一个主题状态下会给同样的 css 变量定义不同的值,包括它们按钮本身的状态。当切换不同状态的时候,实际上就是将css的变量值进行切换。从而实现了主题切换。
3. 方案对比:
这个方案和我们上面讨论的 element-plus 那种处理方案最本质的区别就在于:
- Element-plus 将不同主题对应的不同的颜色集中到了一起处理,最终生成的 css 结果也是比较清晰的:
默认主题的颜色变量可以方便的生成一个 css 文件:
:root {
// xxx
}
Dark 主题的颜色变量,也可以方便的生成一个 css 文件:
:root[theme=dark] {
// xxxx
}
当业务项目需要dark主题的时候,可以就可以直接导入dark主题的css文件。当业务项目不需要 dark 主题的时候就不需要导入了,只需要导入默认主题的css文件就可以了。如果要适配这两种情况,取得最好的css模块体积的话,是比较好处理的。
- Mantine-ui 的主题颜色不是集中编写的,而是编写在具体的 css Module 中的,每一个 css module 都可以方便的自定义本组件相关的 css 变量。而且不仅和主题切换相关的变量,每一个模块还会利用 css 变量定义出当前组件所需要的一系列公共 css 变量:
这样在组件开发层面会更加灵活。但是在主题变量的定义上也会出现一个不好解决的问题,那就是如果不做任何额外处理,直接进行常规打包的话,组件所有的主题都会被打包的一个 css 模块中。这就导致业务项目在使用的时候,不管这个业务项目是否需要支持主题切换,在使用的时候都会额外导入一些其他多余主题的样式,要解决这个问题的话,业务项目需要手动进行 css 摇树优化,去除掉额外的主题样式。不过 css 变量也为业务项目覆盖掉组件默认的样式,进行样式自定义提供了很大的便利:
.mantine-Input-wrapper {
--input-height-xs: xxx
}
3. And 为代表的 css-in-js 处理:
首先我声明一点,and 的 css-in-js 处理逻辑源码比较绕。我在这里对它的处理方案进行简化,抽离它的核心逻辑,解决它最紧要的问题:
还是以 input 组件为例,每一个组件都可以利用 js 定义出当前模块所需要的 css 变量以及主题变量:
input-style.ts:
// 导入公共梯度变量
import { default1, dark1, green2, padding1 } from '@/xxx/utils'
export default {
// 定义和主题无关的样式值
paddingBlockLG: padding1,
// 定义主题颜色变量
default: {
bgColor: default1,
// ...
},
dark: {
bgColor: dark1
}
}
至此我们就定义好了 input 组件所需要的所有的主题样式变量以及一些其他的公共样式变量。
然后我们在使用的时候就就可以这样:
export const StyledButton = styled.button<{ variant?: ButtonVariant }>`
${ButtonVarsCss}
background-color: getVariantColor('bgColor')
`
注意,以上是伪代码,大家理解意思即可。我的预期就是当我一旦调用 getVariantColor 这个函数之后,它就会自动根据当前所处的主题自动计算出对应的主题所对应的色值并且设置上去。那么要实现上述需求,毫无疑问是需要一个全局主题状态的,在组件库开发模式下,要创建全局状态肯定是不能直接依赖第三方状态管理工具的,只能依赖 vue 或者 react 自身的能力。以vue框架为例,需要在组件层面创建全局共享的状态,那么肯定需要使用 provide api,为了方便业务开发者使用,组件库一般会是一个 monorepo 的结构,我们会创建一个通用的 hooks 子包,从中导出一个管理组件库全局状态的 hook:
useSetGlobalTheme:
import { provide, ref } from 'vue'
import { themeKey, setThemeFnKey } from 'xxx'
export default function useSetGlobalTheme() {
// 定义一个全局主题状态
const theme = ref('default')
// 初始化状态
const initTheme = () => {
// 获取根元素
const rootHtml = document.documentElement
if (!rootHtml) {
return
}
// 获取根元素的模式属性
const rootTheme = rootHtml.getAttribute('theme')
theme.value = rootTheme || 'default'
}
const setTheme = (newTneme) => {
theme.value = newTheme
const rootHtml = document.documentElement
if (!rootHtml) {
return
}
rootHtml.setAttribute('theme', theme.value)
}
initTheme()
// 关键步骤,将全局状态直接设置共享给后代组件
provide(themeKey, theme)
provide(setThemeFnKey, setTheme)
}
此外,我们还会搭配两个hook:
useThemeValue:
export default function useThemeValue() {
return inject(themeKey)
}
useSetTheme:
export default function useSetTheme() {
return inject(setThemeFnKey)
}
在用户使用的时候只需要在根组件中导入 useSetGlobalTheme 将全局状态注入到后代组件中:
useSetGlobalTheme()
在需要切换主题的地方直接获取到设置主题状态的函数:
const setUiTheme = useSetTheme()
在合适的时机调用该函数进行状态切换就可以了。
而在具体的ui组件中,因为跟根件已经注入了状态值,所以也可以方便的获取到了:
import inputStyle from "./style"
const themeValue = useThemeValue()
// 获取解析模式样式的高阶函数
const getVariantColor = useCompThemeUi(themeValue.value, inputStyle)
// getVariantColor 是一个高阶函数,会自动根据当前状态以及用户注入的样式对象查找到制定状态的样式
<input
style={
{
// 自动获取当前主题下的背景颜色设置上去
backgroundColor: getVariantColor('bgColor')
}
}
/>
再次声明以下,以上都是伪代码,大家只需要理解这个方案的原理,然后灵活运用就可以了。上面这个实现还是包含了不少vue、react 这类框架的比较高阶的处理技巧的,大家可以慢慢感受一下。实际上主流的 css-in-js 设计方案就是这个原理。
我们可以感受到,实际上 css-in-js 的方案依靠js强大的编程能力,实际上是所有方案中最灵活,也是最有意思的设计,但是因为它所有的样式都是运行时计算出来的,所以必须等到js执行之后才能开始样式设置,所以在对页面首屏渲染要求较高的场景是会遇到瓶颈的。
4. Shadcn-ui 为代表的原子化设计方案:
这个ui框架非常有意思。它本身并不仅限于是一个组件库,而是一个ui解决方案。它一方面提供了一套原子化的组件,另一方面也提供了一个强大的cli工具,允许用户快速基于这个cli初始化一个 react 组件库项目。而且它默认不会安装任何ui组件,而是会在cli命令行中提供一个类似于 vue use 的命令 来允许用户以插件的形式来自定义安装制定的原子化ui组件。因为它的产品定位就是一个ui框架解决方案,所以必须允许用户快速自定义各种各样的主题样式风格,而且官方还会提供一个css样式配置器,让用户配置自己的ui风格并且快速导入到项目中进行使用。基于这些要求,它底层是依赖 tailwind css 这个ui框架来进行管理的:
当用户需要快速自定义自己的ui风格的时候只需要做两件事情:
- 配置出自己的ui风格的原子化样式配置,并且将其导出。
- 将配置直接放置到 tailwind css 配置文件中
就可以了。而且因为 tailwind css 它快捷的开发能力,所以我们在开发组件的体验是非常好的。是目前搭建react组件库一个非常值得考虑的方案。
针对原子化css方案选型有一个很重要的一点需要着重考虑,那就是原子化css适合解决静态样式的问题。不太好解决动态样式切换的问题。这也是一般的通用组件,尤其是带状态类型的组件不会纯使用原子化css的原因。比如button组件一般都会有type属性,随着从type1切换到type2,可能伴随着诸多样式的变化。如果这个时候需要动态切换一堆类名,那还是挺恶心的。而且从渲染性能层面考虑,动态切换过多类名也是不如切换一个类名好的。因此在一般技术选型的时候还是建议这样做:
- 可以总体上使用原子化css,处理大部分静态样式
- 关键的动态样式还是辅助使用其他的css方案。
而shad-ui,因为需要打造最通用组件库解决方案的产品定位,所以只能在动态样式的时候也使用原子化css。
3. 两个细节补充:
1. Css module 组件库工程化解决方案:
我们首先回到 css module 的核心原理,其实很简单,它会将导入的所有的 css 样式编译成为一个对象,对象的key就是设置的原始class,对象的 value 就是编译之后生成的唯一的 hash 值,类似于下面这样:
{
class1: 'xxshshshshs'
}
为了实现这一点 Mantine-ui 在工程化处理的时候,进行了如下的处理:
- 首先在 rollup 中配置了 postcss 插件:
postcss({
extract: true,
modules: { generateScopedName },
}),
modules 实际上就是告诉postcss需要支持 css module 的编译并且通过制定 generateScopedName 来生唯一的 hash 类名。这样之后呢,rollup 在打包的时候就会将 css 转化为一个js文件,并且该js文件会导出一个对象,对象的内容就是转换之后的 css 类名。然后我们在js文件中就可以顺利将其导入了。
- 因为目前的rollup打包并不会生成最终的 css 样式文件,所以 Mantine-ui 还会自定义一个 css 生成脚本,通过执行这个脚本来,调用 postcss 以及它的插件来编译生成最终的css文件。
我们可以看到它还专门为组件库打包定制了一个postcss 插件集合。这也是postcss比普通的与编译器强大灵活的地方。
经过上面的处理,如果是普通的业务项目,实际上css module 工程化就已经实现了。但是如果是通用ui库,那么还会遇到一个非常恶心的问题:
我们生成到 input 上面的是一个随机字符串样式,是没有任何语义可言的。如果使用组件的人需要覆盖这个样式,自定义样式,那怎么搞?总不可能让人家在业务项目中直接编写这个类名吧,这样业务开发肯定不干呀,那就必须解决,怎么解决呢?很简单,在生成随机类名的同时,自动生成一个语义化的类名:
我们可以看到 input 组件所有的随机类名后面都会带上固定格式的语义化类名。Mantine-ui专门基于 clsx 这个动态类名拼接库自研了一个动态类名生成器函数。具体实现大家可以去自行参考。
至此我们就总体上看完了企业级css module 实现工程化方案。大家可以借鉴落地到自己的项目中去。
2. And 组件分层架构设计浅析:
分层架构其实是我们解决大部分复杂问题的一个最常见的解决方案之一,and的组件设计就是这一架构的最佳体现,它将任何一个ui组件的实现都分为连个层次:
- 基础组件:不包含任何业务逻辑,纯粹的基础ui和交互。
- 业务ui组件:在基础组件之上添加业务逻辑以及自定义样式。
我们以 input 组件为例:
首先导入了基础input组件,这里面就已经封装了input相关的通用的逻辑以及事件处理等。
然后在它之上根据各类场景添加各种自定义样式以及交互:
分层架构在复杂通用组件设计的时候非常常用,往往可以解决很多非常难以设计的问题,而且它一方面可以最大限度的复用基础组件的能力,另一方面又可以最大限度扩展基础组件的使用场景。
一个复杂的通用组件库的设计还需要考虑特别多的问题,css 工程化只是它复杂工程化处理的一个切面。关于它的其它内容,我们在后续的文章中可以继续探讨。