remark42 前端样式体系重构:BEM 到 CSS Modules 的 leaf 组件批量迁移实战
remark42 前端样式体系重构:BEM 到 CSS Modules 的 leaf 组件批量迁移实战
导读
本文深入解析 remark42 评论组件前端的一次真实样式体系重构:将 button、dropdown、thread、auth-panel 四个"叶子"组件从 BEM(bem-react-helper 辅助库 + 19 个分散的 BEM CSS 文件)迁移到 CSS Modules(4 个 *.module.css 文件),并顺带清理迁移过程中发现的死代码与无用的类名/DOM 查询。读完本文,你将掌握 BEM 类名到 CSS Modules 的完整映射规则(block→root、element→camelCase、modifier→camelCase、复合修饰符→&. 嵌套)、动态样式用查找表(lookup object)替代 b() 调用的改造手法,以及如何用 data-* 属性 + ref 取代基于全局类名的 DOM 查询——这套方法论可直接复用到任何 Preact/React 组件的样式体系迁移中。
背景:为什么要从 BEM 迁移到 CSS Modules
BEM 文件组织的痛点
迁移之前,remark42 前端的四个组件沿用了经典 BEM 的目录组织方式:每个块(block)的样式被拆散成大量单文件,例如 button 组件有 7 个 CSS 文件——button/button.css、button/_kind/_link/button_kind_link.css、button/_kind/_primary/button_kind_primary.css、button/_kind/_secondary/button_kind_secondary.css、button/_size/_large/button_size_large.css、button/_size/_middle/button_size_middle.css、button/_theme/_dark/button_theme_dark.css,每个文件只描述一个修饰符(modifier)的样式。四个组件合计 19 个 BEM CSS 文件。
这种组织方式带来的问题:
- 文件碎片化:一个组件的外观被切进几十个文件,改动一个按钮样式要在多个目录间跳转;
- 依赖全局类名:组件通过
bem-react-helper的b()函数拼接形如button button_kind_primary button_size_middle的全局字符串类名,样式污染与命名冲突风险高,而且迁移时连b()本身也要一并移除; - 大量死代码:迁移过程中发现,部分类名根本没有对应 CSS 规则(如
auth-panel__pseudo-link)、部分 prop 没有任何调用方(如 dropdown 的headingprop),属于历史遗留的无用代码。
batch 0 确立的迁移范式
本次迁移遵循 PR #2013(batch 0:dropdown-item、list-comments、subscribe-by-rss、settings)建立起的模式,该批次以 零视觉回归(对构建产物做逐像素比对)完成验收。batch 0 沉淀出的关键经验是:
mix本质上就是纯字符串拼接——父子组件只要最终都落到clsx()上,就可以以任意顺序独立迁移,不需要同时迁移整棵组件树。
正是基于这一结论,本次选择 4 个"叶子"组件先行:它们与尚未迁移的父组件之间不存在"内向 mix 耦合"(即父组件没有把自己的 BEM 字符串拼进它们的类名里),或这类耦合本身已死。后续 batch 2(comment-form、subscribe-by-email、comment、root)在本次落地之后再跟进。
改造范围与验收目标
| 组件 | 迁移前 | 迁移后 | 额外动作 |
|---|---|---|---|
| button | 7 个 BEM CSS 文件 | 1 个 button.module.css |
动态 kind/size/theme 改用查找表 |
| dropdown | 7 个 BEM CSS 文件 | 1 个 dropdown.module.css |
3 处 DOM 查询改 ref;删除无用的 heading prop |
| thread | 3 个 BEM CSS 文件 | 1 个 thread.module.css |
level 0-5 无样式规则,仅 level 6 有效 |
| auth-panel | 2 个 BEM CSS 文件 | 并入既有 auth-panel.module.css |
清理死类名,更新测试文件 |
总计:19 个 BEM CSS 文件 → 4 个 CSS Modules 文件,同时删除所有 BEM 遗留目录。验收标准为:4 个组件中不再有任何 b() 调用、bem-react-helper 不再被这四个组件 import、旧 BEM 类名在源码中零残留、pnpm lint 与 pnpm test 全绿。
开发纪律
计划对每个任务提出统一的操作纪律,这也解释了为何 CSS-only 改动可以保持零回归:
- 测试策略:纯 CSS 改动、无逻辑变更,因此不新增测试,每次改动后运行既有测试套件验证;
- 小步前进:每个任务完整完成后再进入下一个,避免交叉污染;
- 每任务收尾门禁:执行
cd frontend && pnpm lint && pnpm test,通过才允许进入下一个任务。
从 frontend/apps/remark42/package.json 可以看到 lint 脚本实际是 run-p lint:*(并行运行 stylelint 与 eslint),test 脚本是 jest——这意味着每个任务门禁同时覆盖了脚本、样式与组件行为三层检查。
任务一:button 组件迁移(7 → 1)
样式合并:一份 button.module.css 收敛全部修饰符
迁移将 7 个 BEM 文件合并为单个 button.module.css,完整内容如下(与计划文档中的目标 CSS 逐字一致,当前仓库即是迁移后的最终形态):
.root {
background: none;
border: 0;
padding: 0;
margin: 0;
border-radius: 4px;
font-family: inherit;
font-size: inherit;
cursor: pointer;
white-space: nowrap;
&:focus {
box-shadow: 0 0 0 2px var(--color47);
outline: none;
}
&:disabled {
opacity: 0.6;
cursor: default;
}
}
.kindLink {
background: transparent;
font-weight: 600;
color: var(--color9);
&:hover { color: var(--color33); }
&:disabled, &:hover:disabled { color: var(--color9); }
}
.kindPrimary {
background: var(--color15);
color: var(--color6);
&:hover { background: var(--color33); }
&:hover:disabled { background: var(--color15); }
}
.kindSecondary {
background: var(--color6);
color: inherit;
&:hover { box-shadow: inset 0 0 0 2px var(--color33); }
}
.sizeMiddle {
height: 2rem;
padding: 0 12px;
}
.sizeLarge {
height: 36px;
padding: 0 12px;
font-size: 16px;
}
.themeDark {
&.kindSecondary {
background: var(--color8);
color: var(--color20);
}
&.kindLink {
&:disabled, &:hover:disabled { color: var(--color6); }
}
}
注意几个值得学习的点:
- CSS 变量保持穿透:样式值全部引用
var(--colorN)设计令牌(design tokens),这些令牌定义在 frontend/apps/remark42/app/styles/custom-properties.css,例如--color6: #fff、--color8: #262626、--color9: #0aa、--color15: #099、--color33: #06c5c5、--color47: rgb(37, 156, 154, 0.4)。CSS Modules 只做类名作用域隔离,CSS 变量照常生效,因此主题切换(light/dark)机制完全不受影响。 - 嵌套表达复合修饰符:
button_theme_dark.button_kind_secondary在 BEM 中是两个修饰符叠加,迁移后写成.themeDark { &.kindSecondary { ... } },把"组合条件"压缩进嵌套结构,可读性反而更好。
动态样式:查找表替代 b() 拼接
BEM 时代,kind/size/theme 是动态 prop,需要 b() 在运行时拼出完整类名串。迁移后改为模块化查找表:CSS Modules 编译后 styles.kindPrimary 是编译期确定的具体类名字符串,直接预置进查找表即可,运行时只做一次索引:
const kindStyles: Record<string, string> = {
primary: styles.kindPrimary,
secondary: styles.kindSecondary,
link: styles.kindLink,
};
const sizeStyles: Record<string, string> = {
middle: styles.sizeMiddle,
large: styles.sizeLarge,
};
当前 button.tsx 的最终形态与计划完全一致——去掉了 import b, { Mods, Mix } from 'bem-react-helper',改为 import clsx from 'clsx' 与 import styles from './button.module.css',类名通过单一 clsx() 组装:
className={clsx(
styles.root,
kind && kindStyles[kind],
size && sizeStyles[size],
theme === 'dark' && styles.themeDark,
mix,
className
)}
同时:
ButtonProps中删除从未被任何调用方使用的mods(迁移分析确认它是死 prop);mix的类型从 bem-react-helper 的Mix改为原生string | string[] | undefined,交给clsx()统一处理(详见下文"mix 的兼容性设计");- index.ts 删除全部 7 个 CSS import,只保留
export { Button } from './button'。
任务二:dropdown 组件迁移(7 → 1 + 逻辑重构)
dropdown 是四个组件中唯一需要动逻辑的:它原本依赖全局类名做 DOM 查询,迁移后全局类名消失,必须改用 ref 与 data-* 属性。
样式合并:一份 dropdown.module.css
合并后的 dropdown.module.css:
.root {
display: inline-block;
position: relative;
}
.content {
position: absolute;
z-index: 20;
outline-width: 0;
display: none;
top: 100%;
left: 0;
transform: translate(-0.5em, 5px);
min-width: 120px;
max-width: 260px;
border: 2px solid var(--color15);
border-radius: 3px;
padding: 0 0 5px;
}
.items {
padding: 5px 0;
&:last-child { padding-bottom: 0; }
}
.title {
&::after {
content: '\25BE';
margin-left: 2px;
}
}
.active > .content {
display: block;
}
.themeDark > .content {
background-color: var(--color8);
}
.themeLight > .content {
background-color: var(--color6);
}
逻辑重构:3 处 DOM 查询 → ref + data-dropdown
下拉面板的定位与展开逻辑原本通过 classList.contains('dropdown')、querySelector('.dropdown__content') 这类基于全局类名的查询访问 DOM。类名哈希化之后这些查询必然失效,迁移方案是用组件 ref + 数据属性替代。计划文档给出了精确的替换对照表:
| 迁移前 | 迁移后 |
|---|---|
parent.classList.contains('dropdown') |
parent.hasAttribute('data-dropdown') |
Array.from(...).find(c => c.classList.contains('dropdown__content')) |
this.contentRef.current |
this.rootNode.current.querySelector('.dropdown__content') |
this.contentRef.current |
对照当前 dropdown.tsx 源码逐条验证:
- 根 div 增加
data-dropdown属性(第 230 行),供嵌套 dropdown 检测沿父链上溯使用; - 新增
contentRef = createRef<HTMLDivElement>()(与既有rootNoderef 并列),内容面板ref={this.contentRef}; - 嵌套检测逻辑从
classList.contains('dropdown')改为parent.hasAttribute('data-dropdown')(第 97 行); - 定位面板的
contentRef.current(第 113、135 行)取代了两处querySelector('.dropdown__content')。
这样 DOM 访问完全绕开类名,即使 CSS Modules 把类名哈希成随机串,行为也不受影响。data-dropdown 本质上是把"这是 dropdown 根节点"这个语义从样式类名中剥离出来,独立承载——这也是迁移中"语义属性化"的通用手法。
同步修正与死代码清理
- 联动修正:已迁移过的 dropdown-item.module.css 中原本用
& > :global(.dropdown)选中嵌套的 dropdown 根节点,现在同步改为& > [data-dropdown],与新的属性约定保持一致; - 删除死 prop:
headingprop 没有任何调用方,且dropdown__heading类没有任何 CSS 规则,随迁移一并从 Props 类型与 JSX 中移除; - 类名组装改为
clsx(styles.root, isActive && styles.active, theme === 'dark' ? styles.themeDark : styles.themeLight, mix),Button 的mix传入数组[styles.title, titleClass](clsx原生支持数组)。
任务三:thread 组件迁移(3 → 1)
一个关键发现:只有 level 6 有样式
分析阶段发现,thread 的缩进/折叠样式只在 level 0-5 有 CSS 规则的空实现之外、level 6 才是唯一有意义的分支。因此迁移时只在类名组合里保留 level === 6 && styles.level6,避免无意义的条件判断。
合并后的 thread.module.css:
.root {
position: relative;
}
.indented {
margin-left: 17px;
}
.level6 .level6 {
margin-left: 0;
}
.collapse {
height: calc(100% - 50px);
width: 11px;
position: absolute;
top: 50px;
left: -4px;
cursor: pointer;
&::after {
display: block;
content: '';
position: absolute;
left: 5px;
top: 0;
border-left: 1px dotted var(--color35);
height: 100%;
}
&:hover::after {
transform: translateX(-1px);
border-left: 3px solid var(--color10);
z-index: 10;
}
}
.collapsed {
composes: collapse;
width: 18px;
height: 18px;
top: 12px;
left: 0;
display: flex;
text-align: center;
opacity: 0.8;
border-radius: 2px;
border: 1px solid;
&::after { display: none; }
&:hover { opacity: 1; }
&:hover::after { transform: translateX(0); }
& > div {
position: relative;
top: 6px;
left: 3px;
width: 12px;
height: 2px;
border-bottom: 2px solid;
&::before, &::after {
content: '';
width: 100%;
height: 2px;
border-bottom: 2px solid;
position: absolute;
top: -4px;
left: 0;
}
&::after { top: 4px !important; }
}
}
.themeDark {
& .collapse {
&::after { border-color: var(--color36); }
&:hover::after { border-color: var(--color6); }
}
}
注意这里用到了 CSS Modules 的 composes 组合机制:.collapsed 通过 composes: collapse 继承展开指示线的全部基础样式,再覆盖自身的尺寸与折叠态细节(比如 &::after { display: none; } 隐藏虚线、用内部 div 画折叠箭头)。这是 BEM 里"修饰符叠加"最自然的 CSS Modules 等价物——不需要复制样式,也不污染全局。
组件侧改动
当前 thread.tsx 与计划一致:根节点 className={clsx(styles.root, indented && styles.indented, level === 6 && styles.level6, theme === 'dark' && styles.themeDark)};折叠手柄按状态二选一 collapsed ? styles.collapsed : styles.collapse。
计划还特别说明:迁移时 thread 的 mix prop(当时从 root 组件收到 "root__thread" 字符串)只是作为普通类名字符串透传给 clsx()——CSS Modules 的类名与裸字符串在 clsx() 中可以共存,这正是"任意顺序独立迁移"得以成立的机制。从当前仓库的 root.tsx 看,batch 2 落地后 root 已改用 styles.* 模块类名,<Thread id={id} level={0} getPreview={getPreview} /> 也不再传递 mix 字符串,印证了迁移链条的完整性。
任务四:auth-panel 组件迁移(2 → 并入既有模块)
auth-panel 的特殊之处在于它此前已经有一部分样式在 auth-panel.module.css 里(user 信息区),本次只需把剩下的 2 个 BEM 文件合并进去,并清理迁移分析中发现的死类名。
追加进模块的 BEM 样式
在 auth-panel.module.css 既有类之后追加:
.root {
display: flex;
justify-content: space-between;
font-size: 14px;
line-height: 16px;
align-items: center;
}
.column:last-child {
margin-left: 8px;
text-align: right;
}
.columnSeparated > * + * {
position: relative;
display: inline-block;
margin-left: 20px;
&::before {
position: absolute;
left: -15px;
display: inline-block;
width: 10px;
text-align: center;
content: '•';
}
}
.adminAction { }
其中 .adminAction 是一个刻意保留的空类,源码注释写明了用途:/* stylelint-disable-next-line block-no-empty -- selector-only class for test targeting */——它没有样式,只作为测试选择器锚点存在。这是 CSS Modules 迁移中的常见模式:测试文件里 container.querySelector(\.${styles.adminAction}`)` 依赖的只是"这个类名存在且能通过模块引用获取",而不是任何视觉规则。
死代码清理清单
迁移分析揪出了一批"有类名、无样式、无调用"的死代码,一并清除:
auth-panel__pseudo-link—— 没有 CSS 规则,从<a>的 JSX 上移除;auth-panel_loggedIn/auth-panel_theme_*——b()产生的死修饰符,没有 CSS 规则,根节点直接使用styles.root;clsx('user', styles.user)这类双类名模式——裸全局字符串'user'、'user-profile-button'、'user-avatar'、'user-logout-button'均无对应 CSS,全部简化为只保留模块类名styles.user、styles.userButton、styles.userAvatar、styles.userLogoutButton;mix="auth-panel__admin-action"改为className={styles.adminAction},两个管理操作 Button 同步处理。
对照当前 auth-panel.tsx:根节点 className={styles.root}、左列 className={styles.column}、右列 clsx(styles.column, styles.columnSeparated)、两个管理按钮 className={styles.adminAction},与计划完全吻合。
测试文件的联动更新
迁移必然改变测试选择器。auth-panel.test.tsx 的更新方式是引入模块类名本身作为选择器:
import styles from './auth-panel.module.css';
// ...
container.querySelector(`.${styles.adminAction}`)
container.querySelectorAll(`.${styles.column}`)
这样测试与样式使用同一来源,类名哈希变化不会破坏测试,也避免在测试里硬编码哈希。
迁移完成后的验收验证
计划要求全部任务完成后执行五步验收,其中前两条构成了"迁移后回归防线":
- 确认 4 个组件全部使用 CSS Modules,已迁移文件中无残留
b()调用; - 运行完整前端测试:
cd frontend && pnpm test; - 运行前端 lint:
cd frontend && pnpm lint; - 全局 grep 旧 BEM 类名,确认没有任何对已删除 CSS 文件的引用;
- 确认
bem-react-helper不再被这 4 个组件 import。
从当前仓库验证结果看,在整个 frontend 目录下搜索 bem-react-helper 已无任何命中,说明该辅助库的引用已从组件树中彻底移除;同时 clsx(CSS Modules 迁移后的类名拼装核心)已进入 package.json 的运行时依赖(clsx: ^2.1.1)。
技术细节:约定、兼容性与清理清单
类名命名约定(BEM → CSS Modules 映射表)
计划文档为整个迁移周期确立了统一约定,后续 batch 2 也沿用同一套规则:
| BEM 概念 | BEM 示例 | CSS Modules 约定 |
|---|---|---|
| block(块) | button |
root |
| element(元素) | dropdown__content、auth-panel__column |
元素名转 camelCase:content、column |
| modifier(修饰符) | button_kind_primary、thread_theme_dark |
camelCase:kindPrimary、themeDark |
| 复合修饰符 | button_theme_dark.button_kind_secondary |
嵌套 &.:.themeDark { &.kindSecondary { ... } } |
button mix prop 的兼容性设计
mix 是让 CSS Modules 迁移可以分批进行的关键接口。迁移后它的类型从 bem-react-helper 的 Mix 变为 string | string[] | undefined,并直接透传给 clsx():
- batch 2 中尚未迁移的调用方(当时)继续传 BEM 字符串 →
clsx()原样输出,可用; - 已迁移的调用方传模块哈希类名 →
clsx()原样输出,可用。
由于 clsx() 天然支持字符串、数组、条件表达式混用,两种调用方可以在同一棵组件树里共存,这就是"parent 和 child 可以任意顺序独立迁移"的底层机制。当前 button.tsx 的 ButtonProps 中 mix?: string | string[] 与 className?: string 并列,正印证了这一设计。
dropdown DOM 查询替换对照
三处 DOM 查询的替换方案已在上文"任务二"给出对照表,核心思路是不再用样式类名做 DOM 语义定位:data-dropdown 属性承载"根节点"语义,contentRef 承载"内容面板"引用。这种做法在类名哈希化(生产环境类名形如 F_A、L_A 这类短 ID,见 CLAUDE.md 中关于 css-module class tokens 的说明)之后是唯一稳定可靠的方案。
删除文件总清单(19 个 CSS 文件 + BEM 目录)
- button:7 个(
button.css+_kind/_link|_primary|_secondary+_size/_large|_middle+_theme/_dark); - dropdown:7 个(
dropdown.css+__content+__items+__title+_active+_theme/_dark|_light); - thread:3 个(
thread.css+__collapse+_theme_dark); - auth-panel:2 个(
auth-panel.css+__column)。
工程保障:视觉冒烟与产物比对
纯样式迁移最大的风险是"测试全绿但肉眼可见的回归"。计划的收尾阶段提供了两层保障:
- 视觉冒烟测试:
cd frontend && pnpm dev:app启动开发服务器,逐项检查 button 的三种 kind/两种 size、dropdown 的开合、thread 的折叠/展开、auth-panel 的管理操作,且 light 与 dark 双主题都要过一遍; - 构建产物比对:从分支构建 Docker 镜像,与 master 构建产物做对比(沿用 PR #2013 的逐像素比对方法)。
配合 webpack.config.js 中 CSS Modules 的编译配置可以理解产物差异的来源:.module.css 走独立的 cssModulesRule,开发模式用 localIdentName: '[name]__[local]_[hash:5]',生产模式启用自定义 getLocalIdent(基于 incstr 生成极短 ID 以压缩体积),并关闭 named export(组件以 default 方式引入整个类名映射,且部分类名不是合法标识符)。由于类名是编译期决定的,构建产物中的哈希变化属于预期内波动,真正要盯住的是 CSS 值本身与 HTML 结构必须逐字节一致——这正是逐像素比对方案存在的原因。
本次迁移的可复用经验
- 分批策略:先叶子、后父组件,利用
mix/clsx的字符串兼容性把迁移拆成可独立合入的小批次,每批零视觉回归; - 清理顺带做:迁移过程天然暴露死 prop、死类名、无用 DOM 查询,顺手删除而不是原样搬运,让重构产生净收益;
- 语义与样式解耦:DOM 逻辑需要访问的"身份"(如 dropdown 根节点)用
data-*属性表达,而不是挂在样式类名上; - 测试锚点靠模块引用:测试选择器使用
styles.xxx而非硬编码类名,迁移后测试依然稳。
对后续要处理 batch 2(comment-form、subscribe-by-email、comment、root)的开发者而言,本批次已经把所有约定、工具链行为和验收方法跑通,直接复用同一套流程即可。