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

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

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

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

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

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

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

因此,这个需求更适合被定义为:

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

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

核心问题

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

多组件库并存

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

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

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

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

设计系统能力不足

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

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

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

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

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

业务样式历史包袱

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

.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:

light.css
dark.css

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

Less 运行时编译

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

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

JS 动态操作样式

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

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

CSS Var

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

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

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

CSS Var 方案可以概括为:

顶层维护主题变量

组件消费语义 Token

切换主题时更新变量集合

典型实现如下:

: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;
}

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

document.documentElement.dataset.theme = theme

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

主题架构与数据维护

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

Global Theme Store

Theme Provider

CSS Variables

Base Components

Business Components

设计 Token

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

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

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

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

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

Global Theme Store

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

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

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

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

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

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

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

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

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

如果产品有服务端渲染或 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。
<ThemeProvider value={themeState}>
  <BaseComponentProvider theme={themeState.resolvedMode}>
    <App />
  </BaseComponentProvider>
</ThemeProvider>

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

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

例如图表主题:

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

例如编辑器主题:

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

例如图片资源:

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

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

基础组件落地

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

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

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 适合规则明确、结构稳定、收益高的迁移。

例如:

import { Button } from 'antd'

可以自动迁移成:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

不适合做:

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

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

迁移规范

LLM 生成候选 diff

研发 Review

自动化测试

页面验收

合并上线

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

基础组件迁移策略与验收

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

更稳妥的顺序是:

组件使用量统计

组件风险分级

选低风险组件试点

沉淀迁移规范

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 写法:

.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 负责运行时主题感知。业务组件同时使用这两层能力,才能完整适配暗黑模式。

特殊场景

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

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

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

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

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

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

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

验收与长期治理

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

验收需要覆盖几个维度:

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

上线后还需要治理:

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

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

总结

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

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

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

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

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

按下 K 进行搜索