重新构想原子化 CSS
前言
原子化 CSS 是一种 CSS 的架构方式,它倾向于小巧且用途单一的 class,并且会以视觉效果进行命名。
英文原文: lets-define-exactly-atomic-css
背景
Anthony Fu,是 Vite 团队的成员,也是 Vitesse (Vite 社区最受欢迎的起手模板之一) 的作者。他享受原子化 CSS 带来的快速开发体验,而因此选择了 Tailwind CSS 作为 Vitesse 的默认 UI 框架。虽然 Vite 较 Webpack 等工具相比,在加载速度上有了大幅提升,但由于 Tailwind 生成了数 MB 的 CSS,使得加载与更新 CSS 成为了整个 Vite 应用的性能瓶颈。他曾以为这是使用为了原子式 CSS 的一种权衡,直到他发现了 Windi CSS。
Windi CSS 是从零开始编写的 Tailwind CSS 的替代方案。它的零依赖,也不要求用户安装 PostCSS 和 Autoprefixer。更为重要的是,它支持 按需生成。Windi CSS 不会一次生成所有的 CSS,而是只会生成你在代码中实际使用到的原子化 CSS。这与 Vite 按需使用的理念不谋而合,也因此,Anthony Fu 为它编写了 一个 Vite 插件。不出所料,从一个简单的测试上可以看到它比 Tailwind 要快了 20~100 倍。
项目进展相当顺利,Windi CSS 也快速成长为一个团队,他们也引入了许多创新,如 自动值推导,可变修饰组,Shortcuts,在 DevTools 中进行设计,属性化模式 等。作为结果,Tailwind 也 因此 使用了同样的技术并推出了自己的 JIT 按需引擎。
什么是原子化 CSS?
首先,让我们为 原子化 CSS (Atomic CSS) 给出适当的定义:
John Polacek 在 文章 Let’s Define Exactly What Atomic CSS is 中写道:
译文:
有些人可能会称其为函数式 CSS,或者 CSS 实用工具。本质上,你可以将原子化的 CSS 框架理解为这类 CSS 的统称:
1 | .m-0 { |
市面上有不少实用至上的 CSS 框架,如 Tailwind CSS,Windi CSS 以及 Tachyons 等。
同时有些 UI 库也会附带一些 CSS 工具类作为框架的补充,如 Bootstrap 和 Chakra UI。
剖析原子化 CSS#
在文章开始前,我们来聊聊原子化 CSS 的工作原理。
传统方案#
制作原子化 CSS 的传统方案其实就是提供所有你可能需要用到的 CSS 工具。例如,你可能会用预处理器(这里选用的是 SCSS)生成如下代码:
1 | // style.scss |
编译结果为:
1 | .m-1 { margin: 0.25 rem; } |
现在你可以直接使用 class=”m-1” 来设置边距。但正如你所见,用这种方法的情况下,你不能使用除了 1 到 10 之外的边距,而且,即使你只使用了其中一条 CSS 规则,但还是要为其余几条规则的文件体积买单。如果之后你还想支持不同的 margin 方向,使用比如 mt 代表 margin-top,mb 代表 margin-bottom 等,加上这 4 个方向以后,你的 CSS 大小会变成原来的 5 倍。如果再有使用到像 :hover 和 :focus 这样的伪类时,体积还会得更变大。以此类推,每多加一个工具类,往往意味着你 CSS 文件的大小也会随之增加。这也就是为什么传统的 Tailwind 生成的 CSS 文件会有数 MB 的大小。
为了解决这个问题,Tailwind 通过使用 PurgeCSS 来扫描你的大包产物并删除你不需要的规则。这得以使其在生产环境中 CSS 文件缩减为几 KB。然而,请注意,这个清除操作仅在生成构建下有效,而开发环境下仍要使用包含了所有规则巨大的 CSS 文件。这在 Webpack 中表现可能并不明显,但在 Vite 中却有着巨大的影响,毕竟其他内容的加载都非常迅捷。
既然生成再清除的方法存在局限性,那是否有更好的解决方案?
按需生成
“按需生成” 的想法引入了一种全新的思维方式。让我们先来对比下这些方案:
传统的方式不仅会消耗不必要的资源(生成了但未使用),甚至有时更是无法满足你的需求,因为总会有部分需求无法包含在内。
通过调换 “生成” 和 “扫描” 的顺序,”按需” 会为你节省浪费的计算开销和传输成本,同时可以灵活地实现预生成无法实现的动态需求。另外,这种方法可以同时在开发和生产中使用,提供了一致的开发体验,使得 HMR (Hot Module Replacement, 热更新) 更加高效。
为了实现这一点,Windi CSS 和 Tailwind JIT 都采用了预先扫描源代码的方式。下面是一个简单示例:
1 | import { promises as fs } from 'fs' |
为了在开发期间提供 HMR,通常会启动一个 文件系统监听器:
1 | import chokidar from 'chokidar' |
痛痒
我现在在我几乎所有的应用中都在使用 Windi CSS,而且效果显著,性能优异,HMR 瞬间完成几乎无法察觉。自动值推导 和 属性化模式 更是提升了我的开发体验。到这里,我本该可以睡上一个好觉去想想其他事情了,但是有时候,它还是会瘙你痒痒打扰你的美梦。
我发现最令人讨厌的是,和很多时候我不清楚我得到的结果是什么,以及怎么样做才能让它生效。对我而言,最好最理想的原子化 CSS 应该是直觉性的。一旦学会,它应该非常直观易懂,并且可以推导出其他已知情况。如果你知道 mt-1 是上边距的一倍单位,你就会直觉地认为 mb-2 是下边距的两倍单位。当它正常工作时,是直觉使然,但当它不起作用时,会令人沮丧和困惑。
例如,我们知道 Tailwind 中的 border-2 标识边框宽度为 2px,4 表示 4px,6 表示 6px,8 表示 8px,但当你使用 border-10 却不起作用(你甚至需要一些时间来发现这件事!)。你可能会说这是故意而为之,以使得设计系统具有一致性。不如这样,我们来做个小测验,如果想要让 border-10 正常工作,应该如何做?
在你的全局样式中的某个位置添加这样一个工具类?
1 | .border-10 { |
快速且直观,最重要的是,它的确解决了你的需求。但是,如果什么都需要我自己手动添加,那我们为什么还需要使用 Tailwind ?
如果你对 Tailwind 了解深入一些,那你可能知道它可以进行额外配置。所以你需要花 5 分钟,检索他们的文档。你将得到如下方案:
1 | // tailwind.config.js |
这似乎很合理,我可以把我需要的情况都列出来,回去继续工作了…等一下,我刚刚进行到哪里了?因为这样一个工具的丢失而被打断,除了配置,我们还会需要时间重新找回原本正在进行的工作的上下文。接着,如果我想设置边框颜色,我还需要查询文档,然后如何进行配置。也许有人喜欢这样的工作流程,但这并不适合我,我并不享受被本该直觉性工作的工具打断的我的工作流程。
Windi CSS 对规则相对宽松一些,会尽可能地根据你使用的 class 提供相应的实用工具类。在这种情况下,border-10 在 Windi 中可以做到开箱即用。但是,由于 Windi 需要与 Tailwind 兼容,它还必须使用与 Tailwind 完全相同的配置项。尽管数字推断的问题得到了解决,但如果你想添加一些自定义的工具,这将是一场噩梦。下面是一个来自 Tailwind 文档 的示例:
1 | // tailwind.config.js |
将生成如下代码:
1 | .rotate-1\/4 { |
生成 CSS 的代码甚至比结果还要长。并且难以阅读和维护,同时,它破坏了按需应变的能力。
Tailwind 的 API 和插件系统沿用了旧的思维方式进行设计,并不能适应新的按需方式。其核心工具是在生成器中锻造出来的,而且其定制化功能相当有限。因此,我开始思考,如果我们可以放弃这些历史包袱,并以随需应变思想重新设计它,我们可以得到什么?
向你介绍 UnoCSS
UnoCSS - 具有高性能且极具灵活性的即时原子化 CSS 引擎。
该项目诞生于我在国庆期间的做的一些随机实验。从使用者的角度出发去探索灵活性和直观性的最佳平衡,加上按需生成的思想,这些实验的最终结果在不少方面甚至超出了我的预期。接下来让我为你逐一介绍:
引擎
UnoCSS 是一个引擎,而非一款框架,因为它并未提供核心工具类,所有功能可以通过预设和内联配置提供。
我们设想 UnoCSS 能够通过预设模拟大多数已有原子化 CSS 框架的功能。也有可能会被用作创建一些新的原子化 CSS 框架的引擎。例如:
1 | import UnocssPlugin from '@unocss/vite' |
让我们来看看如何使它们成为可能:
直观且完全可定制
UnoCSS 的主要目标是直观性和可定制性。它可以让你在数十秒内,定义你自己的 CSS 工具。
静态规则
1 | rules: [ |
1 | .m-1 { margin: 0.25rem; } |
动态规则
1 | rules: [ |
例如,当你使用:
1 | <div class="m-100"> |
就会生成相应的 CSS:
1 | .m-100 { margin: 25rem; } |
这样就行了。而现在,你只需要使用相同的模式添加更多的实用工具类,你就拥有了属于自己的原子化 CSS!
可变修饰
可变修饰 (Variants) 在 UnoCSS 中也是简单且强大的。这里有几个示例:
1 | variants: [ |
你可以参考 文档 了解更多细节。
预设
你可以将自己的自定义规则和可变修饰打包成预设,与他人分享,或是使用 UnoCSS 作为引擎创建你自己的原子化 CSS 框架!
同时,我们在发布时也提供了 一些预设 供你快速上手。
值得一提的是,默认的 @unocss/preset-uno
预设(实验阶段)是一系列流行的原子化框架的 通用超集,包括了 Tailwind CSS,Windi CSS,Bootstrap,Tachyons 等。
例如,ml-3(Tailwind),ms-2(Bootstrap),ma4(Tachyons),mt-10px(Windi CSS)均会生效。
1 | .ma4 { margin: 1rem; } |
灵活性
截止目前为止,我们都在向你展示如何使用 UnoCSS 来模仿 Tailwind 和其他原子化框架的行为,即便 UnoCSS 让这件事变得十分容易,但仅此一点可能也不会在最终使用者的方面产生太大影响。
一起来见识下 UnoCSS 真正的威力:
属性化模式
属性化模式 (Attributify Mode) 是 Windi CSS 最受欢迎的特性之一。它能帮助你通过使用属性更好地组织和分组你的实用工具类。
它会把你的冗长的 Tailwind 代码(难以阅读与编辑):
1 | <button class="bg-blue-400 hover:bg-blue-500 text-sm text-white font-mono font-light py-2 px-4 rounded border-2 border-blue-200 dark:bg-blue-500 dark:hover:bg-blue-600"> |
变成:
1 | <button |
除了 Windi CSS 的属性化模式,仅需改动几行代码,我们还实现了无值的属性的支持:
1 | <div class="m-2 rounded text-teal-400" /> |
现在变为:
1 | <div m-2 rounded text-teal-400 /> |
整个属性化模式是通过 @unocss/preset-attributify 预设提供的,详细的使用方法请参考其文档。
CSS 作用域
在使用 Tailwind / Windi 时,我遇到的另一个问题就是 样式预检 (Preflight)。预检功能重置了原生元素,并为 CSS 变量提供了一些兜底方案,在开发一个只使用 Tailwind/Windi 的新应用时,效果很棒。但当你想让它们与其他 UI 框架一起工作,或者使用 Tailwind 编写一些共享组件时,预检往往会引入许多冲突,破坏你现有的 UI。
这也使得 UnoCSS 在 CSS 作用域上有了更多可能性。例如,我们在 Vite 插件上有一个实验性的 scoped-vue 模式,可以为每个组件生成作用域样式,你可以安全地使用原子化 CSS 作为组件库,而无需担心与用户的 CSS 发生冲突。比如:
1 | <template> |
我们还在尝试更多的可能性,比如支持 Web Component,MPA 情况下的 CSS 代码分割,以及模块级别的 CSS 作用域等。
性能
考虑到 UnoCSS 带来的灵活性和想象力,坦率地说,我认为性能可能不是那么重要的事情。出于好奇,我写了一个 简单的 benchmark 来比较性能。结果令人惊讶:
1 | 10/21/2021, 2:17:45 PM |
在开发时,UnoCSS 做了很多性能上的优化。如果你感兴趣,可以参考:
跳过解析,不使用 AST
从内部实现上看,Tailwind 依赖于 PostCSS 的 AST 进行修改,而 Windi 则是编写了一个自定义解析器和 AST。考虑到在开发过程中,这些工具 CSS 的并不经常变化,UnoCSS 通过非常高效的字符串拼接来直接生成对应的 CSS 而非引入整个编译过程。同时,UnoCSS 对类名和生成的 CSS 字符串进行了缓存,当再次遇到相同的实用工具类时,它可以绕过整个匹配和生成的过程。
单次迭代
正如前文所述,Windi CSS 和 Tailwind JIT 都依赖于对文件系统的预扫描,并使用文件系统监听器来实现 HMR。文件 I/O 不可避免地会引入开销,而你的构建工具实际上需要再次加载它们。那么我们为什么不直接利用已经被工具读取过的内容呢?
除了独立的生成器核心以外,UnoCSS 有意只提供了 Vite 插件(以后可能考虑其他的集成),这使得它能够专注于与 Vite 的最佳集成。
在 Vite 中,transform 的钩子将与所有的文件及其内容一起被迭代。因此,我们可以写一个插件来收集它们,比如:
1 | export default { |
由于 Vite 也会处理 HMR,并在文件变化时再次执行 transform 钩子,这使得 UnoCSS 可以在一次加载中就完成所有的工作,没有重复的文件 I/O 和文件系统监听器。此外,通过这种方式,扫描会依赖于模块图而非文件 glob。这意味着只有构建在你应用程序中的模块才会影响生成的 CSS,而并非你文件夹下的任何文件。
安装
1 | npm i -D unocss |
在 vite.config.ts(或 vite.config.js)中,写入以下配置
1 | import Unocss from 'unocss/vite' |
而后,在 man.ts(main.js)中引入 css
1 | import 'uno.css' |
vscode 代码提示配置
安装完毕后,可能需要重新启动 vscode,之后在 html 中就会为你提供快捷选项。
注意:preset-uno 虽已包含 Tailwind CSS、Windi CSS、Bootstrap、Tachyons 等风格类名,但类名风格建议以 tailwindcss 为准,因为 windicss 也是 Tailwind 的编译速度解决方案,它也完全遵循 Tailwind 规则,所以我们完全可以以 Tailwind 为准进行使用。
结束语
非常感谢你的阅读!如果你对它感兴趣,请记得查看 antfu/unocss
仓库以了解更多细节,也可以通过 在线 Playground 进行尝试。