BEM 规则写进 stylelint 之后:命名规范怎么从文档变成 CI 门禁

BEM 这套 Block、Element、Modifier 的命名思路,团队三年前就写进了规范文档,.card__title.btn--primary 这类写法大家也都认。决定这套规则能不能落地的,从来都不是"要不要用 BEM"这个选择本身,是文档里那几行规则,落到几十个人共同维护的代码库里,到底靠什么维持。

这半年我们的前端团队从两个人涨到了七个人,还外加两个外包。规范文档还是那份,但 git blame 里冒出来的类名越来越不讲规矩:.title2.card .header .name、单独出现的 .active 不跟着任何基类。人肉 code review 挡了一阵,直到有一次上线前夜,两个人各自往 common.scss 里加了一条 .item-title 的规则,谁都没意识到对方也在改,最后两条规则打架,页面上标题的行高忽大忽小。查下来发现根本不是逻辑 bug,是命名规范失守了很久,只是这次刚好撞车。

这件事之后我意识到,规范文档只对愿意读文档的人有约束力,对赶进度的人和新来的外包没有约束力。真正能落地的规范,得是机器帮你守门,而不是靠评审时眼睛尖不尖。

把 BEM 命名规则写成一条可执行的正则

stylelint 里有个规则叫 selector-class-pattern,可以给类名选择器强制一个正则校验,不满足格式直接报 lint 错误,接入 CI 之后连合并都合不进去。团队之前也知道这个规则存在,但一直没有认真写过一条真正贴合 BEM 语法的正则,网上抄来的版本要么太松、什么都能过,要么太严、把合法的多段单词命名也拦下来。

写正则之前我们先翻了一遍存量代码里实际存在的类名写法,避免闭门造车写出一条大家实际用不了的规则。翻下来发现团队里对 BEM 的理解并不完全一致:多数人用双下划线接元素、双中划线接修饰符这套标准写法,但也有两三个人习惯用单中划线做修饰符,比如 .btn-disabled 而不是 .btn--disabled,理由是"少打一个字符"。这种历史分歧如果不先摊开讨论,写出来的正则要么会把这批人的存量代码全部判为不合规,引发新一轮抵触情绪,要么就得放松到连单中划线修饰符也放行,等于没有真正统一规则。最后团队投票定了标准双中划线写法,单中划线的历史写法作为存量豁免,新代码一律不许再用。投票之前我们还翻了一下 BEM 官方文档,确认双中划线确实是规范原文推荐的写法,单中划线更多是社区里图省事演化出的变体,这一点也成了说服少数派接受新规则的一个论据——不是团队内部谁的偏好压过了谁,而是回归了这套方法论本来的样子。

这次我们坐下来重新拆了一遍 BEM 的合法形态,一步步搭正则。Block 名允许多个单词用中划线连接,比如 user-card

1// Block: user-card
2const block = '[a-z]([a-z0-9]+-)*[a-z0-9]+';

Element 是可选的,跟在两个下划线后面,同样允许中划线分词:

1// Element: __title-text
2const element = `(__${block})?`;

Modifier 也是可选的,跟在两个中划线后面:

1// Modifier: --is-active
2const modifier = `(--${block})?`;

拼起来就是完整的 BEM 正则,写进 .stylelintrc.js

1module.exports = {
2  extends: 'stylelint-config-standard',
3  rules: {
4    'selector-class-pattern': [
5      '^[a-z]([a-z0-9]+-)*[a-z0-9]+(__[a-z]([a-z0-9]+-)*[a-z0-9]+)?(--[a-z]([a-z0-9]+-)*[a-z0-9]+)?$',
6      {
7        message: '类名需符合 BEM 格式:block__element--modifier',
8      },
9    ],
10  },
11};

这条正则第一次跑起来的时候,扫出了存量代码里一百多处不合规的类名,包括不少历史遗留的 .title2.wrap1 这种带数字后缀的偷懒写法。这么多报错不可能一次改完,stylelint 支持给单个文件加 /* stylelint-disable */ 注释豁免,但更实际的做法是先跑一遍 --fix 能修的自动修掉,剩下手工改不了的先建一个豁免清单文件,新增代码严格执行,存量代码限期清理,而不是一刀切挡住所有人的提交。

