深色模式这个词,这几年几乎成了App和网站的标配。前两天有项目组找到我,说要把后台管理端做一次“深色模式修改”,我一听就知道,这活儿看着简单,真正落地的时候坑不少。很多人以为深色模式就是把背景换成黑色、文字换成白色,实际上远不止这些。改完之后你会发现,边框看不清了、阴影糊成一团、表格里的斑马纹像被人泼了墨水、第三方组件死活不肯跟着变,诸如此类的问题能把你从早到晚按在调试台上。
这篇文章打算把我在这类改造里踩过的坑、整理出来的方案和直接能用的代码思路完整写下来。不管你是前端新手,还是被需求方临时拉去“顺手改个深色模式”的野生全栈,都能从这里找到参照。我会从需求拆解、技术底座、实操流程、问题排查一直讲到收尾的加分项,尽量做到看完整篇就能直接在自己项目里动手。
1. 把“深色模式修改”拆开看:改的远不止背景色
1.1 深色模式改的是整个视觉层级
先捅破一层窗户纸:深色模式不是一个简单取反操作。如果你用 CSS 的 filter: invert(1) 或者 filter: brightness(0.8) 直接整套变色,大概率会得到一堆饱和度怪异的颜色。真正要改的是信息层级。浅色模式下我们用阴影、边框、留白来区分卡片和背景,深色模式下阴影基本失效,因为深色背景里看不出深色投影,你要么改用边框描边,要么提高背景和卡片之间的明度差。
举个例子。后台管理系统里常见的侧边栏是 #F5F5F5,内容区是 #FFFFFF,这种差异可以通过阴影体现。到了深色模式,侧边栏用 #1E1E1E,内容区用 #252525,两者在视觉上依然能区分,但你要是还保留原来的投影参数,看起来就会脏兮兮的,像蒙了一层灰。所以深色模式修改的本质是重新设计一套完整的视觉变量表,并且要保证它和浅色模式表达同一个层级的含义。
1.2 三种常见改造场景,决定了完全不同的做法
不同项目的深色模式修改,技术路线差异很大。我遇到过三种比较典型的场景。
第一种是面向 C 端用户的内容型站点,比如资讯类、阅读类 App 和门户网站。这种场景最看重流畅度和“沉浸感”。改造时通常要优先保证图片、视频、广告容器在深色模式下的观感,不能出现大片纯白底突然插进深色页面的割裂感,这类项目适合做“跟随系统 + 手动切换”的双轨模式。
第二种是后台管理系统、数据可视化平台,比如我在前文提到的那个项目。核心需求是信息密度和识别效率。表格、表单、图表、状态标签的数量非常多,改造时要特别关注对比度,不然用户很容易看漏数据或者点错按钮。而且 B 端后台一般没有太多花哨视觉,反而是改造难度最高的。
第三种是纯嵌入场景,比如地图页面、弹窗组件、营销活动页。它们往往是局部模块,而不是整个应用都需要深色。此时修改思路不是“做一套主题”,而是给组件本身做暗色适配。如果你直接把全局主题变量引进来,很容易造成样式冲突,所以要控制变量作用域。
1.3 先想清楚设计原则,再动代码
我见过不少人一上来就改 CSS,结果做到一半发现颜色太多了,改了自己都记不住。我的建议是,动手之前先把设计原则定下来。
第一个原则是“语义优先”。你定义颜色的方式必须是语义化的,比如 --color-bg-page、--color-text-primary、--color-border-divider,而不是 --color-white、--color-black。前者意味着“页面的背景色”,在深色模式下自动变成深灰,样式会被正确替换;后者意味着“白色”,你深色模式下还得专门覆盖它,否则组件就会露馅。
第二个原则是“分层”。页面背景、卡片背景、悬浮背景、模态层背景,分别定义成不同的层级变量。层级越多,视觉层次越细腻,但也越难维护。建议至少分四层:页面底、容器底、浮层底、置顶底。
第三个原则是“可验证”。改造完成要有可复用的检查清单,不能光靠肉眼一顿看。后面第 4 节我会给你一个具体到操作层面的对比度验证方法。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 技术方案选型:从系统机制到项目架构
2.1 系统怎么知道用户想要深色
先把系统层面的机制说清楚。无论浏览器还是移动端,都有原生的“深色模式”开关。Web 端最常用的就是 prefers-color-scheme 媒体查询。它其实是操作系统把“当前亮暗模式偏好”通过浏览器暴露给网页,你的 CSS 可以直接读取:
css复制@media (prefers-color-scheme: dark) {
:root {
--bg-page: #121212;
--text-primary: #e0e0e0;
}
}
这段代码的意思就是:当系统告诉浏览器“我现在是深色模式”时,把页面根元素上的变量覆盖成深色值。这里有个容易被忽略的细节:prefers-color-scheme 有三个值,light、dark 和 no-preference,有些老浏览器和定制系统不一定会返回 light 或 dark,所以你的默认样式最好写在 :root 下,而不是写在 @media (prefers-color-scheme: light) 里,否则遇到 no-preference 情况会漏掉样式。
移动端和小程序类似,一般也有系统的深色开关,比如小程序的 darkmode 配置。但不管是哪一端,核心都是“系统给你一个信号,你根据信号切换样式变量点”,不能把业务逻辑写死在颜色判断里。
2.2 颜色管理:从一锤子色值到语义化 Token
如果你现在还在项目里到处硬编码 #fff、#000、#f5f5f5,那深色模式修改的第一步不是写样式,而是做颜色收编。把所有颜色逐步抽进 CSS 变量或者 SCSS 变量里。
我比较推荐用 CSS 变量,原因有两点。第一,它运行时可覆盖。JavaScript 只需要改 document.documentElement 上的 data-theme 属性,就能整体切换到另一套主题,不需要重新编译。第二,它层叠生效。你把变量定义在 :root 上,如果某个组件需要特殊处理,可以在组件顶层重新定义同名变量,局部覆盖不会污染全局。
一个最基础的变量组织方式是这样的:
css复制:root {
--bg-page: #ffffff;
--bg-card: #fafafa;
--text-primary: #1a1a1a;
--text-secondary: #6e6e6e;
--border-default: #e8e8e8;
--brand-primary: #2f6fed;
}
html[data-theme='dark'] {
--bg-page: #121212;
--bg-card: #1e1e1e;
--text-primary: #e8e8e8;
--text-secondary: #a0a0a0;
--border-default: #333333;
--brand-primary: #5b8ef2;
}
这套东西看起来平淡无奇,但它是整个改造的骨架。之后所有组件真正引用的是这些语义化变量,而不是颜色字面量。后面你遇到“某块区域怎么都变不了色”的问题,十有八九就是因为组件里还有硬编码色值没抽干净。
2.3 跟随系统还是手动开关,各自怎么落地
深色模式修改必须回答一个问题:用户能不能自己选?这里有两个方案。
方案一:纯跟随系统。你只写 @media (prefers-color-scheme: dark),不做任何手动切换按钮。优点是不需要存储用户偏好,系统是什么样就什么样,开发量最小。缺点也很明显——用户想在白天用深色,或者深夜想用浅色,看你应用怎么切换,完全没办法。
方案二:跟随系统 + 手动覆盖。应用启动时读取系统设置,默认用系统模式;但页面里提供“浅色/深色/跟随系统”三档按钮,选择后把结果存进 localStorage。之后每次加载都先检查本地存储,有值就用本地值覆盖系统值。
实践中几乎都选方案二。具体实现也很简单:
javascript复制const savedTheme = localStorage.getItem('theme');
const systemDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
if (savedTheme === 'dark' || (!savedTheme && systemDark)) {
document.documentElement.setAttribute('data-theme', 'dark');
} else {
document.documentElement.setAttribute('data-theme', 'light');
}
window.matchMedia('(prefers-color-scheme: dark)')
.addEventListener('change', (e) => {
if (localStorage.getItem('theme')) return;
document.documentElement.setAttribute(
'data-theme', e.matches ? 'dark' : 'light'
);
});
这段代码放到项目入口处,能避免大部分“闪白”问题。具体的闪白原因和解决细节,我在第 4 节再展开。
3. 实操过程:一次完整的深色模式改造记录
3.1 第一步:盘点现状,找出所有硬编码颜色
我们那个后台管理系统,改造前我做的第一件事不是写码,而是全局搜索所有十六进制颜色值。这里的血泪教训很深刻:别指望肉眼找色值,也不要靠设计师给一份色板就能高枕无忧,历史代码里的颜色值绝对比你想象得多。
我用了一个很土但非常高效的方法:在编辑器里全局搜 #,把样式文件和组件代码里的颜色全部列出来。搜索结果经常上百条,这时候先分类——哪些是文本色、哪些是背景色、哪些是边框色、哪些是图表专属色。然后逐个判断它是否会被语义化变量取代。有些颜色是临时调试留下的,直接删掉即可。这个过程大概消耗半天到一天。别嫌慢,这一步做得好,后续替换就是一马平川。
还有一个更省力的办法是把颜色提取交给脚本辅助。你可以通过 AST 工具去解析源文件中的 color、background、border、fill、stroke 等属性值,把所有十六进制值和 rgba 值列出来。工具能帮你减少遗漏,但最终的语义化归类依然要人工判断。
3.2 第二步:定义主题色板,别直接抄系统色
定义深色模式色板有个常见误区:直接拿系统推荐的纯黑 #000000 当背景。纯黑在 OLED 屏上确实能省电,但在大多数屏幕上会显得死板,和文字搭配也容易刺眼。目前主流做法是用“深灰偏黑”作为基底色,比如 Material Design 推荐 #121212,很多成熟组件库用的是 #1a1a1a、#1e1e1e。
色板定义的时候,我会从四个维度拉一个表格出来。
| 颜色角色 | 浅色模式 | 深色模式 | 说明 |
|---|---|---|---|
| 页面背景 | #ffffff |
#121212 |
最底层底色,面积最大 |
| 卡片背景 | #fafafa |
#1e1e1e |
比页面底色亮一级 |
| 悬浮/菜单背景 | #ffffff |
#2a2a2a |
弹层、下拉面板专用 |
| 主文字 | #1a1a1a |
#e8e8e8 |
正文、标题 |
| 次要文字 | #6e6e6e |
#a0a0a0 |
说明文案、辅助信息 |
| 边框分隔 | #e8e8e8 |
#383838 |
卡片边框、分割线 |
| 主色 | #2f6fed |
#5b8ef2 |
按钮、链接品牌色 |
这里有个很关键的设计细节:深色模式下的文字和背景不要用纯白和纯黑。纯白文字放在纯黑背景上,对比度可以达到 21:1,但会产生光晕感,长时间看眼睛其实更累。用接近白的浅灰和接近黑的深灰,视觉上反而更舒服。
3.3 第三步:批量替换与组件适配
色板定好之后,就可以开始批量替换。如果项目里用的是原生 CSS,可以一段一段地替换成 var(--xxx);如果用的是 SCSS 和 Less,建议先把颜色值改成变量引用,再通过编译导出成 CSS 变量,这样全局覆盖更灵活。
替换的时候我习惯按“背景优先、文字其次、边框最后”的顺序来。背景类颜色对深色模式的影响最直观,改完后整个页面立刻有了“夜间感”;文字色改动会影响可读性,需要逐个页面检查;边框类颜色影响最小,但恰恰最容易漏,漏掉之后界面会出现一些“白线”“亮线”的诡异缝隙。
组件适配是一个大主题,很多组件库自带深色主题,比如 Ant Design 的 darkAlgorithm。如果是自研业务组件,最简单的做法就是统一引用语义化变量,不要在组件内部写死颜色。如果某个组件必须同时支持浅色和深色,你可以在组件根部覆盖变量:
css复制.custom-widget {
--bg-card: #2d2d2d;
--text-primary: #ffffff;
}
这样它内部引用的 var(--bg-card) 就会自动变成本地覆盖值,不需要改组件内部逻辑。
3.4 第四步:图片、图表、地图这些特殊元素
普通的 div 和文字改起来快,真正麻烦的是图片、图表和数据可视化。我那次改造就栽在图表上:数据可视化库生成的坐标轴、网格线、提示框全是默认浅色,切换成深色模式后图表区域依然是白底,活像一块狗皮膏药。
图片的处理思路分几种。纯产品照片一般不做处理,深色模式下保持原样即可,但背景透明的 PNG 图片要注意底色的差异,有些图在浅色下看起来正常,深色下会发现透明背景透出的深色把边缘弄脏了。Logo 和图标最好使用 SVG 或字体图标,并且填充色引用当前主题变量,这样无需准备两套图片资源。如果项目里已经引入了大量 PNG 素材,简单粗暴但有效的办法是在深色模式下给图片容器加一层 filter: brightness(0.9),稍微压暗亮度,减少刺眼感。
图表类组件要单独设置样式覆盖。以 ECharts 为例,你可以读取当前主题,然后给图表传入不同的 textStyle、axisLine、splitLine 配色。更优雅的做法是通过 CSS 变量驱动的图表主题,这样切换模式时图表也跟随变化。
地图页面的改动最辛苦。地图底图往往来自第三方瓦片服务,深色模式下通常需要使用专门的暗色地图样式。如果瓦片服务不支持,只能在地图容器上盖一层半透明遮罩或者用 CSS 滤镜,这会牺牲地图的可读性,只适合临时救急。
4. 常见问题与排查技巧实录
4.1 主题切换时页面闪白
闪白问题在我接手过的项目里出现概率极高。原因通常是 HTML 文档开始解析时,还没读取到 localStorage 里的主题值,页面先以浅色模式渲染了一遍,然后再被 JS 切到深色,导致用户看到一帧白屏。
解决思路有两个方向。一个是把主题推断脚本提前到 <head> 里同步执行,因为浏览器解析到 <body> 之前就会执行它,所以首屏渲染时 CSS 变量已经是正确主题。另一个方向是在 CSS 加载前先用内联样式把 data-theme 属性设好,比如直接在 html 标签上用内联脚本设置 documentElement 的属性。等 CSS 真正开始渲染时,变量已经就位,就不会闪。
如果你用框架(比如 Vue、React),千万不要把主题设置放在 App.vue 或者 app.tsx 的 mounted 钩子里,那个时机已经太晚了。要放在入口文件的顶部,甚至放进 index.html 的内联脚本里。
4.2 第三方组件怎么都不肯变黑
第三方组件不肯变黑的原因大多数是样式优先级问题。组件库的样式往往通过 !important 或者极高的选择器权重来固化,你自己的 var(--xxx) 干不过它。
排查步骤一般是这样。先用浏览器开发者工具审查那个顽固组件,看它的背景色值从哪里来,如果看到的是 background: #ffffff !important,那就只能乖乖提高覆盖权重。工具里会显示样式来源文件和行号,顺着找就能定位。
解决方式也有几层。能升级组件库的,优先看它官方有没有 dark 主题 API。不能升级的,就写一层样式覆盖,注意放在组件样式引入之后,并且选择器要比它更具体。还不行就只好用 !important,但要控制使用范围,并在注释里写清楚原因,方便后面维护的人接手。
顺便提一句,很多组件库自带深色主题切换,但开启后并不是全量适配。你还需要处理自定义业务组件,所以“组件库深色 + 业务组件深色”都要做,缺一不可。
4.3 颜色对比度看着难受又说不清原因
很多深色模式改完,用户反馈“看不清”“刺眼”,但又说不出具体哪里有问题。这时候要用工具量化。
我习惯用 Chrome DevTools 的无障碍检查来做对比度检测。选中一个文本元素,Elements 面板里直接会显示它的对比度值,是否通过 WCAG AA 要求一目了然。正文文本推荐达到 4.5:1,大号文本可以达到 3:1。
排查时最常出现的问题是“灰色文字在深色背景上糊了”。比如浅色模式下 #999999 用在 #ffffff 背景上可能还能读,但深色模式下同样用 #999999 放在 #121212 背景上,对比度只是勉强及格。所以深色模式里的次要文字不能简单沿用浅色值,通常要适当提亮,比如改成 #a0a0a0 或 #bdbdbd。
4.4 深色模式下图片太亮、阴影太重
页面切换到深色之后,原有的图片如果没有做任何处理,亮度会显得很高,尤其是一些亮色背景的产品图。我的做法是在深色模式下给图片统一加一个 opacity 或者 filter: brightness 的小幅度降低,推荐控制在 0.9 左右。但注意不要加太多层滤镜,否则会让图片失真。
阴影的问题更隐蔽。浅色模式下的卡片阴影在深色模式中基本不可见,有些开发为了“让阴影可见”,会调高阴影的偏移量和透明度,结果整个界面变得脏、重。正确的做法是用边框来替代阴影,或者在深色模式里把阴影设为半透明深黑色,并在卡片周围加一条极细的浅灰边框,视觉上会干净很多。
这里给一个我常用的深色模式阴影参考值:
css复制html[data-theme='dark'] {
--shadow-card: 0 2px 8px rgba(0, 0, 0, 0.4);
--border-card: 1px solid #333333;
}
阴影变深了,但边界条件要跟浅色模式下保持一致的逻辑。
5. 收尾阶段能做的加分项
5.1 让切换过程有过渡,别硬切
深色模式和浅色模式之间的切换,如果不加任何过渡,用户看起来就是“啪”地一下变了,视觉跳跃感很强。优化方法是给背景色、文字色等主要视觉变量加一个 transition。
但这里有个非常容易踩的坑:如果你给所有带颜色属性都加了 transition,页面加载初始时也会出现颜色渐变或者闪烁,尤其在组件进入视口的时候,整个列表可能会有一段诡异的颜色过渡动画。我的做法是只在主题切换动作发生时全局加一个过渡类:
css复制html.theme-transition *,
html.theme-transition *::before,
html.theme-transition *::after {
transition: background-color 0.2s ease, border-color 0.2s ease, color 0.2s ease;
}
切换主题前给 html 加上 theme-transition 类,切换完成后再移除。这样既能平滑过渡,又不会影响日常渲染性能。
5.2 用户手动选择与系统自动检测的联动
我建议把模式控制做成三态:浅色、深色、跟随系统。很多应用只提供两态,用户一旦手动选了深色,以后系统改成浅色他也不会跟着变,容易造成体验割裂。
三态的交互实现并不复杂。用户选“跟随系统”时,删除 localStorage 里保存的主题值,然后立刻用系统当前偏好重新设置主题。如果用户手动选了浅色或深色,就把选择存下来,之后系统模式的变更不再影响应用。
还有一个细节值得注意:系统偏好变化是通过 prefers-color-scheme 媒体查询监听触发的。但有些 Webview 容器里这个监听事件不稳定,建议在切换成功后主动重新读取当前系统偏好,保证状态同步。
5.3 深色模式的性能与流量细节
深色模式默认使用 CSS 变量切换,不会带来额外的网络请求。但如果你的图片方案是“浅色一张图、深色一张图”,那么会有双倍资源加载问题。我更推荐用 SVG 和 CSS 技巧替代图片,实在不行再用 <picture> 标签做条件加载,只在匹配模式下加载对应图片资源。
内存和绘制性能上,深色模式并不比浅色模式消耗多高,但如果你在深色模式里给大量元素加了复杂滤镜,渲染压力会明显上升。特别是列表页、表格页这种元素密集的场景,过滤镜能省则省。另外,大量过渡动画在低端机上也会有卡顿感,动画范围不要铺满全局。
补充一个和我实际体验有关的小细节:深色模式下页面里的滚动条、弹窗遮罩、loading 状态这些“边角料”很容易被漏掉。滚动条残留浅色,在深色页面里会非常突兀。主流的处理方式是自定义滚动条颜色,并放在深色主题变量里一并切换。
我个人在实际项目里的体会是,深色模式修改最怕的不是工作量大,而是“表面做了但实际上没做透”。真正合格的改造是用户察觉不到切换过程的异常,只会觉得“哦,这个界面本来就该有这个模式”。所以验收的时候不要太依赖自己的眼睛,多拉几个同事来盲测,把常见的页面、弹窗、表单、图表都过一遍。最后再分享一个小技巧:如果你不想在几十个页面里手动切换测试,可以写一小段脚本,自动遍历路由并监听页面加载后的关键元素对比度,虽然做不到完全代替人眼,但能帮你筛掉八成明显的漏网之鱼。
