背景:复杂系统里的暗黑模式
暗黑模式的最终视觉效果是把界面从浅色切换到深色。在复杂前端系统里,它还会牵动产品体验、设计系统、组件体系、业务页面和工程治理。
这类场景通常发生在已经运行了一段时间的存量复杂产品中。业务页面多,历史包袱也不少。更关键的是,系统里可能同时存在 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-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 |
业务组件不应写“白色背景”或“黑色文字”,而应写“页面背景”“卡片背景”“主文本”“次级文本”“默认边框”。
.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 : lightEmptyImageCSS 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 建设、状态模型设计、基础组件迁移策略、业务改造策略和上线后的治理机制。
当这些部分都建立起来之后,暗黑模式就会成为产品可以长期演进的一套主题化能力。