从 styled-components 到 CSS Modules:GitHub 用三年重写样式边界

2026-09-28 35 预计阅读时间: 1 分钟
来源: oschina.net AI 摘要 Original link

Disclaimer: This article is an AI-assisted summary. Read it together with the original source when precision matters. The summary may omit context, version differences, or edge cases and is not official documentation.

预计阅读时间:10 分钟

GitHub 对 github.com 样式体系的改造,不是一次简单的语法替换。迁移从 2023 年组件数量快速增长带来的性能问题开始,持续三年;按照复盘给出的终点,到 2026 年 6 月,网站已经完全运行在 CSS Modules 上,并删除了 styled-components、styled-system 以及大量 sx props。

这场迁移最值得关注的地方,不是“CSS Modules 战胜了 CSS-in-JS”,而是一个大型前端系统如何重新划分样式的执行时机、组件边界和长期维护成本。

问题不只在 CSS,而在运行时成本的放大

GitHub 的 UI 大量建立在 Primer 设计系统之上。当组件数量持续增长时,样式方案中的固定成本也会被一起放大:组件需要解析 props、计算样式、生成或组合类名,并处理主题与样式覆盖。

这并不意味着所有 CSS-in-JS 都一定慢。真正需要检查的是,应用是否把本可在构建阶段完成的工作,留到了浏览器运行阶段。在大型页面中,一点点组件级开销乘以成百上千个实例,就可能进入可观测范围。

CSS Modules 改变了这条执行路径:

  • CSS 在构建阶段被拆分和处理,而不是依赖组件挂载时生成规则。
  • 组件通过普通 className 引用样式,浏览器直接使用成熟的 CSS 匹配机制。
  • 类名默认局部化,降低全局命名冲突风险。
  • 媒体查询、伪类和动画回到原生 CSS,不需要全部包装成 JavaScript API。
  • 删除样式运行时后,依赖图、客户端代码和排查路径都更简单。

因此,这次迁移改变的是网站的“运行性格”:样式从组件执行过程中的动态参与者,变成更接近静态资源的构建产物。

真正困难的是替换 sx 带来的便利

styled-components 这样的依赖可以从 package.json 中删除,但 sx props 往往已经渗透到业务组件内部。它允许开发者在调用处快速写间距、颜色和响应式规则,也容易让设计决策散落在 JSX 中。

迁移到 CSS Modules 后,团队必须回答几个具体问题:

  1. 高频样式组合应该成为组件变体,还是保留为工具类?
  2. 主题值如何传递,使用 CSS 自定义属性还是生成多套样式?
  3. 真正的动态值如何处理,而不重新发明一个 CSS-in-JS 系统?
  4. 调用方是否还能随意覆盖组件内部结构?

合理的边界通常是:有限状态使用类名,主题使用 CSS 变量,只有真正来自运行时的数据才进入内联样式。 例如按钮的尺寸和语义属于有限变体,而进度条的实时百分比才是动态值。

可以这样迁移一个 React 组件

下面是一个可放入 React 与 TypeScript 项目的最小示例。假设构建工具已经支持 CSS Modules;Vite、Next.js 等常见工具通常具备这项能力。

迁移前,组件可能把样式和 props 计算都交给 styled-components:

import styled from 'styled-components';

const Button = styled.button<{ $danger?: boolean }>`
  border: 0;
  border-radius: 6px;
  padding: 8px 12px;
  color: white;
  background: ${({ $danger }) => ($danger ? '#cf222e' : '#1f883d')};

  &:hover {
    filter: brightness(0.92);
  }
`;

export function DeleteButton() {
  return <Button $danger>Delete repository</Button>;
}

迁移后,将有限的视觉状态改为 CSS 类。创建 Button.module.css:

