# 前端暗黑模式：从组件治理到工程落地

> 介绍暗黑模式在存量前端系统中的工程实践，涵盖方案设计、主题架构、组件迁移、业务适配和后续治理。

- 发布于：2025-10-07
- 更新于：2025-10-07
- 分类：软件开发
- 标签：前端、工程化
- 原文：https://chanx.tech/blog/darkmode

## 背景：复杂系统里的暗黑模式

暗黑模式的最终视觉效果是把界面从浅色切换到深色。在复杂前端系统里，它还会牵动产品体验、设计系统、组件体系、业务页面和工程治理。

这类场景通常发生在已经运行了一段时间的存量复杂产品中。业务页面多，历史包袱也不少。更关键的是，系统里可能同时存在 3 - 4 套组件库，不同业务模块在不同阶段引入过不同的基础组件。按钮、表格、弹窗、表单、选择器、业务卡片、筛选区、图表和详情页混在一起，样式规范并不统一。

在这种背景下，如果直接开始写 `.dark` 样式，结果大概率会变成：

- 基础组件有一套暗黑逻辑；
- 业务组件再补一套覆盖样式；
- 图表、图片、编辑器等特殊模块单独打补丁；
- 新页面上线后继续新增硬编码颜色；
- 组件库升级后暗黑模式反复回归。

短期看，页面可能可以切成深色。长期看，这会演变成持续返工和反复回归的工程问题。

因此，这个需求更适合被定义为：

```txt
为产品建立一套可维护、可扩展、可治理的主题化能力。
```

这个定义会直接影响后续所有决策。暗黑模式的核心工作不是“写多少 CSS”，而是“如何让产品里的界面资产都接入同一套主题语义”。

## 核心问题

暗黑模式落地前，需要先把问题拆清楚。复杂存量系统中的主题化改造通常会同时遇到五类挑战。

### 多组件库并存

系统里同时存在多套组件库时，每套组件库都有自己的样式变量、主题机制、DOM 结构和覆盖方式。

如果业务页面直接使用不同组件库，暗黑模式会被迫跟着组件库差异走：

```txt
A 组件库按钮一套主题方式
B 组件库表格一套主题方式
C 组件库弹窗一套主题方式
业务自定义组件再写一套覆盖方式
```

这种方式会让主题能力退化成样式补丁集合。真正可维护的方案必须先解决基础组件统一问题。

### 设计系统能力不足

暗黑模式需要设计系统支持。没有设计系统，前端即使能实现切换，也很难保证产品长期一致。

这里说的设计系统，核心是一套由产品、设计和前端共同认可的主题语义。页面背景、卡片背景、主文本、次级文本、默认边框、禁用态、警告态、错误态都需要有明确 Token，避免直接散落为 `#fff`、`#333`、`#999` 这样的颜色值。

同一个 `#999` 在不同位置可能代表完全不同的语义：

- 次级文本；
- 输入框 placeholder；
- 禁用态文本；
- 辅助说明；
- 弱边框；
- 默认图标色。

这些语义在暗黑模式里不应使用同一个颜色值。

### 业务样式历史包袱

基础组件支持暗黑模式，并不意味着业务页面会自动完成适配。真实页面里往往存在大量业务样式：

```css
.filter-panel {
  background: #fff;
  border: 1px solid #eee;
}

.amount {
  color: #333;
}
```

这些样式不会因为基础组件支持 DarkMode 而自动变好。它们需要被重新映射到设计 Token。

### 运行时主题状态维护

暗黑模式不是一个局部 UI 状态。它通常会和用户偏好、系统设置、租户配置、服务端渲染、组件库 Provider、业务组件 Context 发生关系。

如果主题状态没有唯一数据源，就容易出现：

- 顶层 CSS 变量已经切换，但组件库主题未同步；
- 基础组件已切换，但图表、编辑器仍是浅色；
- 用户选择已保存，但刷新后首屏闪烁；
- 不同页面各自维护主题状态，切换行为不一致。