这一百多处报错里还有一类比较特殊:一些老页面用的 .container.wrapper 这类通用词,单看格式完全合法,正则挑不出毛病,但项目里同名的 .container 出现在七八个毫不相干的页面里,各自定义各自的样式,纯属语义层面的滥用,格式正则天生管不了这种问题。这种情况最后没有硬塞进正则里解决,而是补充成一条写进文档、不进 CI 的人工约定:"containerwrapperbox 这几个词只能作为 Element 名的一部分出现,不允许单独作为顶层 Block 名",算是给正则管不到的语义漏洞打的一块人工补丁,提醒我们机器校验和人工约定从来不是互斥的,各自管各自擅长的那一层。

存量豁免这块,我们没有简单粗暴地给整个文件加 stylelint-disable,那样等于放弃了这个文件里未来新增代码的校验。做法是把 stylelint 的 --quiet 和 git diff 结合起来,只对本次改动新增或修改的行做严格校验,没有触碰到的历史代码即便不合规也先放过:

1{
2  "scripts": {
3    "lint:style:diff": "stylelint $(git diff --name-only --diff-filter=ACM HEAD | grep -E '\\.(css|scss|vue)$') --quiet"
4  }
5}

这个思路本质上是把"新增代码零容忍,存量代码限期改造"这条原则,落实成了一条可执行的脚本,而不是停留在嘴上说说。豁免清单每两周由负责人过一遍,挑几个改动频率高的文件顺手清理掉历史遗留的不合规类名,半年下来那一百多处报错已经消化得只剩十几处,都是些几乎不会再改动的老页面,暂时先放着。

真正有意思的地方在于第二层校验:光靠正则只能保证格式对,保证不了语义对。.aaa__bbb 也能通过这条正则,但它毫无意义。这一层机器管不了,只能靠 code review 补,但至少格式这一层的低级错误——漏写一个下划线、Modifier 和 Element 顺序颠倒——完全可以让机器先挡一道,评审时就不用再花精力抠这些。

CI 里怎么接,才不会拖慢所有人的提交

stylelint 规则写好只是第一步,真正让它长出牙齿的是接入 CI 或者至少接入 pre-commit。我们一开始图省事,把 stylelint 全量扫描整个项目挂在 pre-push 钩子上,结果第一次跑就卡了四十多秒,几个人在群里抱怨提交变慢了,觉得规范是拖后腿的东西。

后来改成只校验本次改动涉及的文件,用 lint-staged 配合 husky

1{
2  "lint-staged": {
3    "*.{css,scss,vue}": ["stylelint --fix"]
4  }
5}
// .huskyrc
{
  "hooks": {
    "pre-commit": "lint-staged"
  }
}

这样每次提交只扫改动的那几个文件,几乎感觉不到延迟。全量扫描则挪到 CI 流水线里,作为 PR 合并前的门禁跑一次,跑得慢一点没关系,反正不阻塞本地开发节奏。这个拆分思路其实和测试用例的分层很像:本地跑快而窄的检查,CI 跑慢而全的检查,两者各司其职,而不是把重活全塞给开发者的每一次 git commit

还有个容易被忽略的细节:--fix 不是万能的。像类名不规范这种语义层面的问题,stylelint 自己是猜不出该改成什么的,--fix 只能修格式类的问题,比如缺少分号、属性顺序。命名不规范这种错误,--fix 之后照样报错,需要人工改。团队里有人一开始误以为挂了 --fix 就能自动把不规范的类名改规范,测试了才发现根本不是这么回事,白白浪费了半天排查时间。

CI 门禁这一层我们还多留了一个心眼:只在 error 级别的规则上真正阻断合并,warning 级别的规则只在 PR 页面留一条评论提示,不阻断。这个区分是几次误伤之后才定下来的——一开始把所有 stylelint 规则统一按 error 处理,结果有条关于属性书写顺序的规则和团队里另一份历史悠久的老代码风格冲突,逼着大家为了合并一个跟本次改动毫不相关的小需求,去顺手改掉一堆无关代码的属性顺序,既拖时间又容易在无关改动里引入新的风险。分级之后,命名这类我们认为不可退让的规则维持 error,属性顺序这类纯风格偏好的规则降级成 warning,各自的严格程度对应各自真正需要守住的底线。

正则拦下了第三方组件库的类名,怎么办

规则接入 CI 第一周,就撞上了一个意料之外的误报。项目里有段样式是为了覆盖 Element UI 组件内部结构写的:

1.order-detail {
2  &__amount {
3    .el-input__inner {
4      color: #f56c6c;
5      font-weight: 600;
6    }
7  }
8}

.el-input__inner 是 Element UI 自己的类名,格式上完全符合 BEM,本不该报错。但我们的正则要求整条选择器链上的每一段类名都单独匹配 selector-class-pattern,而 stylelint 默认是逐个类选择器校验,理论上不该殃及池鱼。真正的报错原因排查了小半天才找到:是我们写正则时漏掉了 Element UI 内部偶尔会出现的双 Modifier 写法,比如 .el-input--small.is-disabled,这种"中划线 Modifier 加短横线状态类"混用的历史命名,不完全是标准 BEM,我们的正则没兜住。

这里的教训是,给第三方组件库的类名做 lint 校验意义不大,因为你根本管不了它的命名,也没必要管。stylelint 支持按选择器模式排除文件或者用 /* stylelint-disable-next-line selector-class-pattern */ 逐行豁免,但更省心的做法是从源头上区分"自己写的类名"和"覆盖第三方组件的类名",约定一条规矩:所有用来覆盖第三方组件内部结构的选择器,必须整行加豁免注释,并且在注释里写清楚覆盖的是哪个组件的哪个版本,方便以后组件库升级类名变动时能搜得到:

1.order-detail__amount {
2  /* stylelint-disable-next-line selector-class-pattern -- 覆盖 element-ui 2.15 的 el-input 内部类名 */
3  .el-input__inner {
4    color: #f56c6c;
5  }
6}

这样处理之后,正则本身反而可以收紧,不需要为了兼容第三方类名的各种历史写法而放松自己的规则,两边互不干扰。

BEM 和 CSS Modules,两条路子解决的其实不是同一个问题

组里有个刚来的同事提议干脆放弃 BEM,全面切 CSS Modules,理由是"构建工具直接把类名哈希掉,天然不会冲突,比人肉遵守规范可靠"。这个提议值得认真掰扯一下,因为它戳中了一个真问题:BEM 说到底是纪律,纪律就有失守的时候;CSS Modules 是工程手段,工程手段理论上不会失守。

但深入想一层,会发现这两者解决的问题层次不一样。CSS Modules 保证的是"类名在全局不冲突",它对 .title 这种短名字也照单全收,因为反正会被编译成 Card_title__a1b2c 这种带哈希的唯一值。可它完全不管你这个 .title 在组件内部和别的类是什么关系——是标题的默认态还是选中态,.title.title-active 两个类放在一起,CSS Modules 不会告诉你它们是同一个语义单元的两种状态。这一层"结构关系是否清楚",恰恰是 BEM 真正解决的问题,是哈希机制完全不涉及的维度。

我们试着在一个新项目里做了个折中:这个项目用 Vue 3 加 Vite 2 搭的技术栈评估项目,vue-loader 天然支持 CSS Modules,配合 <style module> 用起来很顺手:

1<template>
2  <div :class="$style.card">
3    <h3 :class="$style.cardTitle">标题</h3>
4  </div>
5</template>
6
7<style module>
8.card {
9  padding: 16px;
10}
11.cardTitle {
12  font-size: 18px;
13}
14</style>

但组件内部,我们仍然要求类名本身按 BEM 的元素/修饰符思路去起,只是不再手写 Block 前缀——因为 CSS Modules 已经用文件作用域顶替了 Block 要解决的"跨组件不冲突"这一层,cardTitle 不需要真的写成 card__title 也不会和别的组件的 title 打架。真正需要 BEM 补上的,只剩下 Modifier 那层状态语义:

1<style module>
2.button {
3  padding: 8px 16px;
4}
5.buttonPrimary {
6  composes: button;
7  background: #409eff;
8  color: #fff;
9}
10</style>

composes 是 CSS Modules 特有的组合机制,效果类似 Sass 里 @extend 但没有选择器合并的副作用——编译后 buttonPrimary 对应的 DOM 节点上会同时挂 buttonbuttonPrimary 两个哈希类名,本质上还是"基类 + 修饰符叠加"这套 BEM 老思路,只是命名空间归谁管这件事交给了构建工具,我们不用再操心 Block 前缀。这套组合让我们意识到,BEM 不是非此即彼的选择,它的核心价值(结构关系表达)和 CSS Modules 的核心价值(作用域隔离)本来就可以叠着用,谁也不必取代谁。

