CSS 架构模式:BEM、CSS Modules 与 CSS-in-JS
CSS 一多就乱:命名冲突、全局污染、优先级战争。本文讲清几种主流 CSS 架构模式:BEM(命名约定)、CSS Modules(局部作用域)、CSS-in-JS(styled-components/emotion)、以及 Tailwind/原子 CSS 的崛起,各自适用与怎么选。
本文要点
- 主题聚焦:React、TypeScript
- 适合人群:独立开发者、全栈工程师、AI 应用开发者
- 核心价值:快速理解背景、实践路径、常见误区和工程建议
写个 demo 页面,CSS 怎么写都好看;写个正经应用,CSS 怎么写都乱。一两年下来,button 类被五个人覆盖过八次,main 这个类名同时出现在登录页和首页却长得完全不一样,改一个 padding 不知道会牵动几个页面,!important 满天飞还是压不住优先级。这些问题不是 CSS 这门语言不行,是没有架构。CSS 天然全局、天然层叠、天然基于源码顺序——这三个「天然」放在一起,规模一大就一定是灾难。
社区过去十多年沉淀出几条不同的路:BEM 靠命名约定避开冲突,CSS Modules 靠编译时作用域消掉冲突,CSS-in-JS 靠把样式塞进 JS 拿到组件作用域,原子 CSS(Tailwind)干脆不要类名层。每种方案都是对同一个问题的不同回答。这篇文章把这几条路讲清楚,并给出新项目怎么选的实战判断。
一、CSS 为什么需要架构
先确认我们要解决的问题。CSS 在大规模下会爆四类雷:
- 全局污染。任何
.btn都是全局名,你以为是按钮组件,其实全站有十几个.btn,改一处炸一片。 - 命名冲突。
main、wrapper、container、title这些名字人人都用,跨文件碰撞是常态。 - 优先级战争。
#header .nav li a和.nav-link谁赢,取决于选择器特异性和源码顺序。最后大家比谁的选择器更长、!important更多。 - 复用失控。同一个按钮的样式被复制五遍,改设计稿时改不干净。
这四类问题的根因只有一个:CSS 没有组件作用域概念。所有架构模式本质都在解决一件事——人为造一个「组件作用域」出来。差别只在造法:BEM 用命名约定造,CSS Modules 用编译期哈希造,CSS-in-JS 用 JS 闭包造,原子 CSS 干脆不要这个抽象层。理解了这一点,后面所有方案的取舍就一目了然。
二、BEM:用命名约定模拟作用域
BEM(Block Element Modifier)是最朴素的方案:不引入任何工具,靠约定类名格式避免冲突。规则就三条:
- Block:独立的组件实体,如
.card、.nav。 - Element:Block 的内部部件,用双下划线连接,如
.card__title、.card__image。 - Modifier:Block 或 Element 的变体状态,用双连字符,如
.card--featured、.card__title--large。
<article class="card card--featured">
<img class="card__image" src="..." />
<h2 class="card__title card__title--large">标题</h2>
<p class="card__body">正文</p>
</article>
.card { padding: 16px; border-radius: 12px; background: #fff; }
.card--featured { border: 2px solid #2563EB; }
.card__title { font-size: 18px; font-weight: 600; }
.card__title--large { font-size: 24px; }
BEM 的精妙在于平铺的选择器——.card__title 是单一 class,特异度永远是 (0,1,0),不会和别的选择器打架。它用命名前缀(card__)模拟了命名空间:别人的 .title 和你的 .card__title 天然不冲突。零工具、零编译、零运行时,任何框架都能用。
BEM 的代价是靠纪律。约定一旦被破坏(有人写了 .card .title,有人写了 .card-title),作用域就破了。大型团队要么靠 lint 规则强约束,要么靠 code review 死盯。另一个代价是命名冗长——.card__title--large 这种字符串读起来确实累。
BEM 适合:传统多页应用、jQuery 老项目、需要同时服务多个框架(或无框架)的纯静态站、设计系统文档站。它的最大优势是框架无关——同一套 CSS 可以同时给 React、Vue、原生 HTML 用。
三、CSS Modules:编译时生成唯一类名
CSS Modules 把「命名约定」升级成「编译期保证」。你写普通 CSS,工具(webpack css-loader、PostCSS、Vite 等)在编译时把每个类名加上一个唯一哈希,如 .button 变成 .button_x3k9f。
/* Button.module.css */
.button {
padding: 8px 16px;
border-radius: 8px;
}
.primary {
background: #2563EB;
color: #fff;
}
import styles from './Button.module.css';
export function Button({ primary }: { primary?: boolean }) {
return (
<button className={`${styles.button} ${primary ? styles.primary : ''}`}>
点击
</button>
);
}
编译后,styles.button 是一个全局唯一的字符串(如 _button_x3k9f_1),你在另一个文件里再写一个 .button 也互不干扰。作用域是工具保证的,不是靠人盯出来的——这是它比 BEM 高一个台阶的地方。
CSS Modules 的优势全是工程上的:零运行时(编译期完成)、和 React 组件结构一一对应(一个 .tsx 配一个 .module.css)、类型安全(配合 *.d.ts 声明能拿到 styles.button 的自动补全)。它的局限是只解决作用域,不解决设计系统——颜色、间距、变体管理这些都还得自己搞,通常配合 CSS 变量或预处理器(Sass/Less)。
/* Button.module.css + Sass 变量 + 组合 */
.button {
composes: base from './shared.css'; /* 组合复用 */
background: var(--color-brand);
}
CSS Modules 适合:React/Vue 组件化项目、希望样式和组件物理共存(一个组件一个样式文件)、对运行时性能敏感(首屏不能有 CSS-in-JS 的 hydration 成本)。Next.js 默认就支持 .module.css,开箱即用。
四、CSS-in-JS:把样式塞进 JS,拿到真正的组件作用域
CSS-in-JS 的核心洞察是:既然 JS 有作用域,把 CSS 写进 JS,不就有了真作用域? 它把样式当成组件的一部分,而不是分离的资源。以 styled-components 为例:
import styled from 'styled-components';
const Button = styled.button<{ primary?: boolean }>`
padding: 8px 16px;
border-radius: 8px;
background: ${props => props.primary ? '#2563EB' : 'transparent'};
color: ${props => props.primary ? '#fff' : '#2563EB'};
`;
// 用法和普通组件一样
<Button primary>点击</Button>
这把 CSS 完全拉进了组件的闭包。styled.button 模板字符串里的样式只属于这个 Button,运行时生成一个唯一的类名注入到 <head>。它的杀手锏是真正的动态——props.primary 直接进样式,主题切换、用户偏好、运行时状态全部顺理成章。
emotion 走类似的路,API 更灵活,性能略好。两者早期都是运行时方案:样式在浏览器里实时计算、生成、注入。这带来三个痛点:
- 运行时成本:包体积、样式计算、
<head>注入都有开销,首屏 hydration 慢半拍。 - SSR 复杂:服务端渲染要把样式提取出来串到 HTML 里,框架集成需要额外配置。
- DevTools 难读:浏览器里看到的是
.sc-1x2y3z这种哈希类名,排查要打开 React DevTools。
近年的趋势是零运行时(zero-runtime)CSS-in-JS,代表是 linaria、vanilla-extract、panda-css、compiled。它们在编译期把样式提取成普通 .css 文件,运行时零开销,同时保留「样式在组件里写、能引用 JS 变量」的开发体验:
// vanilla-extract:编译期求值,运行时零成本
import { style } from '@vanilla-extract/css';
export const button = style({
padding: '8px 16px',
background: vars.color.brand, // 引用设计 token
});
CSS-in-JS 适合:
- 强动态主题:用户可定制主题(白标产品、可视化编辑器)、运行时颜色频繁变化——运行时方案无可替代。
- 高度组件化的设计系统:样式、逻辑、类型绑死在一个组件里,可移植性最强。
- 新项目且接受零运行时:
vanilla-extract/panda-css是当下最值得考虑的方向,既享受了「CSS-in-JS 的开发体验」,又没有性能包袱。
警告:运行时 CSS-in-JS(styled-components/emotion)在 SSR 重、性能敏感的项目里,首屏开销是实打实的。新项目别无脑上,先想想动态到什么程度。
五、原子 CSS(Tailwind/Windi):不要类名层
原子 CSS 走的是另一极端:干脆不要语义类名,直接写 utility。flex items-center gap-4 p-4 rounded-lg bg-blue-600 一长串就是样式本身。
<button className="inline-flex items-center gap-2 rounded-lg bg-blue-600 px-4 py-2 text-white hover:bg-blue-700">
点击
</button>
它和前面几种模式是完全不同的范式:
- BEM / Modules / CSS-in-JS 都是「先抽象语义类,再写样式」,本质是抽象层。
- 原子 CSS 是「不要抽象层,直接用约束好的原子拼」,把抽象层让给设计 token(config 里的颜色、间距、字号)。
它的红利是:JIT 按需生成、产物极小、不用想类名、设计 token 收敛在 config 里。代价是 className 巨长、抽象要靠 React 组件或 cva 自己做(这部分我们在《Tailwind CSS 工程化实践》里详细讲过,这里不展开)。
原子 CSS 适合:React/Vue 等组件化项目、想要最小产物体积、设计系统能收敛成 token。不适合:无框架的纯静态站(没有组件抽象层来兜底 class 堆积)、强动态主题(原子类对运行时切换主题支持弱)。
六、CSS 原生新方案:浏览器在吸收这些模式的优点
过去几年 CSS 标准在快速吸收上面这些模式的思路。三个值得重点关注:
原生嵌套(Nesting)——Sass 的嵌套进了标准,不用预处理器:
.card {
padding: 16px;
&__title { font-size: 18px; }
&:hover { background: #f5f5f5; }
&.featured { border: 2px solid #2563EB; }
}
Cascade Layers(@layer)——终于能显式控制层叠顺序,不再靠选择器特异性和源码顺序:
@layer reset, framework, components, utilities;
@layer reset {
* { box-sizing: border-box; margin: 0; }
}
@layer framework {
/* 第三方库样式 */
}
@layer components {
.button { /* 优先级低于 utilities,高于 framework */ }
}
@layer utilities {
.text-center { text-align: center; } /* 总是赢,可预测 */
}
这是 CSS 第一次给了开发者声明式地管理优先级的能力。utilities 层放原子类,components 层放组件类,框架的样式压在最底下,谁赢一目了然——再也不用 !important。
@scope——原生作用域,真正意义上让 CSS 拥有了「块作用域」:
@scope (.card) {
:scope { padding: 16px; }
.title { font-size: 18px; } /* 只在 .card 内部生效 */
}
这接近 CSS Modules 的语义,但由浏览器原生支持,不需要编译。
这些标准让 CSS 本身开始具备「架构」能力。短期内它们不会取代现有方案(浏览器兼容、工具链支持、团队习惯都要追),但方向很清楚:社区十年摸出来的模式,正在沉淀回 CSS 标准。新项目可以开始用 @layer 管理层叠(降级成本极低),@scope 和原生嵌套在渐进增强的场景里值得试。
七、选型:别问哪个最好,问你的约束
最后给实战判断。没有「最好」的 CSS 方案,只有「最匹配你的约束」的方案。
- 新项目、React/Vue 单框架、设计系统能收敛成 token → Tailwind 或 CSS Modules。Tailwind 产物最小、开发最快;CSS Modules 更接近传统心智、SSR 零负担。两者都比 CSS-in-JS 简单。
- 强动态主题(白标、可视化编辑器、运行时频繁切色) → 运行时 CSS-in-JS(
styled-components/emotion)。这种场景它的动态能力无可替代。 - 新项目但想要 CSS-in-JS 的开发体验且没运行时成本 →
vanilla-extract或panda-css。这是 2024 年后最值得押注的方向。 - 传统多页、jQuery 老项目、或同一套 CSS 要跨框架复用 → BEM。框架无关是它不可替代的优势,而且零迁移成本。
- 大型设计系统 / 组件库 → 原子 CSS +
cva,或 CSS Modules + CSS 变量。重点是 token 收敛和变体管理,方案是手段。
几个反直觉但重要的判断:
- 不要在一个项目里混用多种范式。BEM + CSS Modules + styled-components 全上的项目,维护成本是单一方案的三倍。选一个,贯彻到底。
- 新项目默认不要选运行时 CSS-in-JS。除非你有强动态主题需求,否则 Tailwind/CSS Modules/零运行时方案都更好。运行时 CSS-in-JS 的红利期已经过了。
- BEM 不是过时,是被高估的「过时」。在没有框架、或跨框架的场景里,它依然是最稳妥的选择。把它当成「最低成本的作用域方案」。
小结
- CSS 架构的本质是造组件作用域:BEM 用命名约定、Modules 用编译期哈希、CSS-in-JS 用 JS 闭包、原子 CSS 干脆不要类名层——四条路解决的是同一个问题。
- BEM 框架无关、零工具成本:适合传统项目、多框架复用、纯静态站。代价是靠纪律维持。
- CSS Modules 是 React 项目最稳的默认:编译期作用域、零运行时、SSR 无负担,心智成本最低。
- CSS-in-JS 看场景:运行时方案(styled-components/emotion)适合强动态主题,零运行时(vanilla-extract/panda-css)是新项目首选。
- 原子 CSS 走另一条路:不要抽象层,设计 token 是地基,但要配组件抽象才不失控。
- CSS 标准正在吸收这些模式:
@layer/@scope/ 原生嵌套让 CSS 自带架构能力,值得关注但短期不取代现有方案。
金句:没有最好的 CSS 架构,只有最匹配约束的——选一个、贯彻到底,比来回横跳十年都强。
Related