跳到正文

我所理解的 UnoCSS 最佳实践(1):为什么选择语义类名与 @apply

我所理解的 UnoCSS 最佳实践

  1. 为什么选择语义类名与 @apply(当前)
  2. 样式约定、Lint 与等价性验证

我想用原子 CSS 提高写样式的效率,但又希望打开 Vue 的 template 时,看到的是组件结构和元素职责。这两个要求可以同时满足:模板保留语义化的 BEM class,SCSS 负责组织选择器,UnoCSS 通过 @apply 展开具体样式。

最近做一个 Vue / Nuxt 项目时,我围绕这套写法调整了预处理器、变量、响应式、主题和组件边界。过程中也遇到了几个值得留下的坑:工具类名字看起来相近,生成的属性却未必等价;媒体块里的属性没变,页面布局仍可能变化;一份全局按钮样式用久了,会逐渐变成没有组件接口的隐式组件库。

我最终采用的是 HTML + scoped SCSS + BEM + UnoCSS @apply。这里的「最佳实践」带着明确的个人偏好:我更重视模板可读性、样式归属和扩展边界,愿意为此接受一些命名和构建成本。以下示例以 UnoCSS 66.11.0、Wind3 和 Sass Embedded 1.105.1 为验证基础;其他预设和版本需要重新核对输出。

为什么用 UnoCSS

原子 CSS 通常把样式拆成职责很小的类,再通过组合完成布局。flex、gap-12px、text-20px 都属于这样的工具类。它们仍然对应样式表中的 CSS 规则,和 HTML 的 style 属性不同,也能表达媒体查询、伪类等条件。

用 @apply 把样式写得更紧凑

普通 CSS 通常按一条声明一行排版。比如资料卡片的这组基础样式:

.resource-card {
  display: grid;
  min-width: 0;
  gap: 12px;
  padding: 24px;
  border-radius: 16px;
}

使用 UnoCSS 的工具类后,同样的样式可以在 style 块里写成:

.resource-card {
  @apply grid min-w-0 gap-12px p-24px rounded-16px;
}

p-24px、rounded-16px 这样的简写,把属性和值合在一个更短的表达里。一行 @apply 就能写全这个选择器的基础样式;对大多数样式不多的元素,这种紧凑感会很明显,同一屏里也能看到更多选择器和它们之间的关系。

CSS 本身也允许把多条声明写在一行,工具类的价值在于减少书写,而不只是减少换行。熟悉这套词汇后,布局、间距、尺寸等常用样式可以更快地写出来,同时继续保留语义类名、选择器和 style 块的组织方式。

这里节省的是源码篇幅。directives transformer 会在构建时把 @apply 展开成 CSS 声明,浏览器最终仍然读取标准 CSS。源码更短,不代表生成的 CSS 也更小;工具类变多、条件变复杂时,可以按职责和条件分行,具体的分行与条件分组约定见第二篇。

相比 Tailwind CSS,UnoCSS 的优势在哪里

Tailwind CSS 同样支持把工具类写进 CSS 的 @apply,也提供主题定制、自定义工具类和变体。因此,上面的紧凑写法是采用工具类的理由;进一步选择 UnoCSS,主要是因为它更适合我现有的 SCSS 组织方式,也给了我更多组合规则和语法的空间。

我需要的能力 UnoCSS 的做法 与 Tailwind CSS v4 的差异
保留 SCSS 的 BEM 拼接、根选择器引用和主题 map SCSS 组织选择器与数据,directives 展开 @apply,本文的示例沿用这条构建链路 v4 官方不以 Sass、Less、Stylus 集成为目标,而是由自身处理 CSS 导入、嵌套等能力
按团队约定定义工具类 rules 支持静态映射与动态匹配函数,presets 可以打包共享规则与变体,常用部分继续使用 Wind3 等预设 Tailwind 也支持自定义工具类和变体,但扩展主要围绕它的 CSS 指令与主题体系;我更偏好直接在 JS / TS 配置里编写匹配和生成逻辑
进一步缩短同一条件下的样式组合 @apply 支持 hover:(...) 这样的条件分组;模板里的同类语法也有独立的 variant group transformer Tailwind 提供 hover: 等前缀,也能在 CSS 中用 @variant 包裹一组声明;UnoCSS 的分组写法更贴近这里一条 @apply 组织一组样式的习惯

这些差异来自两者的组织方式。UnoCSS 把生成引擎、预设、规则、变体和源码 transformer 分开,工具类语法本身也由预设提供,项目可以按需要组合。除了这里用到的 directives,还可以选择 shortcuts、图标预设 和 Inspector。我实际最常用的是 directives 和条件分组;Attributify 会把表现信息放进模板属性,与这套模板阅读偏好未必一致,因此没有一起启用。

本文继续使用 Wind3,因为已有样式和验证都围绕它建立。官方也提供 Wind4,但它涉及主题键、默认值和浏览器条件的变化,新项目可以单独评估。

