很多人最开始接触 BEM 命名法,都是冲着“解决 CSS 命名冲突”去的,用了两年之后又会开始犹豫:为什么项目里照样乱?为什么照样有人写出.card__header__title__text这样的类名?我的经验是,BEM 被严重低估了,也严重被误读。它看起来只是三行命名规则,但真正的难点在命名之前——你要先搞清楚一个组件里的哪些东西是块,哪些是元素,哪些是状态,然后才轮到下划线怎么写。这篇文章想做的,就是用庖丁解牛的方式,把 BEM 这头牛从骨架到筋膜完整拆一遍,把我在一线项目里踩过的坑、总结的判断标准、以及和现代工程化工具配合的姿势,都摊开来讲。
1. 解其骨:命名混乱到底坏了多少事
1.1 一个发展中的项目是怎么被命名拖垮的
先说一个我 2019 年接手的真实项目:一个运营后台,有三十多个页面,CSS 全部手写,命名风格大概有三种。早期的人用短横线.user-card,中期的人用驼峰.userCard,后期的人用 BEM 但不彻底,.user-card_item都有。这个项目真正崩塌的节点不是功能难写,而是所有人不敢改样式了。
举个例子,想要给某个按钮加一个disabled灰色背景,你得先全局搜索.btn,结果出来六百多处,里面既有按钮,又有标签页控件,还有一些表格行。没有人敢确定改这一处不伤其他页面,于是团队养成了一个很诡异的习惯:给新页面复制一份类似的按钮类名再改一改。类名越来越多,样式之间互相覆盖,important出现频率越来越高,最后只能靠“谁最后加载谁生效”来决定样式,连靠前的人都说不准。
这不是管理问题,是命名体系的问题。一套结构化命名方法的缺失,会让样式表的维护成本随着代码量指数级上升。BEM 的价值恰恰在这里:它不直接帮你写样式,它帮你把“这个类属于哪个组件、描述的是结构还是外观状态”这件事,在任何人都能看懂的前提下,在代码里标记出来。
1.2 “什字路口”式的命名方案
你可能会觉得,只要团队规定一种命名风格不就行了?可惜事情没那么简单。kebab-case、camelCase、PascalCase之间的区别只是表皮,真正的问题是语义层面:一个人写.card-title,另一个人写.cardTitle,两个人其实表达的是同一个东西,却因为风格不同,导致搜索的时候搜不全,改样式的时候判断不了全局影响。
BEM 最强的地方,正是把这个问题解构了:Block(块)、Element(元素)、Modifier(修饰符)三个角色,对应三种命名模式,任何类名只要看一眼,就能判断它属于哪一层。这就好比在你家书架上每本书都贴了分类标签,找书和放书都变得有规律可循。命名体系不是表面形式,它是代码可读性的骨架,骨架正了,后面的肉和筋才有地方挂。
2. 解其肉:Block Element Modifier 三层命名只有三件事
2.1 Block(块):独立、自足、可复用的组件单元
Block 是 BEM 的基石,它代表一个“独立存在的 UI 组件”。独立指的是不依赖父容器也能成立。比如一个.search-form放在头部是搜索框,放到侧边栏还是搜索框,放到移动端底部也一样。这也意味着 Block 在设计时就要尽量做到上下文无关,不写依赖父级选择器的样式。
一个 Block 可以是简单的按钮.btn,也可以是复合组件,比如.member-card(成员卡片),内部可以包含头像、姓名、描述、状态标签,但对外暴露的入口永远只有一个 Block 名。
这里有一个关键判断标准:如果一个组件的复用仅限单一页面,它可能只是一个元素;如果它被放置在不同页面、不同容器里都能独立工作,那才配叫 Block。
2.2 Element(元素):必须隶属于 Block 的组成部分
Element 是 Block 内部的一部分,不能脱离 Block 单独存在。比如.member-card__avatar(头像)、.member-card__name(姓名),脱离了 member-card 这个整体,它们没有任何独立意义。命名语法就是block__element,双下划线连接。
这里容易犯的第一个错误是嵌套过深:.card__header__title__text这种。BEM 官方并不支持元素里再套元素,因为一旦允许无限嵌套,类名会变得又长又脆弱。如果你发现一个 Element 结构太复杂,比如.member-card__header里要拆很多东西,正确的做法是无中生有,把.member-card__header升级为一个新的 Block(比如.card-header),或者把它里面需要细分的部分抽成另一个独立的 Block 放在原 Block 内部。
2.3 Modifier(修饰符):同一种结构的差异化
Modifier 描述一个块或元素的外观、状态、行为的变化。语法是block--modifier或者block__element--modifier,用双横线连接。比如按钮有.btn--primary、.btn--large、.btn--disabled,标签页有.tabs__item--active。
Modifier 的核心价值在于:它和原始类名耦合出现,HTML 里是class="btn btn--primary",CSS 里是把公共样式放到.btn,差异样式放到.btn--primary。这种做法避免了你去动态拼接类名或者是复制粘贴一大段基础样式。Modifier 对应的是可枚举的状态集合,primary、large、active、disabled,而不是一个可以无限取值的变量,这一点后面我还会展开讲。
2.4 基础语法速查
| 命名模式 | 语法 | 示例 | 语义 |
|---|---|---|---|
| Block | .block | .search-form | 独立组件 |
| Element | .block__element | .search-form__input | 组件内部的组成部分 |
| Modifier(块级) | .block--modifier | .search-form--compact | 组件的变体或状态 |
| Modifier(元素级) | .block__element--modifier | .search-form__input--error | 元素的具体状态 |
这套语法不是随便定的。双横线--和双下划线__之所以比单横线更“重”,是因为单横线经常出现在单词拼接里,.btn-small会被误判成“这个名字里 right 部分可能是个 block 而 left 部分是形容词”,造成语义歧义。双分隔符就是为了在类名里划出清晰的边界,让机器和人都能一眼区分角色。
3. 解其筋:从组件模型出发的实操命名流程
3.1 第一步先切块,构建组件地图
这一步很多人跳过了,于是命名全凭感觉。我的标准做法是:拿到视觉稿后,先用正方形画出页面里的独立组件,每个框就是一个候选 Block。框与框之间不重叠、不嵌套。举例,一个成员管理页面可以拆出:筛选栏.filter-bar、列表容器.member-list、成员卡片.member-card、分页器.pagination、空态提示.empty-state。
这时候有个很容易犯的错误:把视觉上连续的部分硬拆成多个 Block。比如一个卡片内部有头部区域、内容区、底部操作区,这些区域是卡片的一部分,不是独立组件,所以用 Element 表达而不是 Block。
切块有个简单的判断口诀:“拎出来还能说的清是什么”才配当 Block。member-card拎出来你知道它是什么;member-card__header拎出来如果不带上卡片这个上下文,它就什么都不算,所以它是 Element。
3.2 第二步辨元素,只给有结构语义的节点命名
Block 内部不是所有 DOM 节点都需要类名。样式的目标有三类:层级布局(如 flex 容器)、视觉呈现(如背景色),以及状态控制(如条件渲染)。如果一个标签只是为了包一层、没有实际样式职责,那它就没有存在的必要,更不需要 BEM 命名。
比如 card 内部只有一个div包裹头像和文本,这个div既没布局职责也没视觉职责,完全可以去掉,类名数量自然降下来了。
真正需要元素类名的节点,通常是这几种:
- 承载文本内容的标签:标题、描述、注释
- 承载多媒体内容的标签:图片、图标、视频
- 功能控件:输入框、按钮、链接
- 布局容器:卡片头部、主体、底部操作区
对应到示例上:
<article class="member-card"> <div class="member-card__header"> <img class="member-card__avatar" alt="avatar" /> <h3 class="member-card__name">李雷</h3> <span class="member-card__status">在线</span> </div> <p class="member-card__desc">前端工程师,负责会员增长方向。</p> <div class="member-card__actions"> <button class="btn btn--primary">发消息</button> </div> </article>注意这里.btn是另一个 Block 直接被放进member-card__actions内部。Block 与 Block 之间的嵌套是完全允许的。member-card__actions是一个布局容器元素,它内部挂载独立按钮 Block,这种逻辑非常清晰。
3.3 第三步提修饰,把“变化”从“基础”里解放出来
写完结构类名之后,再检查一下每个 Block 和 Element 身上有哪些视觉或者行为上的差异。差异点从两个视角提取:一是同一页面里相同组件出现多次时各自不同的部分;二是同一组件在不同断点或者不同用户状态下需要切换样式的部分。
比如成员卡片在同一列表里有“在线”和“离线”两种状态,背景色和边框不同,这就可以抽象成.member-card--offline或.member-card--online。但这里有一个更细的问题:在线和离线是业务语义,直接做类名会让 Block 和业务耦合太重。常见的做法是做成通用状态修饰符.member-card--active和.member-card--inactive,状态值交给人来映射。
如果差异点是多个值的,比如按钮有 primary、secondary、ghost 三种,用 Modifier 就非常合适:
.btn { padding: 8px 16px; border-radius: 4px; } .btn--primary { background: #07c; color: #fff; } .btn--secondary { background: #eee; color: #333; } .btn--ghost { background: transparent; border: 1px solid #07c; color: #07c; }我见过不少团队把这种差异做成了.btn-primary、.btn-secondary各自成套,基础样式复制三份,结果后期改圆角改三处,这恰恰是 BEM 想消灭的低效。
4. 解其髓:修饰符的控制边界与嵌套分寸
4.1 状态修饰符和变体修饰符的界限
Modifier 可以再分两类:状态式 Modifier 与变体式 Modifier。
状态式描述的是瞬时状态,比如.btn--loading、.tabs__item--active、.member-card--selected,它们通常由用户交互动态添加或移除。变体式描述的是相对固定的外观分类,比如.btn--primary、.alert--success,一般在渲染时就确定,不随交互变化。
这两者的工程意义不太一样。状态式 Modifier 经常配合 JavaScript 来切换,所以我倾向于在 JS 里只用状态类来控制逻辑,不让内联样式参与显示切换。变体式 Modifier 则更像组件的 API——调用方只要说“我需要一个 danger 类型的按钮”,组件就能输出.btn--danger。如果混为一谈,组件长期迭代后状态类会膨胀到不可维护。
4.2 不要在 __elems 里无限嵌套
BEM 的官方范例中没有block__elem1__elem2。理论上每个 Element 都直属于 Block。实际操作时你会发现有些结构确实需要三层以上,我的建议是:遇到这种情况先停下来,问自己“这个 Element 是否已经复合到可以被抽象成一个独立 Block?”举个例子:
.member-card__header__title如果觉得别扭,说明.member-card__header本身已经承担了足够复杂的布局职责,可以把它抽成.card-headerBlock,标题变成.card-header__title。这样看起来是重构,但本质上你是在把组件边界画得更清楚。
当然也存在一些特例,比如.page-header__title__icon,通常是某个深层节点无法再拆的时候被迫写出来的。真到了那一步,我宁愿用一个语义更直接的类名.page-header__title-icon(单横线表示复合词),也不愿意堆出三层下划线。规则是死的,人是活的,但“尽量两层”的原则能保证类名长度和可读性。
4.3 修饰符组合的顺序与可读性
一个元素同时有多个修饰符的时候(比如一个既 active 又 large 的标签),HTML 里写class="tabs__item tabs__item--active tabs__item--large"。一个常见争议是:到底用tabs__item--active-large这种复合修饰符,还是拆成多个。
我强烈建议拆开。复合修饰符会污染类名空间,每多一种组合就等于新造了一个类名,而拆开只是任意两组修饰符的排列组合,两个类都能匹配到对应的基础样式。组合顺序也不必纠结,BEM 没有规定顺序,我自己的习惯是状态类在前,尺寸类在中,风格类在后,保证团队统一就可以。
4.4 Element 的省略技巧
实际写的时候,有些元素是不需要出现在类名里的。比如.member-card__header只是一个 flex 布局容器,它的样式就一行display:flex; align-items:center; gap:8px;,这种纯布局容器也可以不命名,直接把 flex 写在.member-card上然后用.member-card__avatar来控制自身间距,省掉一个层级。
我的原则是:如果某个 Element 只承载布局、没有独立的视觉语义,而且它底下只有一个子元素,那就去掉这层容器;如果它有多个子元素需要做横向或纵向排布,而它的布局属性会直接影响这些子元素的排列,那保留.member-card__header是合理的。
5. 解其器:SCSS、Vue/React 工程化与 BEM 的协作方式
5.1 SCSS 的 & 符号写 BEM 有三层坑
很多人在 SCSS 里用嵌套写 BEM,语法本身没错,但会踩几个坑。第一个坑是过度嵌套导致选择器特权度升高。比如写成:
.member-card { .member-card__name { font-size: 16px; } }实际编译出来是.member-card .member-card__name,这是一个后代选择器,优先级变成了 0,2,0,而直接写.member-card__name是 0,1,0。这会导致以后想覆盖样式的时候必须同样用后代选择器,层级越来越深。正确的做法是用@at-root把嵌套写法的类名拉回根级:
.member-card { @at-root { &__name { font-size: 16px; } &--active { border-color: #07c; } } }这样编译出来.member-card__name和.member-card--active都是平级类名,选择器权重不会积累。第二个坑是编辑器里的自动缩进容易让人忽略从属关系,类名层次混乱。第三个坑是在修饰符内部继续嵌套元素选择器,比如.member-card--active .member-card__name,这个跟上面的问题一样,应直接写成.member-card--active .member-card__name的逻辑尽量用.member-card__name--active或直接给元素加 Modifier 来表达。
5.2 CSS Modules 和 scoped 到底还需不需要 BEM
先说结论:需要,但侧重点不同。
CSS Modules 解决的是“类名冲突”的问题,它会把局部类名编译成带哈希的全局唯一类名。但 CSS Modules 不解决“语义是否统一”的问题,同一个组件在两个团队手里可能会叫不同的名字。BEM 解决的是语义的统一与层级关系的清晰表达,这两者并不冲突,实践中可以叠加:用 CSS Modules 做作用域隔离,用 BEM 做组件内结构的语义编码。
Vue 的scoped属性会在选择器末尾加>// .stylelintrc.json { "rules": { "selector-class-pattern": "^[a-z][a-z0-9]*(-[a-z0-9]+)*(__[a-z0-9]+(-[a-z0-9]+)*)?(--[a-z0-9]+(-[a-z0-9]+)*)?$" } }
这个正则的含义是:Block 名可以带短横线(member-card),可选一个__元素,可选一个--修饰符,元素和修饰符内部也可以带短横线。基本覆盖 BEM 的完整形态。CSS 预编译的类名,比如 Vue 里的:class动态字符串,只要最终渲染出来的类名符合规范,一样会被规则约束。团队的 onboarding 成本一下就降下来了,不需要有人天天提 PR 审查要求。
6. 解其域:BEM 的适用边界与同行方案的选择逻辑
6.1 哪些场景 BEM 确实不够好
BEM 不是万能的。做组件库的底层样式的时候,原子化 CSS(utility-first,比如 Tailwind)在效率上确实有优势:改一个内边距间距不用新起一个类,不用去设计命名,纯粹就是组合工具类。加上现代 CSS 里层叠层(@layer)的出现,也可以更优雅地处理不同来源样式的优先级问题。
BEM 在两类项目里会显得笨重:一类是纯展示型页面,消费者不需要知道结构层级,只要元素换肤调整即可;另一类是高度原子化的设计系统,里面大量样式都是p-4、flex、text-sm这种通用原子类,BEM 反而会跟它们叠加成冗余类名。
我自己的项目选型原则是这样的:
- 组件库内部、多端复用、长期演进:选 BEM,因为 API 语义稳定,调用方通过 Modifier 来控制变化
- 营销页、活动页、快速原型:直接 utility-first,因为页面一次性,性能优先
- 大型业务系统:两者混合,组件层用 BEM,布局层和细节微调用 utility
6.2 同行方案对照
| 方案 | 核心机制 | 优势 | 短板 | 和 BEM 关系 |
|---|---|---|---|---|
| BEM | 命名约定表达结构和状态 | 语义清晰、无构建依赖、可读性强 | 类名较长、需要团队纪律 | 基准方案 |
| CSS Modules | 编译期生成本地类名 | 自动隔离、不必想全局唯一名 | 调试困难(哈希类名)、层叠语义弱 | 可叠加 |
| CSS-in-JS | 组件内直接写样式对象 | 局部隔离、动态样式天然友好 | 运行时开销、SSR 样式抽取复杂 | 概念冲突 |
| Tailwind / 原子化 | HTML 里组合工具类 | 写起来快、类名几乎不用自己想 | 可读性依赖工具类的熟练度 | 可混用 |
6.3 BEM 在大型团队里最容易失效的组织原因
BEM 失效通常不是因为语法,而是因为组织没有建立组件分层。当团队里任何人都能新造一个 Block、随意添加 Modifier,而组件清单没有评审时,BEM 会退化成“只是长一点的随意命名”。所以光有命名规范远远不够,还要配套组件代码规范、Storybook 维护规范和评审流程。规范的价值只有依托流程才能被维持。
落到我自己的团队,我们有一起“组件命名会议”:新页面拆模之后,命名清单先过一轮评审,重点就是判断新起的类名到底配不配 Block,现有 Block 能不能复用,Modifier 是否应该收敛。这一步消耗的时间不多,但避免了百分之八十的后续返工。
写在最后:我对 BEM 的真实态度
如果你问我,BEM 是不是最好的命名方案?我的回答是:在我参与过的三四十人规模的前端团队里,它是最能让不同水平的人写出相似代码的方案。你可以不认同它在具体项目里的表现,但它的核心价值——强制你把 UI 解构成清晰的层级模型,再把这些层级映射到命名上——这个思路永远不过时。我见过太多团队从 BEM 迁移到 CSS Modules 之后问题依旧,本质原因是他们没有带走 BEM 的思考方式。真正能救项目的不是某一个规范,而是一套稳定的、全员共享的组件拆分心智模型。BEM 只是把这种心智模型外化到了类名上而已。方法论都是可以迭代的,但问题意识和分层习惯一旦养成,后面用什么工具都不会太差。