.button {
  border: 0;
  border-radius: 6px;
  padding: 8px 12px;
  color: var(--button-fg, #ffffff);
  background: var(--button-bg, #1f883d);
  cursor: pointer;
}

.button:hover {
  filter: brightness(0.92);
}

.default {
  --button-bg: #1f883d;
}

.danger {
  --button-bg: #cf222e;
}

.button:focus-visible {
  outline: 2px solid #0969da;
  outline-offset: 2px;
}

再创建 Button.tsx:

import type { ButtonHTMLAttributes } from 'react';
import styles from './Button.module.css';

type ButtonProps = ButtonHTMLAttributes<HTMLButtonElement> & {
  variant?: 'default' | 'danger';
};

export function Button({
  variant = 'default',
  className = '',
  ...props
}: ButtonProps) {
  const classes = [styles.button, styles[variant], className]
    .filter(Boolean)
    .join(' ');

  return <button className={classes} {...props} />;
}

export function DeleteButton() {
  return <Button variant="danger">Delete repository</Button>;
}

这段代码保留了调用方传入 className 的能力,但把核心变体收回组件内部。若团队不希望外部覆盖样式,也可以移除公开的 className,进一步收紧边界。

真正动态的值则可以通过 CSS 自定义属性传递。例如进度条不需要为每个百分比创建一个类:

import type { CSSProperties } from 'react';
import styles from './Progress.module.css';

export function Progress({ value }: { value: number }) {
  const safeValue = Math.max(0, Math.min(100, value));
  const style = { '--progress': `${safeValue}%` } as CSSProperties;

  return (
    <div className={styles.track} aria-label="Progress">
      <div
        className={styles.bar}
        style={style}
        role="progressbar"
        aria-valuemin={0}
        aria-valuemax={100}
        aria-valuenow={safeValue}
      />
    </div>
  );
}

对应的 Progress.module.css:

.track {
  width: 100%;
  height: 8px;
  overflow: hidden;
  border-radius: 999px;
  background: #d0d7de;
}

.bar {
  width: var(--progress);
  height: 100%;
  background: #1f883d;
  transition: width 160ms ease;
}

@media (prefers-reduced-motion: reduce) {
  .bar {
    transition: none;
  }
}

这里的关键不是禁止 style,而是避免用它表达本来有限、稳定且可静态分析的设计状态。

三年迁移说明“大爆炸重写”通常不可取

大型站点很难暂停功能开发,再一次性重做整个样式层。更稳妥的路径是允许新旧方案短期共存,同时持续压缩旧系统的使用范围。

可以先用下面的命令建立依赖和调用点清单;请按仓库目录调整 src:

# 查找直接依赖和导入
rg -n "styled-components|styled-system" package.json src

# 查找 sx props;该表达式只是初筛,仍需人工确认
rg -n "\bsx=|\bsx:\s*" src

# 统计 CSS Modules 文件数量
find src -type f \( -name "*.module.css" -o -name "*.module.scss" \) | wc -l

随后可以按以下顺序推进:

  • 冻结新增债务:Lint 或代码评审禁止新增 styled-components 和 sx。
  • 先迁移叶子组件:按钮、标签、图标容器等依赖较少,更容易验证视觉一致性。
  • 提炼稳定变体:把散落的颜色和间距覆盖转成 size、variant、density 等受控 API。
  • 建立视觉回归测试:CSS 迁移最常见的风险不是编译失败,而是层叠顺序、焦点状态和响应式布局悄悄变化。
  • 观察性能而非只看文件数:同时检查 JavaScript 体积、渲染时间、样式重计算和真实用户指标。
  • 最后删除运行时:只有搜索结果、构建产物和线上监控都确认没有旧调用后,才移除依赖。

在清理阶段,可以把残留检查放进 CI:

#!/usr/bin/env bash
set -euo pipefail

if rg -n "styled-components|styled-system|\bsx=" src package.json; then
  echo "Legacy styling API detected. Use CSS Modules or an approved replacement."
  exit 1
fi

echo "No legacy styling API found."

是否应该跟随 GitHub 迁移

答案取决于瓶颈,而不是技术潮流。如果应用规模较小、动态主题复杂,并且现有 CSS-in-JS 方案已经做了静态提取,那么迁移收益可能不足以覆盖改造成本。反过来,如果样式运行时进入性能剖析热点、组件 API 被 sx 覆盖淹没,或者客户端承担了大量可在构建阶段完成的工作,CSS Modules 就值得认真评估。

落地前可以检查四件事:

  • 是否有真实性能数据证明样式系统值得改造?
  • 设计令牌、主题和响应式规则能否用 CSS 变量与原生 CSS 表达?
  • 是否能让新旧方案共存,并按组件逐步迁移?
  • 是否具备视觉回归、可访问性和线上指标监控?

GitHub 的三年迁移给出的核心经验并不是“选择某个更时髦的库”,而是把样式重新放回合适的执行阶段。CSS Modules 提供的价值,也不仅是局部类名,而是让浏览器负责 CSS,让 JavaScript 更专注于交互与状态。


相关推荐