性能需要按当前版本比较。Tailwind v4 已经重写引擎,提供官方 Vite 插件和 CSS-first 配置,也会按需生成工具样式。2021 年文章里的性能数字有当时的版本背景,不能直接作为今天选型的依据。

我会这样选择:

项目约束 我的选择倾向
Vue / SCSS / BEM,想让工具类留在样式层 UnoCSS
需要自定义匹配逻辑、变体或组合不同预设 UnoCSS
已围绕 Tailwind v4 建立设计系统和工具链 沿用现有体系
大量依赖 Tailwind 插件和配置 先算迁移成本

UnoCSS 的官方比较也说明,它不兼容 Tailwind 的插件系统与配置。预设语法相近,不等于整个生态可以无成本替换。旧项目如果正好要整理样式架构,可以挑一个完整组件评估迁移;稳定项目没有明确收益时,我不会只为统一工具名进行全量替换。

我为什么把工具类放进 @apply

同一张资料卡片,可以直接写工具类,也可以让工具类留在样式层。下图只比较源码组织,展示的是同一个标题元素。

直接组合工具类与语义类加 apply 的源码组织对照

我偏向右边,原因有三个。

读模板时,结构和行为更容易找

<template>
  <article class="resource-card" :class="{ 'resource-card--is-saved': saved }">
    <a class="resource-card__title" :href="href">{{ title }}</a>
    <p class="resource-card__summary">{{ summary }}</p>
    <div class="resource-card__actions"><slot name="actions" /></div>
  </article>
</template>

读这段模板,可以较快找到标题、摘要、操作区,以及收藏状态由谁决定。间距、字体、断点和交互色放到 style 里,检查布局时再展开。

Vue SFC 本来就把结构、行为和表现放在同一个文件里,React 也会在组件函数中返回界面结构。我在意的是文件内部的关注点分离:这些代码可以就近维护,同时让每一块承担比较清楚的阅读任务。

直接组合工具类有另一种便利:观察一个节点时,马上能看到它的视觉实现,不必跳到选择器。Tailwind 作者 Adam Wathan 在 2017 年关于关注点分离的文章里,讨论的正是 HTML 与 CSS 之间的依赖方向。两种组织方式优化的是不同的阅读路径;我更常从组件结构和行为读起,因此选择把视觉细节放到样式层。

BEM 类名保留元素的职责

resource-card__title 能告诉维护者这是资料卡片的标题,resource-card__actions 表示操作区。相比 flex items-center gap-2,它们提供的是业务结构信息。

BEM 的 Block、Element、Modifier 分别表达模块、内部元素和变体。组件状态可以写在根节点上,受影响的元素在自己的样式块里响应它,避免模板中多个子元素重复判断同一个状态。

CSS Modules 已经能隔离类名,显式 Block 前缀可以按项目约定简化;Element 和 Modifier 所表达的职责与状态仍然有用。这里并没有要求 CSS Modules 也必须照搬 BEM 的拼写。

给解耦扩展留出可识别的入口

做浏览器扩展、用户脚本,或者给另一个项目增加深色模式时,扩展代码往往无法调用对方的 Vue 组件。它需要识别页面上的广告区、标题、工具栏或侧栏,再通过 CSS 或 JS 调整。

这时,一个职责清楚的 .resource-card__actions,比一组恰好能拼出相同布局的工具类更容易辨认。工具类也能被选择,但 flex、gap-2 可能在几十个无关节点上出现,用它们判断元素身份通常更脆弱。

语义 class 也不自动构成公共 API。CSS Modules 的 hash、构建压缩或网站改版都可能改变它;自有项目需要长期给外部使用时,应明确提供稳定的 class、data-* 属性或其他扩展接口。组件内部 JS 优先使用 ref,自动化测试优先考虑角色、可访问名称或明确的测试标识,不为了「留入口」就到处查询 DOM。

也可以采用工具类加少量语义 hook 的折中方式。我选择 @apply,还包含前两项阅读偏好,单独增加几个 hook 并不能完全解决。

@apply 的体积成本,压缩后还剩多少

@apply 会把工具类展开到当前选择器。两个不同组件都写一遍 @apply flex items-center,产物中可能出现两份声明;直接使用相同工具类时,对应规则可以由它们共享。

但同一个组件渲染 50 次,并不会让它的 @apply 样式也编译 50 次。与此同时,工具类字符串可能在 HTML 中重复,客户端应用的模板代码又可能存放在 JS 中。因此,仅比较两份 CSS 文件不够。

我做了一个可复跑的静态实验,固定内容和视觉值,比较三种写法:纯工具类、工具类加语义 hook、语义类加 @apply。为了观察共享效应,实验中不同组件使用不同语义类名,视觉声明刻意保持相同;真实项目遇到完全相同的控件,应该先考虑复用组件。下面先看纯工具类与 @apply,数值是 HTML 与 CSS 分别压缩后相加的字节数。

