我所理解的 UnoCSS 最佳实践
我想用原子 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
同一张资料卡片,可以直接写工具类,也可以让工具类留在样式层。下图只比较源码组织,展示的是同一个标题元素。
我偏向右边,原因有三个。
读模板时,结构和行为更容易找
<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 的历史争论弹窗。
HTML、SCSS 与 UnoCSS 分别负责什么
这次项目最初试过 Pug 和 Stylus,目的是少写结束标签和大括号。但实际编辑体验改变了选择:在本机 WebStorm 环境中,HTML 配合 Stylus 时,完整类名能跳到样式,&__header 这类拼接后的 BEM 类名跳转不完整;改成 HTML + SCSS 后恢复了预期。
这是一条本机工具链的观察,不能外推为所有 WebStorm 版本和插件组合的结论。对我而言,少几行语法的收益,抵不过日常导航和维护成本。
SCSS 的另一个用途是让差异样式靠近所属元素:
.resource-card {
$root: &;
@apply grid min-w-0 gap-12px p-24px rounded-16px;
&__title {
@apply truncate text-20px text-$app-color-accent;
#{$root}--is-saved & {
@apply underline underline-offset-4px;
}
}
}
$root: & 保存根选择器,状态仍由根节点表达,标题的变化就在标题附近。SCSS、Less、Stylus 和 PostCSS 在这里的具体差异,另见《组件状态和就近差异样式》。
最小配置保持明确
// uno.config.ts
import { defineConfig, presetWind3, transformerDirectives } from 'unocss';
export default defineConfig({
content: { pipeline: false },
presets: [presetWind3()],
transformers: [transformerDirectives()],
});
content.pipeline: false 只适用于这里的 apply-only 约定:模板不使用工具类时,不再从构建管线里的模板和字符串额外提取独立工具类。它不等于关闭所有内容来源,也不等于关闭 directives;如果又要在模板中使用图标类、shortcut 或其他工具类,就应恢复对应提取配置。UnoCSS 的提取文档区分了 pipeline、文件系统和内联内容来源。
配置文件也不会自己接入构建。普通 Vue + Vite 需要注册 unocss/vite,并按集成文档引入 virtual:uno.css;Nuxt 使用 @unocss/nuxt 模块。SFC 的预处理、scoped 转换、UnoCSS 和 PostCSS 之间的具体顺序,需要看实际集成,不能把配置里的书写顺序当成整条流水线。
UnoCSS 默认不附带通用 reset。已有项目如果已经处理基础样式、按钮和组件库主题,我会复用现有 reset,避免再加一份改变全站默认样式。
@apply 怎么写,才能继续读得懂
我会把普通视觉声明统一放进 @apply,让同一块样式只有一种主要表达方式。常用属性用工具类,复杂值和需要保留精确属性语义的地方使用任意属性。变量定义、描述符、运行时量测值则各自保留合适的语法。
固定设计尺寸显式写单位
整理既有的 padding: 16px 时,我会先写成 p-16px,保持单位和值。不能只因默认根字号下数值相同,就换成基于 rem 刻度的写法;根字号改变后,两者的缩放行为不同。
已有设计系统要求使用间距刻度或 rem 时,继续遵循它。引入 UnoCSS 和调整尺寸体系是两项决策,拆开验证更容易发现布局变化来自哪里。
简单变量用 $,变量名写清用途
.resource-card {
@apply text-$app-color-text-primary bg-$app-color-surface;
}
这里的 $ 是 UnoCSS 预设识别的简写。编译后仍然是 color: var(--app-color-text-primary),它和 SCSS 的 $root、$theme-tokens 变量属于不同机制。
--app-color-text-primary 比 --ink 长,但调用点能直接知道它的命名空间、类别和用途。组件公开变量也应该有自己的命名空间,例如 --common-button-size;第三方的 --el-* 则保留原有契约。
变量定义处的注释解释用途和边界。尤其是阴影,必须说明存的是「颜色」还是「整条阴影」,否则 shadow-$app-color-shadow 看着简洁,实际可能把一个颜色当成完整阴影值。
复合值和有歧义的值保留 var()
.resource-card {
--resource-card-padding: 16px;
@apply p-$resource-card-padding text-$app-color-text-primary;
@apply [border:1px_solid_var(--app-color-border)];
@apply [box-shadow:0_5px_22px_var(--app-color-shadow)];
}
上面的 --resource-card-padding 是定义,p-$resource-card-padding 是完整单变量引用,后两项则是复合值。任意值里的空格用 _ 表达,字面下划线需要按具体解析规则处理。
有 fallback 的 var(--x, 16px)、多层背景、CSS 字符串和需要类型提示的值,也不做机械替换。例如 text-[length:var(--app-title-size)] 已经明确表示字号,不能只因为里面有一个变量,就换成可能按颜色解释的 text-$app-title-size。
简写不能改变属性含义
| 原声明 | 不能直接视作等价的工具类 | 需要保留的含义 |
|---|---|---|
background: var(--surface) |
bg-$surface |
整组背景子属性的重置 |
border: 0 |
border-0 |
边框 shorthand 的重置范围 |
transform: rotate(180deg) |
rotate-180 |
整体覆盖与变换组合的差异 |
需要精确保留时,可以写 [background:var(--surface)]、[border:0]、[transform:rotate(180deg)]。工具类短了多少不是等价性的依据,最终声明才是。
阴影和动画同样可以放进 @apply,但要核对变量存储的内容、时间函数和生成的 keyframes。@font-face、@property 中的描述符、CSS 自定义属性定义、SCSS 变量仍使用原语法。运行时测出的浮层坐标可以保留动态 style,不为统一静态语法改变状态流。
SCSS 中的 @apply 默认不加整组引号。其他预处理器如果会提前解析小数、括号或 calc(),可以使用 directives 支持的整组引号兼容写法,见官方说明。在本文验证的 Sass 链路中,前置 !hidden 会被 Sass 拒绝,后置 hidden! 可以通过;不要直接复制另一种预处理器里的写法。
响应式与状态差异放在所属选择器里
我更常问「这个元素在什么条件下变成什么样」,所以基础样式、状态和响应式差异都围绕元素组织:
.resource-card {
@apply grid p-24px;
@apply lt-lg:(p-20px)
lt-sm:(p-16px);
&__actions {
@apply flex gap-8px opacity-70;
@apply media-[(hover:none)]:(opacity-100);
}
}
相同条件使用分组,每组单独一行,不插入额外空行。同一选择器的条件组放进同一条多行 @apply,基础声明另写。完整的条件链放在组前,例如 md:lt-lg:(...),减少组内再次嵌套条件的阅读成本。
UnoCSS 66.11.0 的 directives 内部会调用 expandVariantGroup,这里不需要为 @apply 再安装一份 variant-group transformer;模板 class 的分组属于另一个入口,需要对应配置。
Wind3 的默认断点是 sm 640px、md 768px、lg 1024px、xl 1280px、2xl 1536px。普通前缀从相应宽度开始生效,lt- 表达小于该断点的范围。默认值已经够用时,我会直接复用,避免维护一组仅有几十像素差异的新全局断点。自定义 breakpoints 会覆盖默认集合,需要自定义时应提供完整设计。
不过,960px 改成 1024px 属于布局决策,不能当成格式化;lt-sm 当前生成的 max-width: 639.9px 也和 max-width: 640px 有边界差异。Header、主栏和侧栏的断点要一起检查。
media-[(hover:none)] 表示主要输入设备无法方便地悬停,和小屏宽度、元素的 hover: 伪类都不同。减少动画可以用 motion-reduce:。定义变量的媒体块、特殊业务宽度、尚未验证的复杂条件,可以继续使用就近的原生 @media,不追求源码里一个媒体查询都不剩。
重叠条件要检查输出顺序
一次整理响应式样式时,我把条件组拆成了多条 @apply。在实际 Nuxt 构建链中,小屏标题区域的宽度由 331px 变成了 305px;按媒体块分别比较属性却没有发现差异。问题出在重叠条件间的层叠位置,单个块正确,并不能保证组合结果正确。
下图用简化 CSS 展示这个风险,数值用于说明层叠关系,不是当时页面的源码。
后来把同一选择器的条件放进一条指令,一起交给 directives 展开和排序,实际布局恢复了预期。这个组织方式也让同条件的属性更容易集中阅读。
示例编译脚本中,两个简单的 width 条件无论拆开还是合并,都产生了相同顺序,见编译探针。这个最小样例没有复现实际应用中的逆序,根因仍需结合完整集成分析;源码中存在异步处理,也不足以单独证明原因。迁移是否等价,最终要看消费者构建后的 CSS 和运行态。
这次整理后,项目的 72 组布局检查恢复一致,34 张对照截图逐字节相同。后续迁移仍然要覆盖重叠条件和边界宽度,这些检查不能被一次格式规则替代。
主题按变量维护,全局样式按所有者划分
样式写得紧凑以后,变量和选择器的组织会更影响可读性。工具类解决表达效率,主题数据和组件边界仍然需要自己设计。
同一个变量的深浅值就近放
先写一整段浅色变量,再写一整段深色变量,适合按主题整体检查;日常查看某个变量时,则需要来回搜索。我更倾向于以变量为索引,用 SCSS map 保存一对深浅值:
@use 'sass:list';
// 配对顺序:浅色、深色。
$theme-tokens: (
/* 标题和主要文字的前景色。 */ 'app-color-text-primary': (#242833, #e7e9ed),
/* 只存阴影颜色,不包含偏移和模糊参数。 */ 'app-color-shadow': (#14233312, #00000038),
);
:root {
@each $name, $values in $theme-tokens {
--#{$name}: #{list.nth($values, 1)};
}
}
:root[data-theme='dark'] {
@each $name, $values in $theme-tokens {
--#{$name}: #{list.nth($values, 2)};
}
}
这里减少的是源码维护成本。浏览器收到的仍是两套标准 CSS 变量定义,运行时切换主题的机制也没有改变。Sass map只是组织数据的手段;更喜欢显式键名时,也可以使用包含 light / dark 的内层 map。
颜色、阴影色、背景、字号和层级按语义命名。组件库适配集中映射到应用 token,避免再维护一套互不相干的深色配色。纯固定值如果没有主题或跨组件语义,不必为了缩短任意值语法就强行抽成全局变量。
共享控件优先组件化
多个按钮都依赖 .result-action 一类全局 class 时,按钮的 hover、disabled、尺寸和焦点样式实际上已经形成了一份控件实现。它缺的只是明确的组件入口。
我会把这类样式移进按钮组件,让业务调用点保留事件和文案;稳定的视觉角色用 variant,反馈色用 tone,父级调整尺寸时通过公开 prop 或 CSS 变量。
<common-button variant="action" :aria-pressed="saved" @click="toggleSaved">
<common-icon name="bookmark" />
</common-button>
封装要保留原生能力:普通按钮默认 type="button",提交按钮显式声明 submit;链接保留 href、target、rel;ARIA、disabled、form、事件和 tooltip 应到达真实控件。原来通过 ref 调用 .focus() 的地方,也要验证组件的焦点入口。Vue 的透传属性是其中一部分,不能用「包装后能显示」代替交互验收。
真正跨边界的自有 class 标明 global-
reset、主题变量、第三方样式适配、程序生成的 DOM,以及明确公开给父级的样式 hook,可能确实需要跨组件边界。这些自有 class 可以使用 global- 前缀,让维护者在调用点就意识到影响范围。
组件内部的 BEM class 不需要全部加前缀,第三方的 el-*、os-* 等固定 class 也不能改名。给 .result-action 改成 .global-result-action 只能标记边界,无法回答它为什么还没有封装成组件。
这也对应前面讨论的扩展需求:内部元素使用职责清楚的语义类名;需要长期供外部使用的入口,再明确其公开性和兼容约束。
把约定交给 Lint,把等价性交给构建和浏览器
格式偏好只写在文档里,很容易在后续开发中逐渐偏离。我把可以静态识别的部分放进自己维护的 @sheng/stylelint-plugin,按项目选择启用:
| 规则 | 检查职责 |
|---|---|
unocss-prefer-apply |
普通声明进入 apply,保留定义类语法 |
unocss-prefer-css-var-shorthand |
已知无歧义的完整变量使用 $ |
unocss-prefer-responsive-variants |
已知断点与明确媒体偏好使用变体 |
unocss-prefer-variant-groups |
同条件分组、每组一行、条件共用一条指令 |
prefer-local-media-query |
原生媒体差异靠近已有选择器 |
prefer-global-class-prefix |
自有跨边界 class 显式标记 |
表中省略了实际规则名的 sheng/ 前缀。这些规则有不同适用范围,unocss-* 不应该加到纯 SCSS 或 Tailwind 项目上;媒体就近规则则可以独立使用。对于未知预设、特殊宽度、变量定义、复合值和第三方 class,规则需要有保守边界和明确例外。
这些 Stylelint 规则不执行整份 UnoCSS 配置,无法保证某个工具类一定生成了预期属性,也不能决定一个全局样式是否应该封装成组件。涉及移动声明、修改选择器或改变条件的检查,我不会做自动修复。
模板里混进工具类,也要能检查出来
样式已经统一到 apply,模板里却仍然能写 class="flex p-4",这套约定就只做了一半。我先核对了现成规则,发现它们各有职责,不能直接拼出这里需要的限制:
| 候选 | 已有能力 | 这里的差距 |
|---|---|---|
vue/no-restricted-class |
根据固定字符串或模式禁止类名 | 需要维护名单,且部分表达式不在提取范围内 |
| UnoCSS ESLint 集成 | 排序、blocklist 等约束 | 没有「模板禁用当前引擎识别的全部工具类,apply 继续可用」的开关 |
| better-unocss 的 no-restricted-classes | 根据配置模式限制类名 | 同样需要描述禁用名单,不能直接代表项目真实的全部工具类 |
以 eslint-plugin-vue 10.11.1、ESLint 10.13.0 为例,给 vue/no-restricted-class 配置 flex / grid 后,静态 class 能命中,下面这个三元表达式没有诊断:
<div :class="compact ? 'flex' : 'grid'" />
这说明固定名单和表达式提取是两个不同的问题。该次探针保留了 16 种输入与真实诊断,包括数组、对象、逻辑分支和字面量拼接;版本升级后应重新验证,不能把这次结果推广成所有版本的结论。
UnoCSS 自己的 blocklist 也不适合拿来禁止模板 class。我在 66.11.0 上把 flex 加进真实配置的 blocklist,.resource-card { @apply flex p-4; } 最终只剩 padding: 1rem,display: flex 也被去掉了。它会影响工具样式的生成,无法单独约束工具类写在哪里。
因此我在 @sheng/eslint-plugin 增加了 ui-no-unocss-class-in-template,放进单独选择的 unocss-apply 分组。规则加载项目实际的 uno.config.ts,通过引擎判断 token;预设、自定义规则、shortcut、任意值和变体都按同一份配置识别。它不扫描 style 中的 @apply。
rules: {
'@sheng/ui-no-unocss-class-in-template': ['error', {
configPath: './uno.config.ts',
checkDynamicClass: true,
allowClasses: ['vendor-container'],
allowPatterns: ['^vendor-widget-'],
}],
}
示例里的例外要按项目实际需要填写。普通 BEM 类与没有注册成工具类的第三方名称自然通过;当第三方约定名恰好与 UnoCSS 工具类重名时,才精确放行。正则适合限定第三方前缀,不应该写成全部匹配,更不应该跳过整个第三方组件。
规则检查 Vue template 和 JSX / TSX,递归读取对象键、数组、三元及逻辑分支,也能计算无副作用的字面量拼接。它不执行函数或读取运行时变量。默认模式放过未知 class 表达式;上面的严格模式会把它们也报告出来。自己的 BEM 变体可以写成明确的对象键与条件;第三方动态透传确实无法检查时,再局部 disable 并写清原因。Vue template 内的 HTML disable 注释需要框架 ESLint preset 的 processor 和 vue/comment-directive,只装 parser 不会生效。
严格模式仍不等于完整的数据流证明。未知的 v-bind="attrs"、JSX props spread、动态属性名、attributify 与运行时 DOM 写入都需要另外审查;纯 group / peer 标记也未必单独生成 CSS。实际配置的 blocklist 继续生效,规则不会为了识别而改变构建配置。
这里还有一个实现边界:ESLint visitor 是同步接口,UnoCSS 配置加载和 token 解析是异步接口。我沿用官方 ESLint 集成的思路,用 synckit 的持久 Node worker 连接两者,按文件批量传入去重后的 class 值,并缓存 generator。首次加载有额外成本;配置改动后需要重启 ESLint 服务。配置错误或 worker 超时会产生诊断,不会当作检查成功。
这条规则目前使用 UnoCSS 66.11.0 引擎,与应用的版本保持一致;升级时一起验证。测试覆盖自定义规则、shortcut、不同配置之间的隔离、第三方例外、严格模式和关闭规则后的行为。实际 Nuxt 配置也补了正反例,确认普通 BEM 与 apply 通过,条件工具类和未知动态 class 被阻断。规则没有自动修复,语义类名和层叠仍由开发者决定。
完整实现与接入示例见完整源码与接入方法弹窗。
静态护栏之外的验证
真正落地还要检查三件事:
- 真实配置覆盖源码。Vue SFC 和独立 SCSS 都进入命令,parser 能正确分发;用应失败和应通过的样例,确认命中的 ruleId 与位置。
- 生产编译保持语义。比较完整选择器、条件、属性、重置范围、important 和层叠顺序;检查最终产物没有残留
@apply,也没有悄悄漏掉未知工具类。 - 浏览器覆盖有差异的状态。检查新旧断点两侧及恰好断点处、深浅主题、无悬停设备、展开弹层、焦点和原生提交,记录 computed style 或布局尺寸。
源码包保留了一张资料卡片、主题 map 和体积实验。静态预览加载预编译 CSS,支持切换深浅主题和收藏状态;改 SCSS 后在本地重新构建。接入现有工程时,可以把下面的路径交给 Coding Agent:
读取 /files/my-unocss-best-practices/kits/unocss-style-practice/ 下的 README.md、MANIFEST.json 和 FILES.json。
按索引读取源码,保留项目现有框架接入,把 copy 中的样式约定迁移到合适的组件。
依据 AGENT_PROMPT.md 验证主题、媒体条件、构建产物和交互;不要直接覆盖现有构建配置。
在源码包目录执行 pnpm install、pnpm build、pnpm measure,可以重新生成示例 CSS 与测量报告。实验里的组件数量、重复次数和样式组合都可以调整;真正要决定产物成本时,用自己的页面和构建方式测量更有意义。
总结
我选择语义类名加 @apply,是因为平时更常从组件结构和行为读起,希望视觉细节留在样式层。BEM、SCSS、CSS 变量和组件接口分别帮助维护元素职责、局部差异、运行时主题与共享控件;UnoCSS 提供简洁的样式表达,不替代这些归属判断。
这套组织也有成本。@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 时,他重点思考的是生成引擎:按需生成、规则匹配、变体和预设怎样组合,如何避免把某一套工具类永久绑定到引擎内部。这条思路对我很有吸引力,因为我需要的是能嵌入既有样式组织方式的工具。
模板工具类规则:Node 源码与实际配置测试
完整实现和同源测试放在下面的源码包里。这是只读源码工作区,实际诊断由 Node 中的测试和 ESLint 命令生成;运行时依赖 Node worker。
读取 /files/my-unocss-best-practices/kits/unocss-style-practice/ 下的 README.md、MANIFEST.json、FILES.json。
按 AGENT_PROMPT.md 接入 copy/eslint 中的规则、测试和实际 UnoCSS 配置;第三方例外按真实重名情况填写。
先运行正反例与项目检查,再决定是否开启严格动态检查,不自动搬动模板 class。