☰
BEM命名法实战指南:从原理到组件级CSS重构
2026/10/1 18:47:34 网站建设 项目流程

接手过一个朋友转交的前端项目,说是只改了半年需求,代码还能跑。打开样式文件那一刻我差点把水喷屏幕上——三千多行的 CSS,类名从div1、red-btn、left-wrap、aaa到box、box2、box2-new、box2-final什么都有,同一个含义的样式散落在七个地方,改一个按钮的圆角得全文件搜三遍。当时我就想,要是写这项目的人一开始就懂 BEM 命名法,后面接手的人也不至于活得这么累。

BEM 不是框架,不是工具库,就是一套 CSS 类名书写约定。它把页面上的每一个视觉单元看作是独立的“块”,块内部的组成部分叫“元素”,元素的某个外观状态或者行为状态叫“修饰符”,然后通过双下划线和双连字符把这三层关系直接写进类名里。听起来简单,但真用明白的人不多。这篇就把 BEM 从原理到实操、从坑到解彻底拆开,拿实际项目模块走一遍完整流程,让看完的人明天就能在自己项目里落地。

1. 先从“为什么”说起:BEM 到底在解决什么问题

1.1 混乱命名的根源:CSS 没有作用域

写 CSS 和写 JS 有一个根本性的差异:JS 有函数作用域、块级作用域,变量不会莫名跑到别的地方去影响别人。但 CSS 里一旦你写了.title { color: red; },它就是全局的,页面上所有叫title的元素全都会变红。这个特性在早期页面简单的时候无所谓,但到了组件化、多人协作、长期迭代的时代,就成了灾难的源头。

举个例子,A 同事写了个.box,B 同事也写了个.box,两个人都没看对方的代码,结果覆盖规则互相打架。症状就是:明明我在这个组件里加了font-size: 14px,保存刷新之后却发现字号还是 12px,查了半天,罪魁祸首是另一个文件里更靠后的.box规则。这种问题在传统的 CSS 写法里几乎无法根治,只能靠团队纪律强行约束——“命名别撞车啊”,但人的记忆力是有限的,几百个页面之后谁也管不住自己。

BEM 做的第一件事,就是把“选择器名字不可重复”这条隐形的纪律,变成了写着写着就自动遵循的规则。

1.2 BEM 的三层模型:Block、Element、Modifier

BEM 的全称是Block Element Modifier,翻译过来就是“块、元素、修饰符”。它把页面上的东西拆成三个角色:

角色含义命名写法举例
Block独立的、可复用的组件块,语义上像名词普通单词或中划线连词.menu、.search-form、.user-card
Element块的组成部分,依赖块才能存在,语义上是块的零件block__element.menu__item、.search-form__input
Modifier块或元素的外观、状态、行为的变化,语义上是形容词block--modifier或block__element--modifier.menu--dark、.menu__item--active

这三个角色的写法各有讲究。下面逐一拆开看。

1.3 为什么用双下划线和双连字符

用.menu-item这种中划线连接,语义是“菜单项”,但它到底是 Block 还是 Element 无从知晓。用.menu__item,双下划线一眼就能看出item是menu的一部分,脱离不了menu。用.menu--dark,双连字符一眼就能看出dark是对menu整体的一种修饰状态。

这看起来只是符号选择,但符号承担了“视觉语法”的作用。程序员扫过类名,不需要看 HTML 结构就能判断:

  • 看到双下划线,马上知道前面是父块,后面是子元素;
  • 看到双连字符,马上知道这是一个状态变体;
  • 只有单词没有特殊符号,那它多半是一个独立块,可以在任何页面复用。

这种可以“扫描”的语法,在代码评审、接手旧项目、跨文件搜索时价值巨大。Ctrl+F搜card__title得到的就是卡片标题相关的全部样式和结构出现位置,不会误伤别的.title。

提示:BEM 类名中间只有一个双下划线,不会出现card__header__title。如果一个元素嵌套太深,说明你需要把这个“深层元素”拆成一个新的块,而不是继续往下钻。这是 BEM 的一条核心原则,后面细说。