代价也很明显:CSS Modules 强依赖构建流程,一旦要做纯静态页面、或者要把某个组件的样式单独抽出来写文档、写设计规范,哈希后的类名完全不可读,没法直接照着看。团队里有几个老的多页应用项目还没上 Vite,短期内也没打算迁移构建链路,这些项目就只能继续用纯 BEM 加 stylelint 这条路,两条方案在同一个团队里并存了一段时间,这也是真实项目演进过程里常见的状态,不是非要哪天统一。

调试体验上也有一点落差需要习惯。用纯 BEM 的项目,浏览器里打开 DevTools,看到的类名和源码里写的一模一样,定位起来毫无障碍;切到 CSS Modules 之后,cardTitle 在浏览器里显示的是类似 Card_cardTitle__a1b2c 这种带哈希后缀的产物类名,虽然大多数构建工具默认会在开发模式下保留可读的原始片段方便调试,生产构建才会压缩成纯哈希,但团队里还是有人反馈刚切换的头几天不太适应,尤其是排查样式问题时习惯性去源码里搜索浏览器上看到的类名,得先在脑子里把哈希后缀去掉才能搜对。

还有一件事是评估 CSS Modules 时才意识到的:它对 stylelintselector-class-pattern 这套校验思路其实是失效的。因为源码里写的 cardTitle 只是个"局部名字",真正参与全局唯一性的是编译后的哈希类名,lint 工具只能校验源码层面的类名格式,管不到编译产物。也就是说,这条规则在走 CSS Modules 的项目里,校验的意义已经从"防止全局冲突"退化成"保证团队内部命名风格一致",两种项目类型里同一条 lint 规则实际发挥的作用是不一样的,这点在写团队规范文档时容易被含糊带过,值得单独说明清楚,免得新人搞不懂为什么明明用了 CSS Modules 还要遵守 BEM 式的类名书写风格。

scoped 样式里,BEM 容易被逐渐放弃的原因

Vue 项目普遍用 <style scoped>,跨组件不冲突这层已经由编译时加的 data-v-xxx 属性选择器兜底了,这也是团队里最常见的"干脆不用 BEM 了"的理由。这半年观察下来,scoped 组件里的类名确实呈现出一种慢慢退化的趋势:一开始还规规矩矩写 .user-card__title,写着写着就变成 .title.desc.wrap,因为反正 scoped 帮忙兜底了,谁也不会真的因为类名冲突而出问题。

这种退化短期内看不出代价,但等组件变复杂,比如一个表单组件内部有十几个字段,每个字段又有标签、输入框、错误提示三种状态,光靠 .label.input.error 这几个短类名,很快就分不清哪个 .error 对应哪个字段:

1<style scoped>
2.label {}
3.input {}
4.error {}
5</style>

对比清楚表达元素归属的写法:

1<style scoped>
2.form-field__label {}
3.form-field__input {}
4.form-field__error {}
5
6.form-field--phone .form-field__input {}
7</style>

后一种哪怕在 scoped 保护下,依然能让人一眼看出这是"表单字段"这个概念下的哪个部分,前一种即便不会跨组件冲突,组件内部自己都容易读混。这也是我们把"scoped 不能替代 BEM"这条写进规范文档里最主要的理由——它俩解决的从来不是同一层问题,scoped 管的是跨组件会不会冲突,BEM 管的是内部结构的可读性,缺哪个都会在项目变大之后暴露出来,只是暴露的时间点不一样:跨组件冲突几乎立刻能被 lint 或者浏览器实际渲染发现,可读性问题要等到某次改动或者交接时才会真正被感知到。

类名太长这件事,到底该不该忍

BEM 用久了必然会撞上一个具体的抱怨:类名太长,写起来累,读起来也累。团队里有个真实的例子,一个搜索结果里的操作按钮,规规矩矩按 BEM 写下来是这样:

1<button class="search-result-item__action-button search-result-item__action-button--disabled">
2  收藏
3</button>

一个类名四十多个字符,还要写两遍(因为 Modifier 必须搭配基类一起出现),HTML 里稍微多几个这样的按钮,模板文件就被类名撑得又长又难读。这不是危言耸听的小题大做,是团队里真实发生过的抱怨——有人在 code review 里直接说,这一行代码大半是类名,看不出结构。

