Skip to main content

CSS 方案选型与原子化 CSS

同一份设计稿,有的团队用 CSS Modules,有的用 Tailwind,有的在运行时插样式。到了 React Server Components,运行时 CSS-in-JS 会先撞墙,因为服务端组件里没有那套插入样式的客户端运行时。

1. 四条技术路线​

按作用域隔离的实现方式来排,四种方案是一条强度递增、成本也递增的光谱:

方案隔离方式运行时开销典型代价
BEM / 命名约定靠人守纪律零命名冲突靠自觉,规模一大就管不住
CSS Modules构建期给类名加哈希无需要构建步骤,动态组合样式不直观
原子化(Tailwind 等)扫描类名生成原子规则无HTML 变长,动态类名会失效
CSS-in-JS运行时注入 <style>有序列化开销、SSR 需收集样式、RSC 下受限

原生隔离(Shadow DOM)是另一条路线:浏览器层面隔离,但主题定制要另开通道(CSS 变量与 ::part),一般只在 Web Components 场景使用。绝大多数业务项目停在中间两档就够了。

2. 原子化产物不会失控的原因​

传统 CSS:选择器与声明打包在一起.card { … }.btn { … }.header { … }原子化:声明被切成最小单元,class 直接对应一条属性p-4flextext-smbg-blueroundedgap-2…产物里只留下实际用到的那几条声明 —— 这就是 tree shaking 友好的原因。代价是 HTML 里 class 变长,可读性靠代码编辑器补全来补。
图:原子化的产物并非「更小」,而是让「只留用到的声明」成为可能

原子化看起来会为每个用到的值生成一个类,实际上构建期只扫描源码里字面出现的类名,扫不到的就不生成。所以同一个 mt-4 无论被多少组件用,产物里只有一条规则;真正会让产物失控的是拼接出来的动态类名——w-${size} 这种写法扫描器根本看不到,必须走 safelist 或者改用内联 style,这是原子化最常踩的坑。

// 错:扫描器看不到 w-${size},产物里没有这条规则
<div className={`w-${size}`} />

// 正解一:safelist 声明用到的尺寸
// /** @type {import('tailwindcss').Config} */
// export default { content: ['./src/**/*.{tsx,ts}'], safelist: [{ pattern: /w-(48|64|96)/ }] }

// 正解二:真正动态的值直接内联
<div style={{ width: size + 'px' }} />

反过来,大量一次性值(如 w-[137px])会把产物撑大——它和字面类名一样会被收集,只是复用率为零。所以「原子化一定比 CSS Modules 产物小」并不成立,取决于复用率与是否出现动态类名。

3. 运行时方案碰上 RSC​

依赖运行时注入的 CSS-in-JS 有三条真实代价:

  1. 运行时序列化:每次渲染要把样式对象序列成字符串并插入 DOM;
  2. SSR 需额外收集样式:服务端要把组件用到的样式抽出来注入 <head>,否则首屏样式缺失;
  3. RSC 下无法运行:服务端组件不执行浏览器环境逻辑,运行时注入的样式在服务端组件里直接丢失。
// 零运行时方案把样式在构建期提取成普通 CSS 文件,RSC 也能用
import styles from './Button.module.css' // 编译期提取,无运行时
function Button() {
return <button className={styles.primary}>提交</button>
}

真正不能用的是「运行时注入」,不是 CSS-in-JS 这种写法本身——编译期提取(零运行时)的 CSS-in-JS 在上 RSC 的项目里完全可以继续用。

判断一个方案到底是不是运行时注入,不用读文档,看构建产物就够了:

# 零运行时:产物里有独立 CSS 文件
ls dist/assets/*.css

# 若样式只在 JS 里、且能搜到运行时插入,说明走的是运行时注入
grep -c "insertRule" dist/assets/*.js

4. 按团队与产物形态定​

选型先列四个条件,再给结论,不要先选库再找理由:

  • 团队规模小、无设计系统、追求开发速度 → 原子化,一致性和开发效率最高;
  • 需要语义主题、多品牌换肤、组件库对外 → CSS 变量 + CSS Modules,主题靠变量映射,不受原子类束缚;
  • 已大量使用运行时 CSS-in-JS 且不上 RSC → 可以继续,但要知道序列化与 SSR 收集样式的代价;
  • 上了 RSC / 服务端组件 → 改用零运行时方案或原子化,避免运行时注入的样式在服务端丢失。

设计系统与原子化也能共存:组件内部用 Modules 或原子化实现,对外只暴露语义 class 与 CSS 变量,业务方不感知内部方案。

/* CSS Modules:构建期把类名哈希,隔离靠工具而非纪律 */
/* 源码 Button.module.css */
.primary { background: var(--color-brand); }
/* 编译后(类名被哈希,不会与全局冲突) */
.Button_primary_ax91 { background: var(--color-brand); }

产物体积这条最容易被口头争论带偏,构建后量一次就结束了:

# 统计产物里实际生成了多少条工具类规则
grep -o "\.[a-z-]*[0-9]*{[^}]*}" dist/assets/*.css | wc -l

# 对比:同样页面用 CSS Modules 的产物体积
du -h dist/assets/*.css

原子化的产物不一定更小。它省掉的是重复规则,但如果页面里大量出现一次性取值(比如 w-[137px] 这种),产物反而会被撑大——收益取决于复用率,这是可以量出来的,不是信仰问题。

CSS-in-JS 也不只是「样式写在 JS 里」这么轻巧。运行时序列化、SSR 时要额外收集样式、RSC 下无法运行,这三条是它真实的成本。反过来说,上了 RSC 也不必把它全删掉:零运行时方案(编译期提取成 CSS 文件)照样能用,真正不能用的是依赖运行时注入的那类。

没有最好的方案,只有最匹配团队条件与渲染模式的那一套——把约束列出来,答案通常自己就出来了。

原子化的官方立场可以看 Tailwind:Styling with utility classes,它的论证方式比社区争论更能说明适用边界。