我所理解的 UnoCSS 最佳实践(2):样式约定、Lint 与等价性验证
- 为什么选择语义类名与 @apply
- 样式约定、Lint 与等价性验证(当前)
下面的配置与例子沿用 HTML + scoped SCSS + BEM + UnoCSS @apply。这种组织方式把结构和视觉细节分别留在模板与样式层,选型理由和体积实验见第一篇。
验证基础是 UnoCSS 66.11.0、Wind3 与 Sass Embedded 1.105.1。其他预设或版本需要重新核对生成结果;动态值、响应式和主题还要回到真实构建与浏览器检查。
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;混合使用模板工具类时,恢复所需提取配置。
普通 Vue + Vite 注册 UnoCSS 与 Vue 插件时,保留默认 directives 阶段,并将 UnoCSS 放在 Vue 前:
// vite.config.ts
import vue from '@vitejs/plugin-vue';
import UnoCSS from 'unocss/vite';
import { defineConfig } from 'vite';
export default defineConfig({
plugins: [UnoCSS(), vue()],
});
入口按集成文档引入 virtual:uno.css,Nuxt 则使用 @unocss/nuxt 模块并核对实际框架配置。
引擎、预设、源码转换与构建集成各自负责什么,以及普通工具类为什么常在两种插件顺序下都能工作,见《Vite 插件顺序:从 CSS v-bind() 失效说起》。
UnoCSS 默认不附带通用 reset。已有项目如果已经处理基础样式、按钮和组件库主题,我会复用现有 reset,避免再加一份改变全站默认样式。
@apply 怎么写,才能继续读得懂
我会把静态视觉声明统一放进 @apply,让同一块样式只有一种主要表达方式。常用属性用工具类,复杂值和需要保留精确属性语义的地方使用任意属性。变量定义、描述符和运行时量测值则各自保留合适的语法;Vue 动态值直接放进 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! 可以通过;不要直接复制另一种预处理器里的写法。
动态视觉值可以直接写进 apply
固定布局可以在构建时确定,用户选择的字体、颜色却可能继续变化。工具类完整写在源码里,动态属性值由 Vue SFC 的 CSS v-bind() 提供:
<script setup lang="ts">
const props = defineProps<{ textFamily: string; textColor: string }>();
</script>
<template>
<p class="font-preview">春江潮水连海平<br>The quick brown fox · 0123456789</p>
</template>
<style scoped lang="scss">
.font-preview
{
@apply px-12px py-8px rounded-6px text-20px leading-32px [font-family:v-bind('props.textFamily')] [color:v-bind('props.textColor')];
}
</style>
在前面的普通 Vue + Vite 配置中,可以直接使用这种写法,保留默认 directives 阶段和 UnoCSS 在前的顺序。样式只在复用或公开契约需要时定义中转变量;复杂绑定表达式使用 Vue 支持的引号形式。
扫描与声明转换的区别、两种顺序下绑定函数如何被处理,以及配图与实验记录,见编译过程分析。
这里改善的是样式归属,不是性能优化。不采用统一 apply 风格时,直接写 font-family: v-bind(textFamily) 也可以;中转 CSS 变量只在确实需要复用时定义。CSS v-bind() 底层仍有内联 CSS 变量和 JS 更新;原先写在元素上的 font-family 改成选择器声明后,优先级也会变化,迁移时仍要核对其他样式的竞争关系。
我不会把全部 :style 都改成这种写法。v-for 的行变量和 v-slot 的插槽变量不属于组件级 setup 状态;浮层量测坐标、SDK 样式对象、公开自定义属性契约也有各自的写入职责。列表项本身值得独立成组件时,可以利用每个实例的 CSS 变量;为了删除一个绑定而机械拆组件,反而增加阅读成本。Teleport 和 Transition 中的关键几何值,还要考虑变量注入与节点创建的时机。
SCSS 导入与 Tailwind CSS v4 有不同的构建前提,阶段调整按预处理器与插件顺序的分析核对,不将普通插件的数组调整直接换成 directives pre。
响应式与状态差异放在所属选择器里
我更常问「这个元素在什么条件下变成什么样」,所以基础样式、状态和响应式差异都围绕元素组织:
.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 配置,无法保证某个工具类一定生成了预期属性,也不能决定一个全局样式是否应该封装成组件。涉及移动声明、修改选择器或改变条件的检查,我不会做自动修复。
其中 unocss-prefer-apply 保留真正含有 Vue v-bind() 函数的声明,允许固定工具样式与动态供值并存。字符串或注释中碰巧出现 v-bind(...),不会成为普通声明的豁免理由。
模板里混进工具类,也要能检查出来
样式已经统一到 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 被阻断。规则没有自动修复,语义类名和层叠仍由开发者决定。
完整实现与接入示例见完整源码与接入方法弹窗。
动态 style 只提示明确的迁移候选
样式层已经能消费组件变量后,我又加了 @sheng/vue-prefer-css-v-bind,以 warning 提醒仍留在模板里的简单动态视觉绑定。例如同组件中的 :style="{ fontFamily: textFamily }",可以考虑迁到 scoped style;固定的像素值则继续由 vue-no-static-px-inline-style 检查。
rules: {
'@sheng/vue-prefer-css-v-bind': 'warn',
}
这条规则只处理单个 script setup、本地 scoped style、普通 HTML 元素和明确的视觉属性。对象中混有尺寸、定位、自定义属性、spread,或者位于列表、插槽、Teleport、Transition 范围时整条保留。明确的 const 对象与 Vue computed 对象也可检查;未知工厂、多语句 getter、函数调用和动态键不推断。
插件顺序可另用 @sheng/vue-vite-unocss-plugin-order warning 保护项目默认。它检查明确的官方 import 与静态调用,遇到外部数组、spread、包装器时不猜测,也不自动重排。
// 其余配置已注册 @sheng 插件与 TS parser。
{
files: ['**/vite*.config.ts'],
rules: { '@sheng/vue-vite-unocss-plugin-order': 'warn' },
}
这条提示不证明完整构建链正确,仍需检查产物和状态更新。具体的阶段追踪、残留语法与浏览器验证方法,见插件顺序的验证。
规则不会执行 computed,也不知道每个 SDK 的契约。项目可以精确忽略属性或样式对象变量,必要时带原因局部关闭。它没有 autofix:维护者仍需选择语义 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 后,变量、动态值、响应式和主题仍要遵循各自的语义。固定尺寸显式保留单位,复合变量值与重置声明按最终属性判断;共享控件通过组件接口维护,确需跨边界的自有 class 再标明 global-。
Lint 能维持可静态识别的约定,工具类名称相近、检查通过或单个媒体块属性一致,都不足以证明迁移等价。最终仍要比较完整构建产物,在真实页面检查属性语义、层叠、布局、主题与交互;未知预设、动态表达式和第三方契约需要人工确认。
上一篇:为什么选择语义类名与 @apply。
模板样式规则:Node 源码与实际配置测试
模板工具类、CSS v-bind() 迁移提示和 Vite 插件顺序规则的完整实现与同源测试放在下面的源码包里。这是只读源码工作区,实际诊断由 Node 中的测试和 ESLint 命令生成;工具类识别依赖 Node worker,后两条规则分别只分析当前 SFC 和配置文件。
读取 /files/my-unocss-best-practices/kits/unocss-style-practice/ 下的 README.md、MANIFEST.json、FILES.json。
按 AGENT_PROMPT.md 接入 copy/eslint 中的规则、测试和实际 UnoCSS 配置;第三方例外按真实重名情况填写。
先运行正反例与项目检查,再决定是否开启严格动态检查,不自动搬动模板 class。