这里没有一个放之四海皆准的答案,但我们摸出了几条实际能用的折中。第一条是缩短 Block 名,不必把页面层级都塞进 Block 里。上面那个例子里 search-result-item 完全可以简化成 result-item,因为"搜索"这个上下文已经由它所在的页面和文件路径隐含了,不需要类名自己再交代一遍。第二条是允许 Element 名本身简短,只要在组件内部语境下不产生歧义,__action-button 完全可以缩成 __action,比如:

1<button class="result-item__action result-item__action--disabled">收藏</button>

字符数从四十多降到二十出头,读起来轻松不少,语义也没有丢。第三条是承认某些场景 BEM 就是不划算,比如一些纯展示、极少被复用、几乎不会被别的组件引用的一次性布局容器,硬套 Block/Element 反而是形式主义负担,这类容器我们允许用更松的短命名,代价是 code review 时要多一句判断——这个容器未来会不会被复用,如果答案含糊,还是老实用 BEM。

这几条折中说到底都是同一个判断标准:类名长度是为了换取可读性和边界清晰,如果为了凑格式反而让代码更难读,那就是本末倒置,该缩短就缩短,不必为了"规范看起来统一"而牺牲工程上的舒适度。

这次讨论完,我们还顺手把"类名不能超过多长"这条口头共识也变成了机器可查的东西。stylelint 有个 selector-max-length 规则,可以给整条选择器设一个字符数上限,超过就报警告而不是直接拦截:

1module.exports = {
2  rules: {
3    'selector-max-length': [60, { severity: 'warning' }],
4  },
5};

设成 warning 而不是 error 是刻意的,因为类名长度这件事没有绝对对错,机器只能提醒"这条选择器有点长了,要不要看看能不能精简",具体要不要改,还是留给写代码的人自己判断。这条规则接入 CI 之后头一周就扫出了十几处超过六十个字符的选择器,多数是像 search-result-item__action-button--disabled 这种可以直接套用上面几条折中方案精简下来的写法,改完之后普遍能压到三十字符上下,读起来轻松很多。

用 Sass 的 & 把类名生成这件事自动化

写 stylelint 正则的过程里还顺带解决了另一个老问题:Element 和 Modifier 手写起来容易漏字符。BEM 类名本身就长,一个 Element 名要写两遍——一遍在 .scss 文件里定义样式,一遍在模板里拼 class,两边稍微手滑打错一个字母,视觉上完全看不出来,只有跑起来样式不生效才会发现。这半年我们踩过好几次这种低级错误,比如样式里写的是 &__discription,模板里敲的是 &__description,多拼一个 i 少拼一个 i,谁也不会在 code review 里逐字符去比对。

Sass 的 & 嵌套本身能省掉一部分重复劳动,但只解决了样式定义这一侧,模板里的类名还是得手写。我们后来在项目里约定了一套配合 & 嵌套的写法,把 Modifier 的生成也交给 Sass 的 @each 循环,减少手写次数:

1$modifiers: (primary, danger, disabled);
2
3.btn {
4  padding: 8px 16px;
5  border-radius: 4px;
6
7  @each $modifier in $modifiers {
8    &--#{$modifier} {
9      @if $modifier == primary {
10        background: #409eff;
11        color: #fff;
12      } @else if $modifier == danger {
13        background: #f56c6c;
14        color: #fff;
15      } @else if $modifier == disabled {
16        opacity: 0.5;
17        pointer-events: none;
18      }
19    }
20  }
21}

这样写的好处不是省了多少行代码,几种 Modifier 分支该写的样式还是得写,真正省下来的是"类名字符本身不会打错"——Modifier 的名字只在 $modifiers 这个列表里出现一次,&--#{$modifier} 插值出来的类名和列表里的拼写永远一致,不会出现样式里定义了 &--danger、模板里却写成 --danager 这种手滑。

配合这套写法,我们还封装了一个简单的 mixin,把"基类加 Modifier"这套模式收敛成一行调用,减少每次都手写 &--xxx 的重复劳动:

1@mixin bem-modifier($name) {
2  &--#{$name} {
3    @content;
4  }
5}
6
7.card {
8  padding: 16px;
9
10  @include bem-modifier(featured) {
11    border-color: $color-primary;
12  }
13
14  @include bem-modifier(disabled) {
15    opacity: 0.6;
16  }
17}

