前端开发··2 阅读·预计 14 分钟

Vite CSS 工程化治理:从原子化冲突到零冗余产物的构建链优化

为什么你的 Vite 项目 CSS 体积总是失控

Vite 开箱即用的 CSS 支持极易让人放松警惕。一个典型的 Vue/React 项目跑上三个月,CSS bundle 轻松破 200KB,其中 40% 是永远不会被触发的媒体查询,30% 是重复的工具类,20% 是未被使用的 scoped 样式碎片。这不是 Vite 的锅——而是我们对 import './styles.css' 过于信任。

核心矛盾在于:Vite 的零配置哲学下,CSS 管线隐藏着三条相互耦合的决策维度——模块化策略、预处理链路、原子化引擎。三者各自独立配置,组合后却可能产生指数级冗余。

CSS Modules:作用域隔离的隐形成本

CSS Modules 是 Vite 原生支持的最常用的作用域方案。凡 .module.css 结尾的文件,Vite 自动启用 CSS Modules 编译。看一个典型场景:

/* Button.module.css */
.button {
  padding: 8px 16px;
  border-radius: 6px;
  font-size: 14px;
}
.primary {
  background: #2563eb;
  color: white;
}
.danger {
  background: #dc2626;
  color: white;
}
import styles from './Button.module.css'

// ❌ 反模式:类型安全丢失,拼写错误无编译期提示
<button className={`${styles.button} ${styles.primary}`} />

// ❌ 更糟:条件类名变成字符串拼接黑洞
<button className={`${styles.button} ${variant === 'primary' ? styles.primary : styles.danger}`} />

TypeScript 把 styles 推断为 CSSModuleClasses ——一个 key 为 string 的万能索引类型,任何不存在的类名都不会报错

解决之道是 vite-plugin-css-modules-typetyped-css-modules 自动生成 .d.ts

// Button.module.css.d.ts (auto-generated)
declare const styles: {
  readonly button: string
  readonly primary: string
  readonly danger: string
}
export default styles

现在拼写错误会在编译期直接爆红。但别急着开心——CSS Modules 在构建时会把每个 .module.css 独立内联为 <style> 标签,10 个组件 10 个 module.css 就是 10 个 <style> 注入。Vite 在 dev 模式下这样做没问题(HMR 粒度),但生产构建需额外配置:

// vite.config.ts
import cssInjectedByJsPlugin from 'vite-plugin-css-injected-by-js'

export default defineConfig({
  plugins: [react(), cssInjectedByJsPlugin()],
  build: {
    cssCodeSplit: false  // ✅ 所有 CSS 合并为单一文件
  }
})

cssCodeSplit: false禁用 Vite 的异步 CSS 按需加载。如果你用了 React.lazy 代码分割,关闭后所有 CSS 都会被塞进入口 chunk。这里没有完美方案——需根据项目规模权衡。

PostCSS 插件链:隐式依赖的爆炸半径

Vite 会自动加载 postcss.config.js,这意味着你安装的任何 PostCSS 插件都会无声明地进入 CSS 管线。一个真实踩坑记录:

// postcss.config.js —— 看上去人畜无害
module.exports = {
  plugins: [
    require('autoprefixer'),
    require('postcss-preset-env'),
    require('tailwindcss'),        // ← 本体 3.5MB AST
    require('postcss-nested'),     // ← 又一层嵌套展开
    require('cssnano')({ preset: 'default' }) // ← 压缩
  ]
}

这条链路实际做的工作:

  1. Tailwind 扫描模板生成数万条候选类 → 3.5MB AST
  2. postcss-nested 展开嵌套规则 → 再次遍历 AST
  3. autoprefixer 逐个属性加前缀 → 第三次遍历
  4. postcss-preset-env 降级现代语法 → 第四次遍历
  5. cssnano 最终压缩

五次完整的 AST 遍历,每次都是 O(n) 成本。Dev 模式下每次 HMR 触发 CSS 变更,这五步全部重新执行。一个 50KB 的 CSS 源文件,从保存到浏览器刷新,PostCSS 链路吃掉 300-800ms。

优化策略:Lightning CSS 置换

lightningcss 用 Rust 重写了 autoprefixer、cssnano、postcss-preset-env 的大部分功能,单次 AST 遍历完成所有变换

// vite.config.ts
import { defineConfig } from 'vite'
import lightningcss from 'vite-plugin-lightningcss'

export default defineConfig({
  css: {
    transformer: 'lightningcss',  // Vite 4.4+ 原生支持
    lightningcss: {
      targets: { chrome: 90 },    // ✅ 替代 browserslist
      drafts: { nesting: true }   // ✅ 原生嵌套,无需 postcss-nested
    }
  },
  plugins: [lightningcss()],
  build: {
    cssMinify: 'lightningcss'     // ✅ 生产压缩也走 Rust 引擎
  }
})