因此，主题数据必须作为全局状态被设计，而不是散落在某个页面组件里。

### 迁移和验收成本

基础组件统一涉及大量存量代码迁移。迁移方式可以是人工、AST 自动化，也可以结合 LLM 辅助，但每种方式都有适用边界。

迁移验收需要同时关注编译结果、业务行为、视觉一致性、暗黑模式完整度以及旧组件入口是否真正收口。没有清晰验收标准，基础组件迁移很容易变成一次没有终点的重构。

## 目标与边界

暗黑模式的目标可以拆成四层。

第一层是用户体验目标。用户可以在产品里切换 LightMode 和 DarkMode，如果产品定位需要，也可以支持跟随系统设置。切换后，页面不应出现明显闪烁、割裂、可读性下降或局部区域失效。

第二层是产品一致性目标。暗黑模式不是只覆盖首页或核心路径，而是要成为产品能力。用户在不同模块之间跳转时，看到的主题表现应保持连续，不应出现一个页面是深色，另一个页面突然回到浅色。

第三层是工程目标。主题状态要有唯一数据源，组件接入方式要稳定，变量命名要有规范，业务同学新增页面时要知道如何写样式，而不是凭经验猜。

第四层是长期治理目标。暗黑模式上线后仍然会持续迭代，所以需要 Code Review 规则、组件文档、样式约束和回归检查，避免几次需求之后主题能力慢慢退化。

落地边界上，暗黑模式不应该只覆盖基础组件，也不能一开始就要求所有历史页面无差别全量完成。更稳妥的边界是：

- 新增页面必须接入统一基础组件和主题 Token；
- 高频页面和核心链路优先完成适配；
- 低频历史页面按业务迭代逐步迁移；
- 特殊区域允许固定主题，但必须显式声明；
- 旧组件库直接使用方式逐步收口。

## DarkMode 方案对比与决策

暗黑模式常见实现大致有几类：编译时生成多套样式、Less 运行时编译、JS 动态操作样式、CSS Var。

### 编译时生成多套样式

使用 Sass、Less 等预处理器在构建阶段生成多套 CSS：

```txt
light.css
dark.css
```

这种方式适合传统样式体系，也适合已经大量使用 Less 变量的项目。但它的问题是运行时切换不够自然，多主题产物体积更大，动态主题能力较弱。如果后续要支持租户主题或用户自定义主题，扩展成本会比较高。

### Less 运行时编译

有些组件库历史上依赖 Less 变量，可以通过运行时修改 Less 变量并重新编译样式来切换主题。

这个方案看起来接入快，但它把主题切换和运行时编译绑在一起，容易带来性能、闪烁、样式顺序和构建工具兼容问题。对于长期产品能力来说，不适合作为主方案。

### JS 动态操作样式

也可以通过 JS 动态生成 style 标签，或者在切换主题时批量修改样式。

它足够灵活，但边界太容易失控。主题语义会散落在 JS 逻辑里，后续很难沉淀成设计系统能力。

### CSS Var

这一类场景最终更适合选择 CSS Var 作为主方案。

选择它的原因不是因为它流行，而是它更符合产品长期目标。

| 维度 | CSS Var 的价值 |
| --- | --- |
| 运行时切换 | 切换顶层属性即可生效 |
| 设计系统 | 可以直接承载语义 Token |
| 组件接入 | 基础组件和业务组件都能消费 |
| 动态能力 | 支持租户、用户偏好和运行时覆盖 |
| 性能 | 不需要重新编译样式 |
| 治理 | 变量集中维护，业务按语义引用 |

CSS Var 方案可以概括为：

```txt
顶层维护主题变量
  ↓
组件消费语义 Token
  ↓
切换主题时更新变量集合
```

典型实现如下：