这个 mixin 本身没有什么技术难度,纯粹是把重复敲的 &-- 前缀收进一个函数调用里,读起来也更接近"这是一个 Modifier 分支"的语义,而不是一段普通嵌套。团队里试用了一阵,评价不算特别热烈——有人觉得多一层 mixin 反而增加了跳转查找的成本,想看某个 Modifier 具体加了什么样式,得先知道这是 bem-modifier 包出来的。这条约定最后没有强制写进规范,作为"可选的写法之一"留在了文档里,用不用看个人习惯,唯一强制的是 Modifier 的名字必须来自同一个地方定义,不能到处手写字符串。

Mix 混合类,正则最容易漏掉的一种合法写法

BEM 社区里有个概念叫 Mix,指的是同一个 DOM 节点上同时挂两个不同 Block 的类名,各自负责各自的样式职责,互不干涉。团队里最常见的场景是一个通用 Block(比如布局用的 .grid-col)和一个业务 Block(比如 .product-card)混在同一个元素上:

1<div class="grid-col grid-col--4 product-card">
2  <img class="product-card__image" src="..." />
3  <h3 class="product-card__name">商品名称</h3>
4</div>

grid-col 负责这个元素在栅格系统里占几列,product-card 负责商品卡片自身的视觉样式,两者是完全独立的关注点,硬要合并成一个 Block 反而会让 product-card 这个组件被布局细节污染,哪天要把这张卡片挪到另一种布局里,还得连着改组件自身的类名。

Mix 写法第一次接入 stylelint 校验时,被我们的正则完整放行了,因为正则是逐个类选择器单独校验的,grid-colgrid-col--4product-card 各自都是合法的 BEM Block,正则不关心它们出现在同一个 class 属性里。这一点没有出问题,纯粹是运气好——因为我们的正则设计成了校验单个类名,而不是校验整条 class 属性字符串,如果哪天有人图省事写出校验整个 class 属性的正则,Mix 这种合法用法反而会被拦下来。这也提醒我们,写 lint 规则时要想清楚校验粒度到底应该落在哪一层,粒度选错了,合法的写法可能被误伤,真正该拦的写法反而漏过去。

Mix 用多了也有个新问题需要规范:到底哪些类可以作为 Mix 的通用 Block,不能谁都随手定义一个"通用"类往上叠。我们的做法是通用 Block 必须集中定义在一个专门的 utilities.scss 或者对应的组件里,比如栅格系统、间距工具类,业务 Block 之间不允许互相 Mix,只允许业务 Block 和这批约定好的通用 Block 混用,避免 Mix 变成新的命名混乱来源。

Sass 嵌套写法和 stylelint 正则打架的那几天

selector-class-pattern 校验的是编译后还是编译前的类名,是我们踩过的另一个坑。项目里 Sass 用 & 嵌套写 BEM 是常态:

1.card {
2  &__title {
3    font-size: 18px;
4  }
5
6  &--featured &__title {
7    color: $color-primary;
8  }
9}

stylelint 默认只处理原始 .scss 源文件里的 AST,它看到的是 &__title 这种还没编译展开的片段,而不是最终产出的 .card__title。第一次接入规则时,这类嵌套写法大批量报错,因为 &__title 单独拿出来看,开头是 & 不是字母,完全不符合我们写的那条 BEM 正则。

解决办法不是把正则改得更松去兼容 &,那样会连累到真正写错的类名也放过去。正确的做法是引入 stylelint-scss 这个插件,它能理解 Sass 特有的语法结构,把 &__title 还原成语义上等价的 .card__title 之后再校验:

1module.exports = {
2  plugins: ['stylelint-scss'],
3  extends: ['stylelint-config-standard', 'stylelint-config-recommended-scss'],
4  rules: {
5    'selector-class-pattern': [
6      '^[a-z]([a-z0-9]+-)*[a-z0-9]+(__[a-z]([a-z0-9]+-)*[a-z0-9]+)?(--[a-z]([a-z0-9]+-)*[a-z0-9]+)?$',
7      { resolveNestedSelectors: true },
8    ],
9  },
10};

resolveNestedSelectors 这个选项开启之后,stylelint 会先把嵌套的 & 结构解析还原成完整选择器再拿去匹配正则,&__title.card 内部会被正确还原成 .card__title 参与校验,嵌套写法带来的误报基本清零。这件事也提醒我们,给团队引入一条 lint 规则之前,最好先拿项目里几种典型的历史写法去跑一遍,而不是规则一写完就直接全量开启,不然大概率第一天就会被一堆无意义的报错劝退,团队对新规则的信任感也会跟着受损。