2. 庖丁解牛:BEM 的三个角色到底各自管什么

2.1 Block:独立的命名空间容器

Block 是一个“命名空间容器”。它圈定了一个范围,里面所有子元素的类名都以它作为前缀。这个前缀的存在让样式的作用域边界变得异常清晰——.card里所有样式只影响.card内部的东西,不会流到外面去。

选一个 Block 名字的标准是:

  • 用名词,不用动词;
  • 它能用于去掉“页面位置”描述(比如home-header这种名字不好,因为脱离了首页这个块就不能复用;叫site-header才是好块);
  • 一个块可以被反复使用。

说得更直白一点,header是好的 Block 名,homepage-header就带着页面依赖;button是好的 Block 名,red-button就把样式写死进名字里了,哪天要改成蓝色就完蛋。块名的目标是描述“组件本身是什么”,而不是描述“它在哪个位置”“长什么样”。

在设计一个 Block 时,一个重要判断标准是“能否独立存在”。卡片能独立存在,所以.card是块。搜索框能独立存在,所以.search-form是块。但是卡片里的标题不能独立存在,它是卡片的零件,所以它是.card__title而不是一个独立的.title块。

2.2 Element:零件与主人的从属关系

Element 是块的内部零件,命名形式是块名__元素名。它只有在所属块内才有意义。去掉card__前缀,剩下的title根本没有语义,这恰恰是 BEM 想表达的——它离不开父块。

Element 命名的细节有几个值得注意的点:

  • 类名里不要出现 HTML 标签名,.card__div、.card__span这种是坏味道。应该写.card__body、.card__content,用“在这个块里的角色”来命名,而不是用“用什么标签实现”。这样做的好处是元素可以随意换标签,类名不用变。
  • 不用不用 ID 和标签选择器来辅助定位,避免权重问题。BEM 配合.card__title这种单类选择器,天然把层叠权重维持在同一个水平线上,后续覆盖样式非常轻松。
  • Element 理论上可以嵌套,但在类名上不表现嵌套关系。HTML 结构是.card > .card__header > .card__title,但类名都是扁平的,没有.card__header__title这种三层钻取。保持扁平是为了可搜索性、可读性,也为了维持选择器最高性能。

“Element”与“子 Block”的边界是新手最容易混淆的地方。比如卡片里放了一个按钮组件,这个按钮是card__btn还是独立的.btn?答案是如果这个按钮在自己的项目里有独立的视觉规范、独立的状态管理,就应该用.btn块(很可能还有btn--primary、btn--ghost一类的修饰符),卡片里的.btn只是“内嵌了另一个块”。反过来说,如果这个按钮只是卡片特有的、没有也不要紧的普通操作,用.card__btn也没问题。

我自己的判断标准是:这个模块在自己的网站上出现两次以上,独立成块;只在这个父组件里出现一次,作为 Element。页面头部导航里的“搜索按钮”几乎每个页面都有,它就是.btn内嵌在.site-header里;但卡片里偶尔出现的自定义展开按钮,它大概率就是.card__toggle。

2.3 Modifier:状态和变体的唯一合法出口

Modifier 是 BEM 里最出彩也最容易被滥用的部分。它表达三种变化:

  • 外观变体:.card--featured(高亮的卡片)、.btn--primary(主按钮);
  • 结构变体:.layout--sidebar(带侧边栏的布局);
  • 状态变化:.menu__item--active(当前选中项)、.search-form__input--error(输入出错)。

写法和用法分别是:

<div class="card card--featured"> <!-- 内容 --> </div>
<button class="btn btn--primary">提交</button>

关键点在于,Modifier 永远不单独出现,它必须和原始块或原始元素一起写在同一标签上。class="btn--primary"而没写btn是无效的,因为修饰符只是“额外增量”,基础样式仍由原始块提供。

在代码组织上,Modifier 的样式应该紧挨着对应的 Block 或 Element 写,用 Sass 的&语法可以写得很优雅:

.btn { padding: 8px 16px; border-radius: 4px; &--primary { background: #1677ff; color: #fff; } &--ghost { background: transparent; border: 1px solid #1677ff; } }

这段 Sass 编译成 CSS 以后会生成.btn、.btn--primary、.btn--ghost三个平级规则,HTML 中把修饰符类名和基类名共同写入即可。

Modifier 使用上要注意“不要过度细分”。我以前见过有人写.btn--small--round--blue,这种组合不是 BEM 的初衷。如果两种变体经常一起出现,应该把它们提炼成一个新的独立变体,比如.btn--primary-small,或者使用额外的状态类来承载交互变化(后面会讲is-状态类的用法)。组合修饰符过多本质上说明这个组件的变体体系该重新设计了。

3. 实操之旅:用 BEM 重写一个真实组件

3.1 场景设定:用户卡片 + 搜索面板

现在拿一个典型后台系统中的常见组合来走一遍完整流程:一个“用户卡片列表页”,包含顶部搜索面板和若干用户卡片。

先看未使用 BEM 的混乱版本长什么样:

<div class="search-panel"> <div class="input-group"> <input class="text-input" placeholder="搜索姓名或工号"> <button class="btn-blue">查询</button> </div> </div> <div class="card-wrap"> <div class="card"> <div class="avatar avatar-lg"> <img src="..." alt=""> </div> <div class="info"> <div class="name">张三</div> <div class="email">zhangsan@example.com</div> <div class="tags"> <span class="tag tag-active">管理员</span> <span class="tag">已激活</span> </div> </div> <div class="actions"> <button class="op-btn edit-btn">编辑</button> <button class="op-btn del-btn">禁用</button> </div> </div> </div>

乍一看好像也还行,但只要后续迭代加需求,问题就藏不住了:tag-active到底是标签的结构还是状态?edit-btn和del-btn之间是什么关系?avatar-lg里的lg是该元素独有的吗?如果再有一个info在其他地方出现,选择器就直接冲突。改了.name的样式,可能影响全局所有叫 name 的类。

3.2 BEM 重构完整过程

先把用户卡片拆解成一个独立的块.user-card,因为它出现在列表里、详情页侧边栏、搜索结果的推荐位,完全符合“可复用独立块”的定义。

它的内部结构:

  • 块本身:.user-card(容器,含基础布局、白底圆角阴影)
  • 元素:__avatar(头像)、__name(姓名)、__email(邮箱)、__tag(标签)、__actions(操作区)、__btn(操作按钮)
  • 修饰符:.user-card--compact(紧凑模式,用在侧边栏)、.user-card__tag--highlight(重点标签)、.user-card__btn--danger(危险操作)

HTML 重构后长这样:

<div class="user-card"> <div class="user-card__avatar"> <img src="..." alt="用户头像"> </div> <div class="user-card__info"> <div class="user-card__name">张三</div> <div class="user-card__email">zhangsan@example.com</div> <div class="user-card__tags"> <span class="user-card__tag user-card__tag--highlight">管理员</span> <span class="user-card__tag">已激活</span> </div> </div> <div class="user-card__actions"> <button class="user-card__btn">编辑</button> <button class="user-card__btn user-card__btn--danger">禁用</button> </div> </div>

搜索面板也独立成.search-panel块,内部元素叫__input、__btn,修饰符用--expand(展开模式)等。搜索面板里那个查询按钮,在这里写search-panel__btn也没问题,因为在这个页面上它就是搜索面板的零件,没有独立复用需求。

重构后的几个关键决策和理由:

  • 头像不是普通图片元素,它自带圆角、尺寸、占位背景,在卡片和详情页都有,所以.avatar可以单独成块,配合avatar--lg修饰符。在卡片中它作为.avatar嵌套在.user-card里,不再写.user-card__avatar。这是“块中块”的经典处理方式。
  • 标签如果想要完全解耦,也可以做成.tag块配合tag--highlight。但如果整个项目只有卡片里用标签,直接写成.user-card__tag更节省,我自己偏好代码复用性优先,会做成tag块。
  • “禁用”按钮在文案上不是删除,是危险操作,用 Modifier 命名--danger而不是--delete。一个安全类修饰符名表达的是一种“危险级别”,未来如换成“封禁”“移除”,类名都不用改。

3.3 对应的 Sass 结构与样式细节

对应上面的 HTML,完整 Sass 可以这么组织,注意&语法让命名空间一目了然:

.user-card { display: flex; gap: 16px; padding: 16px; background: #fff; border: 1px solid #e8e8e8; border-radius: 8px; &--compact { padding: 8px; gap: 8px; .user-card__name { font-size: 14px; } } &__avatar { flex-shrink: 0; width: 56px; height: 56px; border-radius: 50%; overflow: hidden; } &__name { font-size: 16px; font-weight: 600; color: #1a1a1a; } &__email { font-size: 13px; color: #666; margin-top: 4px; } &__tags { display: flex; gap: 8px; margin-top: 8px; } &__tag { display: inline-flex; align-items: center; height: 22px; padding: 0 8px; border-radius: 4px; background: #f5f5f5; font-size: 12px; color: #666; &--highlight { background: #fff7e6; color: #d48806; border: 1px solid #ffd591; } } &__actions { margin-left: auto; display: flex; gap: 8px; align-items: center; } &__btn { height: 30px; padding: 0 12px; border: 1px solid #d9d9d9; border-radius: 4px; background: #fff; cursor: pointer; transition: all 0.2s; &:hover { border-color: #1677ff; color: #1677ff; } &--danger { border-color: #ff4d4f; color: #ff4d4f; &:hover { background: #fff2f0; } } } }

注意观察&--compact内部的.user-card__name写法。虽然嵌套在.user-card内部,但&--compact生成的类名是.user-card--compact,它的后代.user-card__name如果要覆盖样式,是会正常起作用的。Sass 里写嵌套选择器要小心,嵌套尽量不能太深,否则编译出的选择器层级会拖累性能。标准是:Sass 嵌套层级不超过三层,.user-card--compact .user-card__name这种两层已经接近极限。

3.4 状态类与 JS 钩子的边界处理

BEM 的 Modifier 一般用于“静态的、可描述的外观变体”,但像“菜单展开中”“弹窗打开”“按钮 loading”这种交互过程中动态变化的样式,适合用独立的状态类is-前缀来处理。

<button class="page-action__btn is-loading">保存</button>
.page-action__btn { &.is-loading { pointer-events: none; opacity: 0.6; } }

为什么用is-loading而不是.page-action__btn--loading?两个原因。第一,is-前缀在业界已被广泛理解为“交互动态状态”,团队成员看到is-active就能立刻反应出这是 JS 控制的临时状态,而--active修饰符可能被误认为一个静态主题。第二,状态类可以脱离组件存在,一个全局工具类.is-hidden,任何组件都能用。

JS 钩子的处理也建议单独隔离。BEM 强项是“展示样式”,但 JS 需要寻找某个 DOM 节点时,通行的做法是加一个js-前缀:

<button class="user-card__btn js-delete-user">styles/ components/ _user-card.scss _search-panel.scss _tag.scss layouts/ _grid.scss _sidebar.scss base/ _reset.scss _typography.scss

每个_user-card.scss文件只用.user-card做根类名,天然不会产生跨文件冲突。用 Sass 的@use引入到入口文件,整个样式体系就像搭积木一样可维护。

4.2 在 React/Vue 组件里用 BEM 的注意点

框架组件化以后,BEM 并没有过时,反而更贴合组件思想。以 React 为例,一个组件就是 Block,组件里的 JSX 结构天然就是 Element 名。常见配合写法:

const UserCard = ({ compact }) => ( <div className={`user-card ${compact ? 'user-card--compact' : ''}`}> <img className="user-card__avatar" src={user.avatarUrl} alt="" /> <div className="user-card__name">{user.name}</div> <div className="user-card__email">{user.email}</div> <div className="user-card__actions"> <button className="user-card__btn user-card__btn--danger">禁用</button> </div> </div> );

这时候有一个省心的工具:classnames 库。它专门用来拼接条件类名:

import classNames from 'classnames'; const UserCard = ({ compact, theme = 'default' }) => ( <div className={classNames('user-card', { 'user-card--compact': compact, 'user-card--dark': theme === 'dark' })}> ... </div> );

这样标签上的className表达式不再是一长串三元运算拼接,阅读和维护都舒服很多。

Vue 里同样好操作。模板里用简单的三元判断即可:

<template> <div class="user-card" :class="{ 'user-card--compact': compact }"> <span class="user-card__tag">管理员</span> </div> </template>

Vue 的:class对象语法本身就非常契合 BEM 的修饰符模型。

然而,在 Vue SFC 中用scoped样式时,BEM 会产生冗余问题。scoped会给每个元素加一坨><div class="card"> <div class="card__header"> <h2 class="card__header__title">标题</h2> </div> </div>

这是坏味道,原因有两个:类名越来越长,双下划线出现两次,视觉上脏;深层元素与父块的耦合度暴增,想把这个标题拿到别处复用只能重新命名。

合理的三种解决方式:

  1. 如果title只存在于这个卡片内,提升为直接元素.card__title,不需要在意它在 HTML 里处于header内部。BEM 的“元素”表达的是逻辑隶属关系,不是 DOM 层级关系。
  2. 如果header本身有独立的结构和复用需求,把header提成一个新块.card-header,此时标题可以写成.card-header__title。
  3. 干脆不设 header 这个 Intermediate 层,卡片头部只是几个元素的集合,直接定位到.card__title处理。

我的经验是:如果你不确定这个中间层将来会不会独立,先不要为它建立 Element 关系,直接扁平化,结构复杂了之后自然会有新块涌现。

5.2 问题二:Modifier 加在 Block 上还是 Element 上

最典型的是菜单。激活态的样式,是加在.menu--active还是.menu__item--active?

仔细想想:菜单激活的是哪个对象?

  • 如果整个菜单处于一种高亮模式下,比如夜间模式、紧凑模式,作用目标是块本身,用.menu--dark、.menu--compact;
  • 如果激活的是菜单里的某一项,被选中的 item 需要高亮背景,作用目标是元素,用.menu__item--active。

所以激活状态通常加在 Element 上。同理,“卡片置顶”是.card--pinned,“卡片中某个标签高亮”是.card__tag--highlight。判断标准就是问一句:这个修饰符描述的是整体还是局部。

5.3 问题三:块中块到底该怎么处理

我在 3.2 节已经提到过.avatar嵌在.user-card里的例子,这里展开几种模式。

块中块有两种嵌套方式:

第一种,HTML 上嵌套、类名上独立。例如卡片里有图片按钮组件:

<div class="product-card"> <button class="icon-btn icon-btn--favorite">♥</button> </div>

.icon-btn是完全独立的块,它有自己的管理文件,和product-card没有任何命名关联。.product-card负责给它留位置,但不负责它内部的样式。这就是“组合优于继承”在 CSS 里的体现。

第二种,真正的“块中块”命名。如果你觉得某个块和父块绑定得太紧,比如用户卡片的标签确确实实只属于卡片,那就写成.user-card__tag,别硬拆一个.tag块增加选择器链接。

有个更罕见但需要注意的情形:两个块互相嵌套,且需要配合修改样式。比如.media块嵌入.story-card,当 story-card 处于--compact模式时,希望media里面的图片变小。

.story-card { &--compact { .media__image { width: 48px; height: 48px; } } }

这种跨块的修饰耦合可以写,但不宜滥用。多写几个就会发现story-card文件里堆满了别的块的选择器,维护起来越来越困难。更好的做法是让.media自己支持media--small变体,在 HTML 上同时切换两个修饰符:

<div class="story-card story-card--compact"> <div class="media media--small"> <img class="media__image" ...> </div> </div>

哪个变体组合需要同时出现,就在模板层控制,样式层不受跨块污染。这是更接近“组件自治”的解法。

5.4 问题四:类名太长,转眼自己都写不动了

.user-card__tag--highlight确实不短。有人嫌手打太累,这也是 BEM 推广中最大的“劝退点”。几招缓解:

  1. Sass 的&语法。写一遍块名,后面的元素和修饰符都用&引用,无脑快捷:
.user-card { &__tag { &--highlight { ... } } }
  1. Emmet 缩写。在 VS Code 里,输入.user-card__tag--highlight按下 Tab,Emmet 可以直接展开成div标签:
.user-card__tag--highlight → <div class="user-card__tag--highlight"></div>
  1. 组件框架中的封装。React 里组件文件本身就叫UserCard.tsx,JSX 里可以直接把 BEM 类名封装成小工具函数,或者干脆定义常量减少重复书写。Vue 可以用模板内简单的计算属性拼接修饰符类名。

  2. 别在自己项目里乱加缩写后缀。BEM 解决的是清晰,如果你的类名像天书缩写,还不如不写。长类名确实是代价,换来的是定位和可维护性,这笔账在长期项目里是划算的。

5.5 问题五:BEM 和全局工具类怎么共存

把所有类名都 BEM 化,有时反而不高效。比如“隐藏”“清浮动”“文字超出省略”这类通用样式,每个组件自己写一套纯属浪费。业界的做法是保留少量工具类:

.is-hidden { display: none !important; } .truncate { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }

用!important在工具类里反而是合理用法,因为它的定位就是“强制覆盖任意组件样式”。但要注意,工具类的数量务必控制。一旦工具类超过三五十个,就又陷入了全局命名冲突的泥潭。我给自己定过一个标准:组件生命周期内少于五十行的全局样式,先用工具类兜底;超过五十行或语义成体系了,就把它组件化。

5.6 问题六:动态类名拼接时如何保持可维护性

多条件组合时,拼接类名容易变成面条代码。比如:

className={`user-card ${isCompact ? 'user-card--compact' : ''} ${isDark ? 'user-card--dark' : ''} ${isSelected ? 'is-selected' : ''}`}

这可读性很差。除了用classnames库,还有一个小技巧:把变体组合抽象成组件变量。

const modifiers = { 'user-card--compact': compact, 'user-card--dark': dark, 'user-card--borderless': borderless, };

这样写的好处是格式化之后能快速看到有哪些变体、哪些启用,将来新增变体也只要在对象里加一行。

6. 用 BEM 心态写 CSS 是一种怎样的体验

讲了这么多技术细节,最后分享点个人感受。

BEM 带给我的最大改变不是类名写法,而是思维方式。以前写样式,脑子里想的是“这个元素叫什么”,写出来是.title、.content这种极度随意的名字。用 BEM 之后,每次落笔都是先想“这个元素属于哪个块?它在块中扮演什么角色?是否可能被独立复用?有没有状态变化需要表现?”——这一个思考过程,逼着我在写 CSS 前先想清楚组件的边界和复用性,等于把设计工作前置到了写样式的时刻。

在代码评审里,BEM 也给沟通带来了很大的便利。看到search-panel__input--error,不用翻 HTML 就能告诉同事,“这个 input 的错误态和搜索面板耦合了,你可以提成工具类”。看到.list__item__title这种写法,可以直接说“这里层级钻太深了,建议用扁平的元素结构”。命名本身成了评审讨论的语法基础。

如果你想在自己的项目里引入 BEM,不需要宏大改造。挑一个新组件开始,严格按 BEM 写一个月,后面再回头迁移旧组件。一个人的坚持带动不了团队,但一个规范在团队里跑通之后,后面接手项目的人会感激你。

最后顺手分享一个我用得最多的检查小技巧:写完组件后,在浏览器控制台跑一句document.querySelectorAll('[class]'),扫一遍渲染出来的类名。如果发现某个元素上有三个以上的 BEM 类名,多半说明结构层级或者变体设计出了问题,尽早重构比拖到后面要轻松十倍。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询