```css
:root {
  --color-bg-page: #f7f8fa;
  --color-bg-surface: #ffffff;
  --color-text-primary: #1f2329;
  --color-text-secondary: #646a73;
  --color-border-default: #dee0e3;
}

[data-theme='dark'] {
  --color-bg-page: #111318;
  --color-bg-surface: #1b1f27;
  --color-text-primary: #f2f3f5;
  --color-text-secondary: #a8abb2;
  --color-border-default: #343946;
}
```

实现上只需要在顶层切换主题标识：

```ts
document.documentElement.dataset.theme = theme
```

但真正的复杂度不在这行代码，而在变量体系、组件接入、业务改造和长期治理。

## 主题架构与数据维护

暗黑模式落地后，前端主题能力可以拆成五层：

```txt
Global Theme Store
  ↓
Theme Provider
  ↓
CSS Variables
  ↓
Base Components
  ↓
Business Components
```

### 设计 Token

真正可维护的暗黑模式，需要至少三层 Token。

| 层级 | 作用 | 示例 |
| --- | --- | --- |
| 基础色板 | 提供原始颜色集合 | `gray-100`、`blue-600`、`red-500` |
| 语义 Token | 表达业务语义，是业务样式主要入口 | `color-bg-page`、`color-text-primary`、`color-border-default` |
| 组件 Token | 描述组件内部状态 | `button-primary-bg`、`table-header-bg`、`modal-mask-bg` |

业务组件不应写“白色背景”或“黑色文字”，而应写“页面背景”“卡片背景”“主文本”“次级文本”“默认边框”。

```css
.order-card {
  background: var(--color-bg-surface);
  color: var(--color-text-primary);
  border: 1px solid var(--color-border-default);
}
```

这样设计师调整暗黑模式色阶时，前端不需要逐页面修改。业务页面只要使用了正确语义，就能自然跟随主题变化。

### Global Theme Store

主题状态必须有唯一数据源。

主题数据适合放在 Global Store 中维护，至少包括用户选择和最终生效结果。

```ts
type ThemeMode = 'light' | 'dark' | 'system'

interface ThemeState {
  mode: ThemeMode
  resolvedMode: 'light' | 'dark'
  source: 'user' | 'system' | 'tenant' | 'default'
}
```

`mode` 表示用户选择，`resolvedMode` 表示最终生效主题。

例如用户选择跟随系统，系统当前是深色模式，那么状态可以表示为：

```ts
{
  mode: 'system',
  resolvedMode: 'dark',
  source: 'system'
}
```

这个拆分很重要。业务组件不应自己判断系统偏好，只需要消费最终主题。

主题来源可以按优先级处理：

```txt
用户手动选择 > 租户配置 > 系统偏好 > 默认主题
```

如果产品有服务端渲染或 HTML 模板直出，还需要在页面加载前尽早写入主题，避免先显示浅色再切到深色。

```html
<script>
  const theme = localStorage.getItem('theme') || 'system'
  const systemDark = window.matchMedia('(prefers-color-scheme: dark)').matches
  const resolvedTheme = theme === 'system' ? (systemDark ? 'dark' : 'light') : theme
  document.documentElement.dataset.theme = resolvedTheme
</script>
```

这段逻辑需要尽量前置，确保 CSS 加载时就能命中正确变量。

### Theme Provider 与 Context

Theme Provider 负责把主题状态传给组件树。

它同时承担两件事：

- 同步顶层 CSS Var，例如设置 `data-theme`；
- 向基础组件和业务组件提供 Context。

```tsx
<ThemeProvider value={themeState}>
  <BaseComponentProvider theme={themeState.resolvedMode}>
    <App />
  </BaseComponentProvider>
</ThemeProvider>
```

基础组件需要 Context，是为了把主题传给底层组件库。例如 Ant Design、Element Plus 或其他组件库往往有自己的 ConfigProvider、ThemeProvider 或主题算法。

业务组件同样需要 Context，因为不是所有场景都能通过 CSS Var 解决。

例如图表主题：

```ts
const chartTheme = theme.resolvedMode === 'dark' ? darkChartTheme : lightChartTheme
```

