
上个月帮一个团队看他们的构建产物dist/assets里躺着一份 380KB 的 CSSgzip 之后还有 60 多KB而他们整个项目的组件加起来不到 40 个。我随手 grep 了一下发现里面大量是从来没被用过的工具类和框架自带的全量样式。这种情况在前端开发里太常见了样式表是先写后删不敢删的资产越滚越大最后谁也不敢动。UnoCSS 之所以这两年被反复提起核心原因不是它又做了一个原子化 CSS 框架而是它把思路换了个方向——不生成样式表只生成你真正写过的那些类名对应的规则按需、即时、由引擎在构建时算出来。这篇文章我打算按一个真实落地的顺序讲先把它和常见原子化方案的边界说清楚再走一遍 Vite 环境的接入流程然后是 preset 选型、自定义规则与 shortcuts、transformers 开关最后重点讲那个几乎每个团队都会撞上的问题——为什么开发环境好好的打包之后样式就少了一半。适合已经会写 Vue/React、正在纠结要不要换一套样式方案的人也适合已经在用 UnoCSS 但配置停留在抄了一份就又没动过的人。1. UnoCSS 到底是什么先把引擎和框架这两个词分清楚绝大多数人对 UnoCSS 的第一印象是Tailwind 的替代品这个印象会让你在配置和排错时走很多弯路。它更准确的定位是一个原子化 CSS 引擎本身不提供任何具体的类名类名来自你挂载的 preset。你挂presetWind3它就长得像 Tailwind你挂presetMini它只有一套精简的核心规则你甚至可以只挂自己写的 rules让它变成一个完全专属的工具类系统。理解这一点之后很多为什么我的类名不生效的问题就迎刃而解了——大概率是你根本没挂那个 preset。1.1 手写 CSS 与类名堆叠各自在为什么买单先说手写 CSS 的成本这个成本是隐性的不会在项目初期暴露。你为每个模块起名字.user-card-header-title、.user-card-header-title--active命名本身就是脑力消耗半年后重构你不敢删任何一个类因为不知道哪个页面还在引用设计稿改了一版间距你需要在十几个文件里找出所有padding做替换。更麻烦的是媒体查询同一个组件在三种断点下的样式被拆到文件的不同角落阅读时要来回跳。类名堆叠方案解决的是上面这些问题但引入了新的成本。最直接的是 HTML 变长、可读性下降classflex items-center justify-between px-4 py-2 rounded-lg bg-white shadow-sm hover:shadow-md transition-shadow这样的字符串一屏放不下三行。第二个成本是类名和样式的映射关系变成了一种需要记忆的知识新人上手需要时间。第三个成本是构建产物的体积——如果你用的方案是全量生成那么无论你用不用那几千条规则都会进 CSS 文件这也是我开头提到那份 380KB 样式的来源。1.2 按需生成UnoCSS 真正省掉的是提前定义很多人以为按需生成省的是体积其实体积只是结果真正被省掉的是提前定义这个动作。传统流程里你必须先有一份完整的工具类清单不管是框架给的还是自己写的再去引用UnoCSS 的流程反过来你先写引擎在构建时扫描源码发现mt-13这个类名没有现成规则就用动态规则把它算出来margin-top: 3.25rem。这个算的过程是纯字符串匹配加函数调用几乎不消耗构建时间。这带来的直接好处是数值不再受限。Tailwind 默认的间距刻度是 0、1、2、3、4、5、6、8、10、12……你写mt-13是不生效的除非改配置。UnoCSS 的动态规则通常直接接收任意数字mt-13、mt-17、mt-23都能算出来。我在做后台管理项目时特别依赖这一点因为设计稿经常给出 13px、18px、22px 这种非标准间距以前要么凑刻度要么写行内样式现在直接写类名就行。另一个容易被忽略的好处是构建速度。因为不需要先生成一份巨大的 CSS 再交给 PostCSS 处理冷启动和热更新都很快。我测过的项目里改动一个.vue文件的热更新基本在几十毫秒级别切换页面时不会有明显的样式闪烁。1.3 三个必须提前接受的事实第一UnoCSS 默认不带 reset。presetWind3会带一份类似 Tailwind Preflight 的样式但如果你用的是presetMini或者自己拼的 preset浏览器默认的margin、list-style、box-sizing都会原样保留很多人第一次接入后觉得页面全乱了就是这个原因。解决办法是安装unocss/reset在入口处按顺序引入。第二类名的语义表达力是下降的。.card-title一眼能看懂用途text-lg font-semibold text-gray-900需要你在脑子里拼一下。这个代价是必须付的缓解办法是用 shortcuts 把高频组合封装成语义化类名后面第 4 节会详细讲。第三preset 生态更新比较快命名会有变化。比如早期大家用的presetUno在新版本里已经逐步让位给presetWind3、presetWind4这类命名。所以配置文件的版本固定很重要package.json里最好用精确版本或者锁定小版本号不要用^避免某次npm i之后整个项目的样式规则悄悄变了一套。2. 接入顺序比配置内容更容易出错Vite 环境的最小可跑路径我见过不少教程把 UnoCSS 的配置写得特别完整一上来就是几十行 presets、rules、shortcuts新人照着抄完发现样式不对又找不到问题在哪。更靠谱的做法是先跑到最小可运行状态看到样式确实生效了再往上加东西。这一节按这个顺序走。2.1 安装与插件挂载基础依赖只有两个引擎本体和 Vite 插件。如果你要用图标 preset再额外装图标数据包。pnpm add -D unocss unocss/vite # 需要图标时按需安装对应的图标集 pnpm add -D iconify-json/ep iconify-json/carbon然后挂到 Vite 配置里注意插件顺序UnoCSS 插件要放在框架插件如vitejs/plugin-vue之后、其他样式处理插件之前。// vite.config.ts import { defineConfig } from vite import vue from vitejs/plugin-vue import UnoCSS from unocss/vite export default defineConfig({ plugins: [ vue(), UnoCSS(), ], })这一步有个细节容易踩如果你的项目里同时还挂了其他 CSS 相关的插件比如自动导入样式的插件要让 UnoCSS 的虚拟模块在它们之前被解析否则virtual:uno.css会报找不到模块。判断方法很简单看终端报错是Failed to resolve import virtual:uno.css还是样式不生效——前者是插件顺序或没挂插件后者基本是配置文件的问题。2.2 uno.config.ts 的最小骨架配置文件放在项目根目录文件名uno.config.tsUnoCSS 会自动识别。最小可用的版本长这样// uno.config.ts import { defineConfig, presetWind3 } from unocss export default defineConfig({ presets: [ presetWind3(), ], })就这些。别急着加东西先在任意组件里写一个classtext-red-500跑起来看看颜色有没有生效。生效了说明链路是通的再继续。这一步看着简单但它帮你把插件没挂好和配置写错了这两种完全不同的故障隔离开了后面排查能省很多时间。2.3 样式引入顺序为什么这一行位置很关键在入口文件里引入样式顺序不能随便// main.ts import unocss/reset/tailwind.css // 先重置 import virtual:uno.css // 再引入工具类 import ./styles/app.css // 最后是项目自定义样式为什么必须是这个顺序CSS 的优先级规则里同等特异性下后加载的覆盖先加载的。reset 的作用是抹掉浏览器默认样式比如h1的font-size、ul的padding它必须排在最前面否则会把你自己写的工具类也抹掉。virtual:uno.css是你写的所有工具类的集合应该排在 reset 之后。项目自定义的全局样式放最后因为它通常包含一些需要覆盖工具类的场景比如第三方组件的兜底样式。另一个相关配置是outputToCssLayers开启之后 UnoCSS 会把生成的工具类包进 CSS 的layer里export default defineConfig({ presets: [presetWind3()], outputToCssLayers: true, })它的价值在第六节会展开讲简单说就是让你的工具类和第三方组件库的样式有明确的层级关系不用靠!important硬顶。3. Preset 选型清单开哪些、为什么以及开了之后付什么代价Preset 是 UnoCSS 最容易被无脑全开的地方。我见过一份配置把能挂的 preset 全挂上结果构建产物里混了四套颜色体系、两套间距刻度最后没人说得清p-4到底是 16px 还是 1rem。选型的原则应该是默认只挂一个基础 preset其他按需加每加一个都要能说出它解决了什么具体问题。3.1 基础 preset 的选择边界Preset定位适合场景主要代价presetWind3接近 Tailwind v3 的完整工具类含 preflight新项目默认首选规则多配置项也多presetUno早期命名功能与 Wind 系重叠老项目沿用新版本中逐步被新命名取代presetMini精简核心不含 preflight自己有一套完整 reset 的项目需要自己补 reset规则少presetAttributify属性化写法支持长类名可读性差、想省字符需要配 TS 类型模板体积变大presetIcons图标按需转 CSS 或内联 SVG图标零散使用、不想引图标库需要装图标数据包首屏体积要盯presetTypography富文本内容排版文章、Markdown 渲染区规则体量大仅局部使用presetWebFonts在线字体按需引入需要多套字体的展示站依赖外部字体服务有延迟有个判断方法很实用如果你不确定要不要加某个 preset先问自己我要写多少个类名会用到它。图标用了三个、字体只用一套那就先别加直接写内联 SVG 和一行font-family更省事。preset 不是越多越专业它更像是往引擎里装插件装得越多规则匹配的路径越长冲突的可能也越多。3.2 presetWind3 与 presetMini 的取舍逻辑这两个的差别本质上是要不要 preflight。presetWind3自带一份近乎 Tailwind Preflight 的重置会统一box-sizing、清掉标题和列表的默认样式、给img加display: block之类。新项目直接用它省事而且和大部分 UI 库的预期一致。presetMini只给你工具类不带任何重置。什么时候用它一种是你的项目已经有了一套完整的设计系统样式底座比如公司内部的 CSS 库不想被第二套重置干扰另一种是你对产物体积极度敏感只想保留最核心的规则。但要注意用了presetMini就一定要自己引 reset否则浏览器默认样式会跑出来典型的症状是h1到h6大小不一致、ul前面有圆点、按钮在不同浏览器里长得不一样。我的建议是除非有明确理由否则新项目一律presetWind3起步。等哪天真的遇到这份 preflight 和我现有样式打架的具体问题再换成presetMini也不迟。3.3 presetIcons按需内联的收益和它的隐藏账单图标这块 UnoCSS 做得确实漂亮。装了iconify-json/ep之后直接写classi-ep-search引擎会把对应的 SVG 取出来转成 data URI 塞进background-image默认模式或者内联成svgmode: svg时配合组件。好处是只打包你用到的图标一个后台系统用几百个图标也不会把整个图标库拉进来。但账单有两个。第一每个图标都会在 CSS 里变成一段 base64 或 data URI用得多的时候 CSS 文件会明显变大。如果图标在几十个地方用考虑抽成一个基于svg的组件比在 CSS 里重复塞几十份 data URI 划算。第二图标是按类名扫描的如果你把图标类名拼成动态字符串比如i-ep-${name}扫描阶段看不到完整类名图标就不会被生成。这种情况必须加 safelist 或者用图标组件方案具体做法在第六节会讲。还有一个实际使用中的小坑图标默认继承currentColor的行为依赖于你选的 preset 配置颜色控制方式和你想象的background-image可能不一样。第一次用的时候建议先写一个图标在浏览器里检查一下它到底是背景图还是内联标签确认之后再大范围用。3.4 presetTypography 和 presetWebFonts 的价值判断presetTypography解决的是富文本区域的排版问题。后台项目里经常有富文本编辑器用户输入的内容带h2、p、blockquote、ul你又不想给每个标签手写样式。给容器加一个prose类内部元素就有一套完整的排版规则prose-sm、prose-invert还能调字号和暗色反转。它的代价是规则体量不小但只在你真正用到prose的时候才会被生成所以对产物体积的影响可控。使用它的前提是引入方式要对通常需要额外的样式引入pnpm add -D unocss/preset-typography然后在配置里挂上并且在需要排版的容器上显式加prose类。如果你的项目根本没有富文本内容加它纯粹是浪费配置复杂度。presetWebFonts我个人的态度偏保守。它会根据配置生成 Google Fonts 之类的引入好处是字体按需、不用手写link坏处是依赖外部服务某些网络环境下首次渲染会有明显字体闪烁FOUT。展示型站点可以用但后台系统或者对首屏要求高的场景我更倾向于把字体文件放到自己的静态资源里用font-face手动声明。这不是技术上的对错是场景取舍。4. 把设计规范写进引擎rules、shortcuts 与 theme 的配合Preset 解决的是通用能力而一个团队真正的效率提升来自把自家的设计规范固化到引擎里。这一节讲三件工具自定义规则、快捷方式、主题变量。它们的关系是层层递进的——rules 定义最底层的能力shortcuts 组合出语义化的类名theme 保证颜色和刻度的统一来源。4.1 静态规则与动态规则的写法差异静态规则针对固定类名写法最直白import { defineConfig, presetWind3 } from unocss export default defineConfig({ presets: [presetWind3()], rules: [ [card-shadow, { box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08) }], [text-balance-pretty, { text-wrap: pretty }], ], })第一个元素是匹配的类名第二个元素是要生成的 CSS 声明对象。这类规则适合那些只在一处特殊但需要统一的样式比如公司统一的卡片阴影、特定字体的字重组合。动态规则用正则匹配返回一个函数rules: [ // 支持 mt-13、mt-27 这类任意数字按 4px 为基准 [/^m-(\d)$/, ([, d]) ({ margin: ${d / 4}rem })], // 支持 grid-cols-任意数字 [/^grid-cols-(\d)$/, ([, d]) ({ grid-template-columns: repeat(${d}, minmax(0, 1fr)) })], ]这里有几个坑一定要知道。正则必须带^和$不然mt-4会被mt-40的规则误匹配捕获组的取值要用d转成数字再算直接拼字符串会得到mt-3.25rem这种非法值规则数组的顺序会影响优先级更具体的规则要写在更靠前的位置。另外动态规则的正则如果写得太宽松比如[/^(\w)-(\w)$/]会把大量你不想接管的类名全部吃进去导致 preset 里原有的规则失效。我一般的原则是正则里出现的字符集越小越好宁可多写几条规则也别写一个大而全的。4.2 快捷方式什么时候该封装什么时候是灾难shortcuts 是把多个类名组合成一个语义化名字它有两种形式。静态 shortcutshortcuts: { btn: px-4 py-2 rounded-md text-sm font-medium transition-colors, btn-primary: btn bg-blue-600 text-white hover:bg-blue-700, btn-ghost: btn bg-transparent text-gray-700 hover:bg-gray-100, }注意btn-primary里可以直接引用btnUnoCSS 会递归展开这让组合变得很自然。动态 shortcut 用正则shortcuts: [ [/^btn-(red|green|blue)$/, ([, c]) btn bg-${c}-600 text-white hover:bg-${c}-700], ]什么时候该用 shortcuts我的判断标准是这个组合出现了三次以上而且它代表一个设计概念。按钮、卡片、输入框、标签这些是典型的设计概念封装成 shortcut 能让模板更短也让设计规范有了唯一的落点——改设计只需要改配置。什么时候是灾难当 shortcuts 变成把每个页面的类名组合都封一层的时候。我见过有人把home-banner-wrapper、user-list-item-inner这种纯页面语义的东西写进 shortcuts结果配置文件和项目耦合死换一个页面就得回去改配置比直接在模板里写类名还麻烦。判断标准很简单如果这个 shortcut 只有一处地方用那它不该存在。另外要注意 shortcuts 和 rules 的优先级关系。shortcut 本质上是一组类名的展开展开后的每个类名还要再经过 preset 和 rules 匹配。如果你定义了一个 shortcut 名叫flex它会覆盖掉 preset 里的flex规则这种命名冲突排查起来非常费劲所以 shortcut 命名尽量带业务前缀btn-、card-、input-这类。4.3 theme 与 CSS 变量换肤和暗色模式的落点主题配置是保证颜色只有一处定义的关键export default defineConfig({ theme: { colors: { brand: { DEFAULT: #2563eb, light: #60a5fa, dark: #1d4ed8, }, }, breakpoints: { sm: 640px, md: 768px, lg: 1024px, }, }, })配完之后text-brand、bg-brand-light就能用了。这样做的价值在于改主色只需要改一个十六进制值全站跟着变而不是全局搜索#2563eb。如果要做换肤或者暗色模式theme 里的静态值不够用需要走 CSS 变量theme: { colors: { surface: var(--surface-color), onSurface: var(--on-surface-color), }, }然后在全局 CSS 里定义变量用属性选择器切换:root { --surface-color: #ffffff; --on-surface-color: #1f2937; } html[data-themedark] { --surface-color: #111827; --on-surface-color: #f9fafb; }这样bg-surface text-onSurface在切换>presets: [ presetWind3({ dark: class }), // 跟随 .dark 类名手动可控 ],用class策略的好处是用户能手动切换主题用media策略则跟随系统设置。后台系统一般两种都要支持做法是把class策略和读取系统偏好的逻辑结合起来在初始化时决定要不要给html加.dark类。CSS 变量方案和dark:变体可以混用我的经验是整体底色、文字色走 CSS 变量局部需要特殊处理的走dark:变体两者不冲突。5. Transformers让写法变短的开关以及它们的顺序问题Transformers 是 UnoCSS 里锦上添花的部分它们不生成新规则而是改变你写类名的方式。用得对模板能短一大截用得不对会出现我明明写了但没生效的诡异问题。这一节把常用的几个过一遍重点讲顺序。5.1 variant group 与 attributify 的实际写法收益transformer-variant-group让你把同一个变体前缀下的多个类名合并!-- 不用 transformer -- div classhover:bg-blue-600 hover:text-white hover:shadow-lg/div !-- 用了之后 -- div classhover:(bg-blue-600 text-white shadow-lg)/div在响应式场景下收益更明显md:(flex items-center gap-4)比一条条写清晰得多。我实测下来一个中等复杂度的后台页面用了 variant group 之后模板里的类名字符数大概能减掉三成。transformer-attributify-jsx配合presetAttributify使用让你把类名写成 HTML 属性div flex items-center gap-4 p-4 rounded-lg bg-white span text-sm text-gray-500标题/span /div这种写法在 JSX/TSX 里特别舒服因为不用在className里堆一长串字符串。但它有两个前提一是必须在 preset 里挂presetAttributify()二是 TSX 项目必须额外挂transformer-attributify-jsx否则 JSX 编译器会把flex、items这些当成未知属性报错或者直接忽略。属性名也要注意像p-4这种带短横线的属性在 HTML 里没问题但在严格的 TS 类型检查下可能需要声明扩展类型。5.2 directivesapply 在 UnoCSS 里怎么用才对transformer-directives让你在style块里用apply、screen、variants这些指令style scoped .card { apply p-4 rounded-lg bg-white shadow-sm; } .card-title { apply text-base font-semibold text-gray-900; } /style用它的前提是在配置里启用import transformerDirectives from unocss/transformer-directives export default defineConfig({ transformers: [transformerDirectives()], })什么场景该用我的经验是三种情况。一种是需要写复杂选择器的场景比如.card:hover .card-title这种后代选择器用类名堆叠写不出来。第二种是给第三方组件补样式比如:deep(.el-input__inner)上加工具类。第三种是style scoped里已经有逻辑不想再切回模板写类名。但要克制。apply用多了就等于把手写 CSS 的路又走回来了失去了原子化的意义。我在 review 的时候看到超过五行连续的apply一般会建议拆成 shortcut 或者直接写回模板。另外注意apply里的类名同样要能被扫描到动态拼接的类名在这里也不会生效。5.3 compile class长类名压缩成短哈希transformer-compile-class是我个人很喜欢的一个它把一长串类名在编译时压缩成一个短哈希!-- 写法 -- div class:uno: flex items-center gap-3 p-4 rounded-lg bg-white shadow-sm !-- 产物 -- div classuno-abc123 style.uno-abc123{display:flex;align-items:center;gap:.75rem;...}/style它的收益主要在产物体积和可读性上。同一个类名组合在页面里出现几十次的时候用短哈希能省下不少字节。写法上用:uno:前缀标记需要编译的节点也可以用配置指定自定义前缀比如:cls:。要注意的是它改变的是产物而不是源码所以在浏览器开发者工具里看到的类和源码里的类对不上排查样式问题时容易迷惑。我的建议是只在那些确定不会再改的稳定组件上用开发阶段频繁调整的模块先别编译等定稿了再加。这样既拿到了体积收益又不至于让调试变得困难。5.4 顺序真的会影响结果transformers 在配置里是一个数组执行顺序就是数组顺序。多数情况下顺序无所谓但有两处敏感第一transformerDirectives要在其他会改写style内容的 transformer 之前否则apply可能来不及展开就被后面的处理跳过了。第二transformerVariantGroup要在transformerCompileClass之前因为 compile class 需要看到展开后的完整类名列表如果 variant group 还没展开hover:(bg-red-500)会被当成一个整体处理结果就错了。配置里我习惯按这个顺序写transformers: [ transformerVariantGroup(), transformerDirectives(), transformerCompileClass(), ]每加一个 transformer最好都跑一遍完整的打包确认产出的 CSS 和开发环境一致。transformer 的问题往往在开发环境看不出来因为 Vite 的 dev server 是逐个模块转换的而打包是一次性处理两者的处理路径不完全一样——这也是下一节要重点讲的内容。6. 从 dev 正常到 build 掉样式一条可复现的排查链路这一节是全篇最实用的部分。UnoCSS 最常见、也最让人抓狂的问题就是开发环境样式好好的打包之后少了一部分甚至全没了。原因基本集中在扫描和优先级两类下面按排查顺序走。6.1 动态拼接类名样式消失的头号来源UnoCSS 的工作原理是构建时扫描源码文件把源码里出现的字符串当作候选类名再去匹配规则。这意味着它看到的是字面量不是运行时的值。所以下面这些写法都不会生效// 全都不行 const cls text-${color}-500 const size text- fontSize const list [text, red, 500].join(-)扫描器看到的是text-${color}-500、text-、text、red、500这些碎片没有任何一条能匹配上text-red-500的规则。开发环境有时候看起来正常是因为热更新时某个模块恰好把完整字符串暴露给了扫描器或者你之前写过这个类名缓存里还在。打包时重新全量扫描缓存没了就露馅了。三种正确的解法按推荐程度排// 方案一用完整的字面量映射扫描器能看到全部候选 const colorMap { red: text-red-500, green: text-green-500, blue: text-blue-500, } const cls colorMap[color] // 方案二在配置里加 safelist把所有可能的类名预先声明 // uno.config.ts safelist: [text-red-500, text-green-500, text-blue-500] // 或者用函数形式 safelist: [text-red-500, text-green-500, text-blue-500].map(i i.replace(500, 600)) // 方案三把类名放在一个专门的常量文件里供扫描器扫描 // constants/uno-classes.ts export const TEXT_COLORS { red: text-red-500 text-red-600, green: text-green-500 text-green-600, }方案一最好因为它不增加额外配置类名还在源码里可见。方案二适合类名来自后端接口的场景比如后台返回一个颜色 key前端映射成类名。方案三适合类名集合特别大的场景但要注意这个文件本身必须被扫描到——如果你的扫描配置排除了某些目录它照样失效。6.2 三步定位法从 inspector 到产物 grep当你确定不了问题在哪建议按这三步走从开发环境查到产物。第一步打开 UnoCSS 提供的检查器。Vite 开发服务器启动后访问/__unocss就能看到界面里面有几个关键面板生成的 CSS 列表、匹配到的类名、没匹配上的类名。先看没匹配上的列表里有没有你写的类名如果有说明扫描到了但没规则能算出来——问题在 preset 或 rules如果连列表里都没有说明扫描根本没扫到那个文件——问题在扫描配置。第二步检查扫描范围。默认情况下 UnoCSS 会扫描项目根目录下的源码文件但有一些情况会漏文件后缀不在默认列表里比如.mdx、.svelte文件在node_modules里但你确实需要它的类名文件通过某种方式动态加载比如异步加载的富文本模板字符串。扫描配置长这样export default defineConfig({ content: { filesystem: [src/**/*.{html,js,ts,jsx,tsx,vue}], pipeline: { include: [/\.(vue|svelte|[jt]sx|mdx?|html)($|\?)/], exclude: [node_modules/**, dist/**], }, }, })pipeline.include用的是正则比filesystem的通配符更精细。如果你的项目里有.mdx或者自定义模板文件一定要把它加到include里这是漏扫最隐蔽的来源。第三步grep 产物。打包之后在dist/assets里找到 CSS 文件直接搜你怀疑失踪的类名grep -o text-red-500 dist/assets/*.css搜到了但页面没生效说明是优先级或加载顺序问题下一小节讲搜不到说明是扫描或规则问题回到第二步。这一步虽然土但定位效率最高不用在浏览器里猜。6.3 第三方组件库、富文本和动态内容怎么处理第三方组件库的样式冲突是另一个高频问题。典型场景是你给 Element Plus 的按钮加了一个p-2结果被组件库自己的padding覆盖了。原因通常是组件库的样式特异性和你的工具类相同但加载顺序在后面。两个解决办法。第一个是开启outputToCssLayers让工具类进入 CSS 层级export default defineConfig({ outputToCssLayers: true, })这样生成的工具类会被包在layer里层级顺序由你在全局 CSS 里声明可以明确把 utilities 放在组件库样式之后不用靠特异性硬拼。第二个办法是调高工具类的特异性export default defineConfig({ important: #app, // 所有工具类都带上 #app 前缀特异性明显更高 })important: #app会让生成的规则变成#app .p-2 { ... }这样的形式特异性一下子就上去了。它的代价是工具类几乎无法被普通样式覆盖所以只在确实需要压制第三方样式的时候用别当默认配置。富文本内容用户输入的 HTML里的类名不会被扫描到因为它们不在源码文件里。处理方式是给渲染容器加prose类用presetTypography提供排版规则而不是指望工具类。如果确实需要给富文本里的元素加工具类那必须在代码里显式声明一份类名清单或者用safelist兜住。动态组件和异步渲染的内容也是同理。凡是运行时才拼出来的类名都要走 safelist 或者字面量映射没有第三条路。6.4 一个真实的排查记录前段时间遇到一个案例某个列表页在开发环境正常打包后行高全部塌了。按上面的流程走检查器里能搜到leading-relaxed这个类名说明扫描和规则都正常grep 产物也能搜到但浏览器里 computed style 显示line-height: normal。最后查到原因是组件库在局部引入了一份自己的样式文件里面重定义了line-height且它的加载时机在工具类之后。解决办法就是上面说的outputToCssLayers把工具类放进 utilities 层层级顺序固定下来之后问题消失。这个案例说明一个道理扫描和优先级是两类完全不同的故障排查时一定要先分清。grep 能找到类名就不要再折腾扫描配置了往特异性、加载顺序、CSS 层级这三处找。7. 落地节奏约定、迁移顺序与 review 检查项技术选型只是第一步能不能在团队里跑起来取决于约定和执行。这一节讲三个实操层面的东西。7.1 从现有方案迁移的渐进路径不要一次性把整个项目的样式重写。我见过的成功案例基本都走了三步。第一步新旧共存。先把 UnoCSS 接进去只在新写的页面和组件上用工具类老代码原样不动。这一步的目的是让团队熟悉写法同时验证构建链路没问题。这个阶段大概持续一到两周。第二步抽公共约定。把反复出现的样式组合沉淀成 shortcuts把颜色、间距、断点收进 theme。这一步做完团队才会有我们的设计规范在配置里的感觉而不是各写各的。同时把全局的 reset、CSS 变量、层级顺序定下来写进项目的样式入口文件。第三步渐进清理。从改动最频繁的模块开始把老的手写 CSS 逐个替换掉。替换的时候一定要删干净——我看到过太多项目工具类和旧类名同时留着结果是两套样式互相覆盖排查起来极其痛苦。判断某个 CSS 文件能不能删的方法很简单注释掉它的引入跑一遍全量页面截图对比没变化就删。7.2 类名书写约定与 review 检查清单工具类写法自由度高没有约定就会乱。下面这份清单是我们团队实际在用的可以直接参考。检查项建议类名顺序布局 → 尺寸 → 间距 → 外观 → 状态例如flex items-center gap-3 p-4 rounded-lg bg-white hover:shadow-md自定义颜色只能来自 theme禁止硬编码十六进制值间距数值优先用 4 的倍数非标准值需在 review 中说明设计依据动态类名出现\xxx-${var} 形式的拼接必须改为映射表或加 safelistshortcut 使用只有三处以上复用的组合才允许封装apply 使用单个选择器不超过五行超出需拆分为 shortcut响应式统一用断点前缀禁止在style里写媒体查询做同一件事这份清单最大的价值不是限制而是让 review 有据可依。新人第一次提交的时候reviewer 可以直接指出这个拼接类名打包会丢而不是笼统地说风格不统一。7.3 和设计系统、组件库的分工边界最后一个容易混乱的地方是职责划分。我的建议是把边界划成三层。最底层是 preset 和 theme管的是通用的语言——颜色、间距、字号、断点。这一层应该尽量小、尽量稳定改一次影响全站。中间层是 shortcuts 和自定义 rules管的是项目的语言——按钮、卡片、表单控件这些设计概念。这一层应该有明确的所有者通常是前端负责人改之前要评估影响面。最上层是页面模板里的类名管的是这一页的具体布局。这一层可以自由写但必须遵守上面的约定清单。至于组件库它的定位是提供交互复杂的组件比如日期选择器、级联选择、表格。这些组件的内部样式不要去改用:deep()加工具类的做法要克制因为组件库升级时这种写法最容易坏。更稳的做法是通过组件库自己的主题变量定制把工具类留给布局层。我在实际使用中发现遵循这个分层之后样式相关的沟通成本下降得很明显——讨论这个间距该多少时大家看的是同一份 theme 配置而不是各自截图对像素。技术方案解决不了所有协作问题但至少能把样式标准的唯一来源这件事确定下来这对一个多人协作的前端项目来说价值不比构建提速小。