#React#TypeScript

CSS 架构模式:BEM、CSS Modules 与 CSS-in-JS

CSS 一多就乱:命名冲突、全局污染、优先级战争。本文讲清几种主流 CSS 架构模式:BEM(命名约定)、CSS Modules(局部作用域)、CSS-in-JS(styled-components/emotion)、以及 Tailwind/原子 CSS 的崛起,各自适用与怎么选。

本文要点

  • 主题聚焦:React、TypeScript
  • 适合人群:独立开发者、全栈工程师、AI 应用开发者
  • 核心价值:快速理解背景、实践路径、常见误区和工程建议
作者 @admin·发布于 2026年5月29日·更新于 2026年7月23日

写个 demo 页面,CSS 怎么写都好看;写个正经应用,CSS 怎么写都乱。一两年下来,button 类被五个人覆盖过八次,main 这个类名同时出现在登录页和首页却长得完全不一样,改一个 padding 不知道会牵动几个页面,!important 满天飞还是压不住优先级。这些问题不是 CSS 这门语言不行,是没有架构。CSS 天然全局、天然层叠、天然基于源码顺序——这三个「天然」放在一起,规模一大就一定是灾难。

社区过去十多年沉淀出几条不同的路:BEM 靠命名约定避开冲突,CSS Modules 靠编译时作用域消掉冲突,CSS-in-JS 靠把样式塞进 JS 拿到组件作用域,原子 CSS(Tailwind)干脆不要类名层。每种方案都是对同一个问题的不同回答。这篇文章把这几条路讲清楚,并给出新项目怎么选的实战判断。

一、CSS 为什么需要架构

先确认我们要解决的问题。CSS 在大规模下会爆四类雷:

  1. 全局污染。任何 .btn 都是全局名,你以为是按钮组件,其实全站有十几个 .btn,改一处炸一片。
  2. 命名冲突。main、wrapper、container、title 这些名字人人都用,跨文件碰撞是常态。
  3. 优先级战争。#header .nav li a 和 .nav-link 谁赢,取决于选择器特异性和源码顺序。最后大家比谁的选择器更长、!important 更多。
  4. 复用失控。同一个按钮的样式被复制五遍,改设计稿时改不干净。

这四类问题的根因只有一个: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。
html
<article class="card card--featured">
  <img class="card__image" src="..." />
  <h2 class="card__title card__title--large">标题</h2>
  <p class="card__body">正文</p>
</article>
css
.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。

css
/* Button.module.css */
.button {
  padding: 8px 16px;
  border-radius: 8px;
}
.primary {
  background: #2563EB;
  color: #fff;
}
tsx
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)。

css
/* 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 为例:

tsx
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 更灵活,性能略好。两者早期都是运行时方案:样式在浏览器里实时计算、生成、注入。这带来三个痛点:

  1. 运行时成本:包体积、样式计算、<head> 注入都有开销,首屏 hydration 慢半拍。
  2. SSR 复杂:服务端渲染要把样式提取出来串到 HTML 里,框架集成需要额外配置。
  3. DevTools 难读:浏览器里看到的是 .sc-1x2y3z 这种哈希类名,排查要打开 React DevTools。

近年的趋势是零运行时(zero-runtime)CSS-in-JS,代表是 linaria、vanilla-extract、panda-css、compiled。它们在编译期把样式提取成普通 .css 文件,运行时零开销,同时保留「样式在组件里写、能引用 JS 变量」的开发体验:

ts
// 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 一长串就是样式本身。

tsx
<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 的嵌套进了标准,不用预处理器:

css
.card {
  padding: 16px;
  &__title { font-size: 18px; }
  &:hover { background: #f5f5f5; }
  &.featured { border: 2px solid #2563EB; }
}

Cascade Layers(@layer)——终于能显式控制层叠顺序,不再靠选择器特异性和源码顺序:

css
@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 拥有了「块作用域」:

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 收敛和变体管理,方案是手段。

几个反直觉但重要的判断:

  1. 不要在一个项目里混用多种范式。BEM + CSS Modules + styled-components 全上的项目,维护成本是单一方案的三倍。选一个,贯彻到底。
  2. 新项目默认不要选运行时 CSS-in-JS。除非你有强动态主题需求,否则 Tailwind/CSS Modules/零运行时方案都更好。运行时 CSS-in-JS 的红利期已经过了。
  3. 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

相关推荐