听说过原子化 CSS,团队还没打算跟

评估构建工具那阵子,有同事在掘金上看到国外一些团队在用 Tailwind CSS 这类原子化方案,把样式直接写成一堆功能类拼在 class 里,类似 class="flex items-center p-4 text-sm",完全不需要给组件单独起名字,也就没有 BEM 这套命名负担了。这个思路他在周会上提了一嘴,说是不是可以关注一下。

我们没有深入试,一是当时国内讨论这个方向的团队还很少,中文资料和实践案例都不多,二是这条路和 BEM、CSS Modules 解决问题的方式根本是两种哲学,贸然引入意味着团队现有的组件库、设计规范都要跟着推倒重来,代价和收益都需要更长时间观察。原子化 CSS 的好处据说是几乎不用再操心命名——你不需要给一个盒子想名字,直接拼功能类上去;但反过来 HTML 里会堆出一长串类名,可读性问题从"类名太长"变成了"类名太多",只是换了一种形式存在,而不是消失了。

那位同事顺手贴了一段国外案例里的写法给大家看,类似 class="flex items-center justify-between p-4 rounded shadow-sm" 这样一整排功能类堆在一个标签上。团队里看完之后争论的焦点反而不是命名负担,而是这种写法对我们现有分工方式的冲击:目前视觉稿到样式这一层,多数时候是前端拿到设计稿自己写 CSS,原子化方案要求写页面的人对间距、字号这些设计 token 相当熟悉,才能不用来回翻设计稿去拼那一长串功能类,学习成本不小;而且我们项目里还有不少通过 SCSS 变量统一管理的主题色、间距规范,如果切到原子化方案,这批变量要怎么和一堆预置好的功能类对应起来,也是个没想清楚的问题。这个方向目前对我们来说还停留在"听说过、留意一下"的阶段,没有到评估落地的程度。

倒是这次讨论帮我们把 BEM 存在的意义想得更清楚了一层:不管是 BEM、CSS Modules 还是原子化 CSS,它们本质上都是在给"CSS 没有原生模块作用域"这件事打补丁,只是补丁打在不同层次——BEM 打在人的约定上,CSS Modules 打在构建工具上,原子化 CSS 干脆放弃组件级命名,把粒度下沉到样式属性本身。选哪一种,取决于团队规模、构建链路成熟度、以及愿意承担多少学习成本,没有哪一种能通吃所有场景。

命名规范之外,还得管住选择器优先级

规范聊到后面,有人翻出了一个和命名规则并列的老问题:项目里 :is() 这类新选择器要不要用。Chrome 88 今年一月已经支持 :is():where(),这两个选择器可以把多个选择器合并写,还能控制权重是否叠加。放在 BEM 场景下,它能解决一个具体的痛点:多个 Block 共享同一段 Modifier 逻辑时,以前要么各写一遍,要么用 Sass 的 @each 循环生成,现在可以直接写:

1:is(.btn, .link-button):is(.btn--loading, .btn--disabled) {
2  pointer-events: none;
3  opacity: 0.5;
4}

:is() 的权重按内部选择器里最高的那个算,不会因为写了多个选择器就把权重堆高,这一点和 :where() 不同——:where() 的权重永远算作零,适合写那种"我只是想省事合并几个选择器,完全不想让它掺和权重战争"的场景。团队实际用起来发现,:where() 更适合用在给某些默认态兜底、可能会被业务样式随意覆盖的场景,比如 reset 样式;:is() 适合合并本身权重就该相等的几个平级选择器。这两个选择器目前只能在支持 Chromium 88 以上和对应 Safari/Firefox 版本里放心用,我们项目对外部用户还保留了小部分 IE11 流量,所以只敢在管理后台这类内部工具类项目里先用起来,面向外部客户的项目暂时按兵不动,等兼容性数据再看几个月。

命名和选择器权重这两件事凑在一起看会更清楚:BEM 从命名上尽量把权重压平到单类选择器,:is()/:where() 则是从语法层面给了额外的权重控制能力,两者不冲突,反而是互补的两层保险——一层靠约定不让权重升高,一层在少数确实需要合并选择器的场景里,还能显式声明这次合并要不要计入权重。