例如编辑器主题：

```ts
editor.setTheme(theme.resolvedMode === 'dark' ? 'vs-dark' : 'vs')
```

例如图片资源：

```tsx
const emptyImage = theme.resolvedMode === 'dark' ? darkEmptyImage : lightEmptyImage
```

CSS Var 解决的是样式变量问题，Context 解决的是运行时主题感知问题。两者不是替代关系，而是配合关系。

## 基础组件落地

基础组件统一是暗黑模式能否长期成功的前置条件。

更稳妥的方式是在业务侧建立基础组件层。业务页面不直接依赖底层组件库，而是依赖统一出口。

```tsx
import { Button as AntButton } from 'antd'

export function Button(props: ButtonProps) {
  return <AntButton {...props} />
}
```

这个例子看起来简单，但背后的工程意义很大。基础组件层可以逐步承载：

- 统一的组件 API；
- 统一的尺寸、状态和交互规范；
- 统一的主题接入方式；
- 对底层组件库差异的适配；
- 后续组件库替换或升级的缓冲层。

基础组件层优先处理高频、通用、影响面积大的组件：

- Button；
- Input、Select、DatePicker；
- Form；
- Table、Pagination；
- Modal、Drawer；
- Tooltip、Popover；
- Message、Notification；
- Tabs、Menu、Dropdown。

如果底层组件库已经提供暗黑模式，优先接入它的官方能力。如果官方能力不完整，再通过 CSS Var 和样式覆盖补齐。

这里的目标不是让每一个细节一次性完美，而是让基础组件形成统一出口。只要新页面使用统一基础组件，默认就能获得暗黑模式能力。

### 基础组件迁移方式

基础组件统一说起来很清楚，真正难的是迁移。

在一个已经运行多年的产品里，基础组件可能散落在几百个文件里。不同页面的写法不一致，有些组件被二次封装过，有些组件直接透传了底层组件库的私有属性，还有些页面依赖了组件库特定的 DOM 结构或样式类名。

因此，迁移不能简单理解成“把 import 路径替换一下”。它更像一次有风险分级的工程迁移。

迁移方式可以分成三类：人工迁移、AST 自动化迁移、结合 LLM 辅助迁移。

人工迁移适合复杂度高、业务语义强、风险不容易自动判断的组件。

典型场景包括：

- Table 列渲染逻辑复杂；
- Form 和业务校验强绑定；
- Modal 内部有复杂交互；
- Select、DatePicker 做过大量二次封装；
- 组件属性和业务状态混在一起。

这类场景如果强行自动化，很容易生成“代码能跑，但业务细节变了”的结果。更稳妥的方式是由熟悉业务的同学按模块迁移，并配合页面验收。

人工迁移的关键不是慢慢改，而是要有明确边界：

- 每次只迁移一个页面或一个业务模块；
- 每个 PR 控制改动范围；
- 迁移前后保留行为对照；
- 复杂组件先补基础用例或截图；
- 出现不确定语义时回到组件规范，而不是临时兼容。

AST 适合规则明确、结构稳定、收益高的迁移。

例如：

```tsx
import { Button } from 'antd'
```

可以自动迁移成：

```tsx
import { Button } from '@/components/base'
```

再比如一些简单属性可以做规则映射：

```tsx
<Button type="primary" size="large" />
```

如果统一基础组件仍然兼容这些属性，就可以安全迁移。

AST 迁移适合处理这类问题：

- import 来源替换；
- 组件命名替换；
- 简单属性重命名；
- 删除废弃属性；
- 添加统一前缀或默认属性；
- 统计迁移覆盖率。

但 AST 不适合判断复杂业务语义。比如一个 `type="danger"` 到底是删除操作、风险提示，还是只是历史样式滥用，自动脚本很难判断。

所以 AST 更适合作为批量迁移工具，而不是最终决策者。

LLM 适合处理“有规律但不完全结构化”的迁移。

例如业务代码里存在大量相似但不完全一致的写法：

