remark42 前端样式体系重构:BEM 到 CSS Modules 的 leaf 组件批量迁移实战

原创2026-10-12 01:30:141,422 阅读
文章标签:后端前端

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 的 heading prop),属于历史遗留的无用代码。

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>()(与既有 rootNode ref 并列),内容面板 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:heading prop 没有任何调用方,且 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}`)

这样测试与样式使用同一来源,类名哈希变化不会破坏测试,也避免在测试里硬编码哈希。

迁移完成后的验收验证

计划要求全部任务完成后执行五步验收,其中前两条构成了"迁移后回归防线":

  1. 确认 4 个组件全部使用 CSS Modules,已迁移文件中无残留 b() 调用;
  2. 运行完整前端测试:cd frontend && pnpm test;
  3. 运行前端 lint:cd frontend && pnpm lint;
  4. 全局 grep 旧 BEM 类名,确认没有任何对已删除 CSS 文件的引用;
  5. 确认 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)。

工程保障:视觉冒烟与产物比对

纯样式迁移最大的风险是"测试全绿但肉眼可见的回归"。计划的收尾阶段提供了两层保障:

  1. 视觉冒烟测试:cd frontend && pnpm dev:app 启动开发服务器,逐项检查 button 的三种 kind/两种 size、dropdown 的开合、thread 的折叠/展开、auth-panel 的管理操作,且 light 与 dark 双主题都要过一遍;
  2. 构建产物比对:从分支构建 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)的开发者而言,本批次已经把所有约定、工具链行为和验收方法跑通,直接复用同一套流程即可。

关键源码索引

登录后查看全文
remark42