实测效果(50KB CSS → 38KB gzip):

  • PostCSS 链路:420ms(dev HMR)/ 680ms(build)
  • Lightning CSS:32ms(dev HMR)/ 45ms(build)

但要注意:Lightning CSS 不支持所有 PostCSS 插件生态。如果你的 postcss.config.js 里有自定义插件(如 postcss-px-to-viewport),必须保留 PostCSS 并仅置换非自定义部分。用 css.lightningcss 配合 postcss.config.js 的 exclude 实现混合管线。

原子化 CSS 的按需陷阱

UnoCSS / WindiCSS / Tailwind JIT 都宣称"按需生成,零冗余"。信了这句话的人,最终产物里往往躺着 40KB 的 @keyframes 和 15KB 的 CSS Reset。

<!-- ❌ 这是"按需"吗?Tailwind 会生成整段 animate-spin 定义 -->
<div class="border-t-2 border-blue-500 rounded-full animate-spin w-4 h-4" />

<!-- Tailwind 生成的 CSS 包含完整的 @keyframes spin -->
<!-- 即使你只用了 animate-spin,整个 keyframes 定义都会注入 -->

Tailwind 的 content 扫描只会排除未被引用的类名,而非类的内部实现路径。你在模板里写了 animate-spin,Tailwind 就会注入整个 @keyframes spin 动画定义——不可能因为你只用 rotate(0deg)rotate(180deg) 就裁剪掉 rotate(360deg)

UnoCSS 在这方面做得更激进:

// uno.config.ts
import { defineConfig, presetUno, presetAttributify } from 'unocss'

export default defineConfig({
  presets: [
    presetUno(),
    presetAttributify()
  ],
  rules: [
    // ✅ 真正按需的自定义规则
    [/^m-([.\d]+)$/, ([, d]) => ({ margin: `${d}rem` })],
    [/^p-([.\d]+)$/, ([, d]) => ({ padding: `${d}rem` })]
  ],
  shortcuts: {
    'btn': 'px-4 py-2 rounded font-semibold transition-colors',
    'btn-primary': 'btn bg-blue-600 text-white hover:bg-blue-700'
  },
  // ✅ 预检(reset)也按需
  preflights: [{
    getCSS: () => `*,::before,::after{box-sizing:border-box}`
  }]
})

对比测试——同一套 UI 组件库的 CSS 产物:

方案未压缩Gzip备注
Tailwind v3 (JIT)12.4KB3.8KB含 full reset + 基础工具类
UnoCSS Attibutify8.1KB2.6KB无预设 reset,按需注入
手写 CSS Modules6.3KB2.1KB人工极致精简
混合方案7.2KB2.4KBCSS Modules 双组件 + UnoCSS 双工具类

现实是:手写的极致不等于可维护。UnoCSS 的 2.6KB vs CSS Modules 的 2.1KB,多了 0.5KB 换来完整的设计令牌系统和 shortcuts 复用能力——这是工程化应该做的取舍。

核心决策树

你的项目需要:
│
├─ 设计系统/主题令牌 → 原子化 CSS(UnoCSS 优先,轻量且按需)
│   └─ 同时需要组件隔离 → UnoCSS + CSS Modules 混合
│       ├─ 公共组件用 CSS Modules(避免样式泄漏)
│       └─ 布局/间距/排版用 UnoCSS shortcuts(减少重复定义)
│
├─ 组件库开发 → CSS Modules + 自动类型生成
│   └─ 产物需导出 CSS → cssCodeSplit + 库模式
│
├─ 已有 Tailwind 项目 → 升级到 v4(原生 Vite 插件,无 PostCSS 桥接)
│   └─ 考虑用 @tailwindcss/vite 替代 postcss 入口
│
└─ 追求极致构建速度 → Lightning CSS 全链路
    └─ 评估:放弃哪些 PostCSS 插件是可接受的?

总结

Vite CSS 工程化的三层优先级:

  1. 编译速度:Lightning CSS > 原生 CSS > PostCSS 全链路。优先用 Vite 4.4+ 的 css.transformer: 'lightningcss',仅在必须兼容自定义 PostCSS 插件时保留混合管线。
  2. 产物体积:原子化 CSS(UnoCSS)> CSS Modules > 全局 CSS。但原子化 CSS 的"按需"有边界——@keyframes、CSS Reset 等不会自动裁剪,需手动审计 safelist。
  3. 开发体验:CSS Modules 的类型安全生成 + UnoCSS 的 attributify 模式,是当前组合拳最优解——组件内部强隔离,布局层高度复用。

别让你的 CSS bundle 成为性能债务的沉默承担者。一次 npx vite-bundle-visualizer 比十次"感觉有点慢"更有说服力。

0 评论

评论区

登录 后参与评论