- import 路径不同；
- 组件属性组合不同；
- 局部样式和组件混在一起；
- 组件使用方式不标准；
- 迁移后需要顺手改成 Token 写法。

这类场景用纯 AST 会写很多规则，用人工迁移又比较耗时。LLM 可以作为辅助工具，先根据组件迁移规范生成候选改动，再由研发做 Review。

但 LLM 不能直接作为无监督迁移工具。它更适合做：

- 生成迁移建议；
- 处理低风险重复代码；
- 根据迁移规范改写组件使用；
- 解释某个组件迁移风险；
- 辅助生成测试和验收清单。

不适合做：

- 大范围无 Review 自动提交；
- 修改复杂业务逻辑；
- 判断产品语义；
- 替代视觉验收。

更安全的方式是把 LLM 放在“半自动迁移”位置：

```txt
迁移规范
  ↓
LLM 生成候选 diff
  ↓
研发 Review
  ↓
自动化测试
  ↓
页面验收
  ↓
合并上线
```

这样既能提升效率，也能避免把迁移风险完全交给模型。

### 基础组件迁移策略与验收

实际推进时，不适合一开始就全量迁移。

更稳妥的顺序是：

```txt
组件使用量统计
  ↓
组件风险分级
  ↓
选低风险组件试点
  ↓
沉淀迁移规范
  ↓
AST / LLM 批量辅助
  ↓
复杂场景人工迁移
  ↓
核心页面回归
  ↓
关闭旧组件入口
```

组件可以按迁移风险分级：

| 等级 | 类型 | 迁移方式 |
| --- | --- | --- |
| 低风险 | Button、Icon、Tag、Tooltip 等简单组件 | AST 或 LLM 批量迁移 |
| 中风险 | Input、Select、DatePicker、Pagination 等表单和交互组件 | 半自动迁移加人工 Review |
| 高风险 | Table、Form、Modal、Upload、业务复合组件 | 人工迁移为主 |

迁移过程中还要保留统计数据。比如每周关注：

- 旧组件引用数；
- 新基础组件覆盖率；
- 已迁移页面数；
- 暗黑模式验收通过率；
- 迁移后缺陷数；
- 剩余高风险组件清单。

基础组件迁移的验收不能只看编译是否通过。至少要覆盖四个层面。

代码层面：

- 旧组件 import 是否减少；
- 是否仍然直接引用底层组件库；
- 是否出现新的硬编码颜色；
- 是否绕过统一基础组件。

功能层面：

- 表单输入、选择、提交是否正常；
- 弹窗、抽屉、浮层交互是否正常；
- 表格排序、筛选、分页是否正常；
- 禁用、加载、错误等状态是否保留。

视觉层面：

- LightMode 下是否和迁移前保持一致；
- DarkMode 下是否符合设计 Token；
- hover、active、focus、disabled 状态是否完整；
- 弹窗、浮层、下拉菜单是否有亮色残留。

回归层面：

- 核心页面人工验收；
- 基础组件截图对比；
- 高频业务链路回归；
- 发现问题后可以快速回滚到旧组件。

迁移完成的标准也要明确：不是“这批代码合并了”，而是“旧组件入口被收口，新页面不会再继续扩散旧写法，核心页面在 LightMode 和 DarkMode 下都通过验收”。

## 业务组件落地

基础组件完成后，暗黑模式仍然无法自动覆盖所有页面。真实产品里有大量业务样式和业务组件，它们需要在基础能力之上继续完成适配。

### 业务样式 Token 化

业务样式需要逐步改成 Token 写法：

```css
.filter-panel {
  background: var(--color-bg-surface);
  border: 1px solid var(--color-border-default);
}

.amount {
  color: var(--color-text-primary);
}
```

业务组件改造不能机械替换颜色，而要回到业务语义。金额涨跌色、审批状态色、风险等级色、禁用态、异常态都需要和设计确认语义，而不是前端随手找一个深色变量替换。