Module Federation 评估阶段,暴露出的命名空间新问题

团队里同时在跟进 webpack 5 从 4 升级的评估工作,其中一项重点是 Module Federation,这个特性允许多个独立构建的应用在运行时共享组件,不需要把代码打包进同一个产物。我们拿一个内部工具平台做了小范围试点,把一个日期选择器组件从主应用里拆出来,作为远程模块被另一个子项目在运行时加载进来。

这一试就试出了一个之前完全没预料到的命名问题:两个项目各自都有一个叫 .picker 的 Block,主应用里 .picker__panel 表示日期面板,子项目自己也有一个下拉选择器用了同样的 Block 名 .picker,写着 .picker__panel 表示下拉的面板。两个项目原本各自独立构建、各自的样式作用域互不相干,经 Module Federation 把组件在运行时拼到了同一个页面上,两段样式忽然出现在了同一个 DOM 树里,.picker__panel 直接打架,日期面板的样式被下拉组件的样式覆盖了一部分。

这个问题本质上是 BEM 的 Block 前缀只解决了"单个项目内部不冲突",从没设计过要应对"两个独立构建的项目在运行时合并"这种场景。过去每个项目都是各自构建、各自部署,样式作用域天然被项目边界隔开,Module Federation 打破了这层隔离,命名冲突的风险从"同一个仓库里的模块之间"扩大到了"不同仓库、不同团队维护的项目之间",而后者显然没法指望大家共用同一份 BEM 命名规范、约定同一批 Block 前缀。

排查这个问题的时候我们还确认了另一件容易被忽视的事:两个项目的 stylelint 配置是各自独立跑的,谁的 CI 都不会知道对方项目里也有一个 .picker 类。也就是说,这类跨项目的命名冲突,靠现有的 lint 门禁完全拦不住,因为门禁的检查范围从来就是单个仓库内部,Module Federation 引入的这种运行时耦合,处在所有现有校验工具的视野之外,只能靠人在联调阶段肉眼发现,或者等页面上出现明显的样式错乱才会暴露。

评估阶段我们暂时没有找到一劳永逸的方案,只把这个问题记录下来作为后续跟进的风险点,当下能落地的临时对策有两个方向:一个是给通过 Module Federation 暴露出去的远程组件,强制在 Block 前面加一层项目级前缀,比如把 .picker 改成 .dp-picker(date-picker 的缩写),本质上是把原来项目边界承担的隔离职责,重新用命名前缀的方式找补回来;另一个方向是给这批可能被跨项目共享的组件强制走 CSS Modules 或者 <style scoped>,靠工具链的作用域机制来管,而不是继续依赖人工前缀。这件事目前还在评估阶段,Module Federation 本身对我们来说也还是一个刚接触、没有生产落地经验的新能力,命名空间这个问题算是评估过程中意外挖出来的一个坑,值得先记下来,等真正决定要不要把这套跨项目组件共享方案铺开的时候,再认真定一条规矩。

规范最终落地成什么样

这一轮下来,规范文档没有推翻重写,但补上了两块之前一直缺的东西:一是 stylelint 的 selector-class-pattern 正则和 CI 门禁,让命名规则不再只靠自觉;二是明确了 BEM、CSS Modules 该在什么项目类型下各自使用,不强求全团队统一到一种方案。类名长度的折中标准、:is()/:where() 的使用场景,也都补充进了同一份文档里,作为具体的判断依据,而不是抽象的原则。

外包同事入职培训的时候,现在直接拿这份文档过一遍,再跑一次 stylelint --fix 让他们直观看到哪些写法会被拦下来,比单纯讲道理管用得多。规范这东西写在纸上永远只是纸上谈兵,真正让它长期有效的,是给它配一套机器能执行的检查,让遵守规则这件事不再依赖每个人当时够不够仔细。

Module Federation 那个跨项目命名冲突的问题还没有定论,暂时先按前面说的临时前缀方案处理了那次试点里冲突的两个 .picker,等评估阶段真正推进到要铺开的地步,再回头认真定一条跨项目的命名协议。规范文档这次更新之后,会作为下一次团队分享的素材,讲清楚这半年从"文档里的规则"变成"CI 里的门禁"这条路上,到底哪些判断是必须做的,哪些是可以先放一放的。