场景 直接工具类,Brotli @apply,Brotli
1 种组件,出现 50 次 772 B 561 B
50 种组件,各出现 1 次 772 B 1,195 B
50 种组件,各出现 10 次 1,457 B 1,943 B

三组输入说明了两件事:重复文本确实容易压缩;但压缩后的差距仍取决于组件种类、重复次数、类名长度、样式共享程度和产物组织,甚至可能改变大小关系。

看第二组的 CSS,更容易看到 @apply 本身的代价:

第二组的 CSS 直接工具类 @apply
原始大小 799 B 21,737 B
Brotli 后 321 B 574 B

压缩消化了大量重复声明,但该组的 @apply HTML 更短,不同组件选择器下的重复声明又增加了 CSS,最后 Brotli 总量仍然较大。加语义 hook 的工具类方案总量为 1,131 B,处于二者之间。第二组的 gzip 总量分别为 1,170 B 和 2,070 B,Zstd 总量分别为 1,080 B 和 1,498 B;各项输入、分项字节数和参数都保存在完整测量报告中。

实验使用 Node.js 26.12.0,CSS 统一经过 esbuild 0.28.2 minify,HTML 直接生成紧凑文本;gzip 等级 9、Brotli 质量 11、Zstd 等级 19,固定 UnoCSS 和 Sass 版本。这些是实验参数,不代表 CDN 的默认级别。三种写法在 375px、1280px 下进行了共 18 组浏览器检查,布局、检查项对应的 computed style 和视口截图一致,见等价检查记录。这里没有统计 Vue 客户端 JS、scoped 属性、框架运行时、HTTP 头和热缓存,也没有测压缩耗时,不能把它当成整个 Nuxt 应用的性能排名。

gzip 和 Brotli 是常见的 HTTP 内容编码,Zstd 也已进入实际的浏览器与 CDN 链路。例如 Chrome 的压缩文档列出了 gzip, br, zstd 协商;Cloudflare 压缩规则支持配置 Zstd,并回退到其他编码。具体站点是否启用,仍要检查请求的 Accept-Encoding 和响应的 Content-Encoding,不能仅因为用了 CDN 就默认一定压缩。

压缩主要减少传输字节,不会把解压后的重复声明从 CSSOM 中消除;HTML、CSS、JS 通常还是各自压缩、各自缓存。共享字典属于额外机制,也不能假定每个站点已经启用。

所以我接受的取舍是:用可测量的产物成本,换取更符合自己维护习惯的源码组织。实际项目仍然要测构建产物和缓存场景,不能把「压缩后应该差不多」当成性能结论。

Atomic CSS 的争论早于 Tailwind;UnoCSS 更关注按需生成、规则、变体和预设的组合,具体样式组织仍由项目选择。完整内容见原子 CSS 的历史争论弹窗。

总结

语义类名加 @apply 更符合我从组件结构和行为读起的习惯。模板保留元素职责,视觉细节集中在样式层,外部扩展也更容易找到语义入口。

代价是不同选择器下可能出现重复声明,并增加构建上的约定。压缩可以减少传输字节,却不会消除展开后的 CSS。实际项目仍要测量自己的产物与缓存场景,再判断维护便利是否值得这项成本。

下一篇:样式约定、Lint 与等价性验证。

原子 CSS 的历史争论

原子 CSS 的这场争论,早于 Tailwind

早期 ACSS 通常指 Yahoo 实践中的 Atomic CSS,以及后来配套的 Atomizer 工具。ACSS 这个缩写也被其他项目使用,这里讨论的是 acss.io 如今指向的 Atomizer。

Thierry Koblentz 在 2013 年的《Challenging CSS Best Practices》里,已经讨论了用表现型 class 驱动样式,以及这与传统关注点分离观念之间的冲突。原文编辑说明就提到 Yahoo 的实际使用。Atomizer 则能根据 D(f)、Fz(1.5rem) 这样的标记生成静态 CSS;它也不是先准备好所有可能样式,再要求页面全量加载。

因此,把它概括成「当年没人用」不准确。能确认的是它有真实的大站实践,语义类名、HTML 可读性、样式依赖方向这些争议也确实很早就存在;仅凭今天的印象,无法给出可靠的历史采用率。

Tailwind 的官方早期发行记录将首个 alpha 追溯到 2017 年 11 月 1 日。它让工具类、设计约束、状态变体和工程接入形成了一套更完整的开发体验。至于流行程度的变化,不能只归因于某个语法更好看。

到 Anthony Fu 在 2021 年《Reimagine Atomic CSS》中提出 UnoCSS 时,他重点思考的是生成引擎:按需生成、规则匹配、变体和预设怎样组合,如何避免把某一套工具类永久绑定到引擎内部。这条思路对我很有吸引力,因为我需要的是能嵌入既有样式组织方式的工具。