改造优先级可以这样定：

| 优先级 | 范围 | 原因 |
| --- | --- | --- |
| P0 | 页面背景、主文本、主容器、弹窗、表单、表格 | 影响基础可用性 |
| P1 | 状态标签、提示、空状态、图表、详情卡片 | 影响业务理解 |
| P2 | 插图、图片、阴影、动效、特殊模块 | 影响体验完整度 |

高频页面和核心链路优先，低频页面可以跟随业务迭代逐步补齐。

### 业务组件 Context

业务组件需要 Context，是因为不是所有主题差异都能通过 CSS Var 表达。

典型场景包括：

- 图表主题；
- Monaco / Ace Editor 这类编辑器主题；
- 地图主题；
- 图片资源切换；
- 复杂可视化画布；
- 特殊业务组件的状态计算。

CSS Var 负责静态样式变量，Context 负责运行时主题感知。业务组件同时使用这两层能力，才能完整适配暗黑模式。

### 特殊场景

暗黑模式真正落地时，特殊场景往往比基础颜色更麻烦。

固定主题区域不应跟随全局主题，例如合同预览、第三方嵌入页面、截图预览、品牌素材、富文本历史内容。

这类区域不能靠偶然样式覆盖逃逸，而应显式声明固定主题：

```html
<div data-theme="light">
  <!-- 固定浅色展示区域 -->
</div>
```

图表的暗黑模式适配还包括坐标轴、网格线、tooltip、图例、数据系列、选中态和高亮态。如果产品里图表较多，需要单独维护图表主题，避免每个业务图表各自判断颜色。

浅色模式中，阴影经常用来表达层级。暗黑模式中，阴影在深色背景上可能并不明显。暗黑模式更适合通过背景色阶、边框、描边和透明遮罩表达层级。这部分需要设计重新确认，而不是把浅色阴影变量直接搬过去。

纯色图标尽量使用 `currentColor` 或 CSS Var。复杂图片、插图、空状态图需要确认是否适合深色背景。如果图片本身带白底，暗黑模式下会非常割裂，通常需要提供深色版本或重新设计。

## 验收与长期治理

暗黑模式上线前，验收不应只看“页面是不是黑了”。

验收需要覆盖几个维度：

- 主题切换是否稳定，是否有明显闪烁；
- 基础组件状态是否完整，包括 hover、active、disabled、focus、selected、error；
- 核心业务页面是否存在亮色残留；
- 文本和背景对比度是否足够；
- 图表、图片、编辑器等特殊模块是否可读；
- 弹窗、抽屉、浮层、Toast 是否跟随主题；
- 局部固定主题区域是否符合预期；
- 新增页面是否有明确接入规范。

上线后还需要治理：

- 样式规范中禁止无语义硬编码颜色；
- Code Review 关注 Token 使用；
- Stylelint 可以限制直接写颜色值；
- 组件文档展示 Light 和 Dark 两种状态；
- 组件库升级时增加暗黑模式回归；
- 核心页面可以接入视觉回归测试；
- 旧组件库直接使用方式需要持续收口。

只有把治理机制补上，暗黑模式才不会在后续迭代中逐渐失效。

## 总结

从高级前端视角看，暗黑模式落地更接近一次产品级基础能力建设。

这类场景可以沉淀出一套相对稳定的方案：

```txt
以设计系统定义主题语义
以 CSS Var 承载设计 Token
以 Global Store 维护主题数据
以 Theme Provider 下发主题上下文
以基础组件统一消化组件库差异
以业务组件改造补齐剩余界面
以规范和验收机制保证长期可维护
```

CSS Var 是技术方案，但不是全部答案。真正决定暗黑模式能否稳定落地的，是前期范围判断、组件体系收口、设计 Token 建设、状态模型设计、基础组件迁移策略、业务改造策略和上线后的治理机制。

当这些部分都建立起来之后，暗黑模式就会成为产品可以长期演进的一套主题化能力。
