
深色模式这事儿近几年几乎成了所有正经产品的标配但真正能把主题切换做得干净、不闪烁、不割裂的团队其实不多。我见过太多项目白天模式美如画黑夜模式一打开要么背景亮得刺眼要么图片带着一圈白边要么切换的瞬间整个页面白闪一下。说白了大多数人把深色模式理解成了“换一套颜色”而不是“一套可维护的主题系统”。这篇就基于我实际做过的几个项目聊聊CSS媒体查询、主题切换的实现方案以及那些文档里不会写清楚的坑。不管你是刚接触CSS的新手还是已经在项目里被深色模式折磨过的老手这篇应该都能给你一些参考。1. 先搞清楚深色模式到底在解决什么问题1.1 不只是“省电”和“护眼”这么简单很多人一聊深色模式张口就是“省电”“护眼”。这话对但不全对。AMOLED屏幕下深色像素确实更省电低亮度环境下深色背景也确实减少眩光但更核心的诉求是用户对光线环境的自适应。晚上躺床上刷手机周围一片黑屏幕却白晃晃的瞳孔会不断收缩放大眼睛很快就干涩疲劳。这跟你在暗房里开一盏白炽灯是一个道理——不是灯不够亮而是色温、亮度和环境不匹配。深色模式本质上是对“环境光”的响应所以它才被设计成跟随系统的媒体查询而不是一个单纯的手动开关。这里有个很关键的点深色模式不是把白底换成黑底就完事了。灰度反转、纯黑背景、高饱和文字这些都是新手最容易掉进去的坑。真要做得好得考虑亮度层次、对比度、色相偏移甚至阴影的透明度这些我在后面章节展开讲。1.2 用户期望值跟随系统还是手动覆盖做产品调研的时候我常问一句话用户到底想要什么答案通常分两类。一类用户希望“我系统开了深色App就自动深色”这是大多数普通用户的心智模型也就是跟随系统。另一类用户希望“系统是浅色但App里我就想用深色”这类用户多半是有眼部敏感或特定使用习惯的人。所以真正完善的方案是三层结构系统设置、App内手动开关、以及“跟随系统”这个默认中间态。这也决定了你代码层面的架构——不能只写一个prefers-color-scheme媒体查询就收工你得有一套主题状态管理的机制。我见过不少项目第一版只做了自动跟随第二版加手动开关的时候几乎把样式重写了一遍。原因就是当初颜色值直接写在媒体查询里没有抽成变量。2. 核心机制解读prefers-color-scheme 媒体查询与CSS变量配合2.1 prefers-color-scheme 是怎么工作的我先说结论prefers-color-scheme是CSS媒体查询它读取的是操作系统层面的主题偏好而不是浏览器设置。macOS在“系统偏好设置—外观”里切换Windows在“设置—个性化—颜色”里选Android和iOS也都有类似入口。浏览器会把系统这个状态暴露给CSS和JS。基础写法长这样:root { --bg-color: #ffffff; --text-color: #1a1a1a; } media (prefers-color-scheme: dark) { :root { --bg-color: #1a1a1a; --text-color: #f0f0f0; } } body { background-color: var(--bg-color); color: var(--text-color); }这套写法的核心思路是颜色值全部收敛到CSS变量里媒体查询只负责在不同主题下修改变量值。业务代码里不会出现任何一处硬编码颜色这样加主题、换主题、删主题都只动一个地方。2.2 为什么用CSS变量而不是直接写两套样式你可能会想那我不写变量直接在媒体查询里覆盖每个元素的行不行行但你要付出的代价是后期维护的噩梦。一旦设计稿改了主色你得在媒体查询里把所有改过颜色的地方翻出来一个个替换漏一个就是一颗“彩蛋”。用CSS变量的好处有几点单一数据源主题色、背景色、文字色、边框色、阴影色都定义在:root里改设计就是改变量。运行时可切换变量可以在JS里通过element.style.setProperty(--bg-color, #000)直接覆盖这为手动主题切换提供了底子。天然支持嵌套覆盖组件局部想覆盖主题色在组件根节点上重设变量即可不必跟全局主题打架。2.3 媒体查询的兄弟color-scheme属性很多人不知道CSS还有一个color-scheme属性它的作用不是帮你换颜色而是告诉浏览器“我这个页面支持哪些主题”从而影响浏览器默认控件的渲染。举个例子复选框、单选按钮、滚动条、表单下拉框这些原生控件它们的配色不归你的CSS管而是由浏览器根据系统主题自动渲染。如果你页面是深色背景但系统是浅色模式原生控件就会白得刺眼。解决方案是在:root上声明:root { color-scheme: light dark; }这样浏览器就知道页面同时支持两种主题它会根据系统偏好渲染表单控件。如果你只支持深色可以写color-scheme: dark。这个属性我强烈建议所有人都加上它解决的是“页面里那块永远白着的地方”的问题而且零成本。3. 主题切换的三层架构从自动到手动再到持久化3.1 第一层纯CSS的自动跟随第一版实现通常就是前文写的媒体查询方案代码量最少适合静态站或不需要手动切换的页面。它有一个天然缺陷用户无法在页面内覆盖系统设置。如果你所在的城市电网不稳深夜关灯但系统还是浅色模式用户就只能忍受刺眼的屏幕。所以纯CSS方案适合什么场景适合那些“工具型页面”“文档页”“临时活动页”用户停留时间短、交互少不需要记忆偏好。它最大的优点就是零JavaScript、零闪烁、实现成本极低。3.2 第二层手动切换的class方案如果需要给用户一个按钮“亮/暗”两个状态来回切我推荐用class方案。思路是这样的页面根节点默认不带任何主题class此时跟随系统。用户手动选择“深色”就往html上添加classdark同时用JS写入localStorage。用户手动选择“浅色”就添加classlight同样持久化。用户选择“跟随系统”就移除那两个class回落到媒体查询逻辑。CSS这边其实不用改太多只需把媒体查询里的变量改名为class选择器:root { --bg: #fff; --text: #1a1a1a; } html.dark { --bg: #1a1a1a; --text: #f0f0f0; } media (prefers-color-scheme: dark) { html:not(.light) { --bg: #1a1a1a; --text: #f0f0f0; } }注意这里有个巧思html:not(.light)。意思是“系统是深色且用户没有明确选择浅色时应用深色变量”。这样用户选择“跟随系统”时变量会自动落到系统主题上用户选择“浅色”时class覆盖掉媒体查询的结果。3.3 第三层JS管理的持久化与状态同步第三层考虑的是更复杂的场景多页面站点、需要记住用户偏好的SPA、或是有登录态的Web应用。这里我惯用的做法是用一个很小的工具函数来管理主题状态const themeKey site-theme; function getSystemTheme() { return window.matchMedia((prefers-color-scheme: dark)).matches ? dark : light; } function applyTheme(theme) { const root document.documentElement; root.classList.remove(dark, light); if (theme dark || theme light) { root.classList.add(theme); localStorage.setItem(themeKey, theme); } else { localStorage.removeItem(themeKey); } } function initTheme() { const saved localStorage.getItem(themeKey); if (saved dark || saved light) { applyTheme(saved); } else { applyTheme(getSystemTheme()); } } // 监听系统主题变化 window.matchMedia((prefers-color-scheme: dark)) .addEventListener(change, (e) { if (!localStorage.getItem(themeKey)) { applyTheme(e.matches ? dark : light); } }); initTheme();这段代码有几个细节值得说。第一matchMedia().addEventListener(change)监听的是系统主题变化事件用户不手动覆盖时页面能实时跟随系统切换不用刷新。第二localStorage只存用户手动选择的选项不存系统自动推导的结果这样系统主题变了还能随时跟进。第三applyTheme里先移除再添加class保证状态不会叠加出“dark light”这种脏class。3.4 防止页面闪烁内联脚本提前执行这是个必须单独讲的问题。如果主题逻辑放在JS文件里在head里正常引入它会在页面渲染前阻塞一会儿然后执行但很多浏览器在JS执行之前就把背景色画出来了。结果就是白背景闪一下再变成深色。这个体验非常糟糕用户会以为网站坏了。我的做法是在head里放一段内联脚本在body渲染之前就设置好classscript (function () { var saved localStorage.getItem(site-theme); var theme saved || (window.matchMedia((prefers-color-scheme: dark)).matches ? dark : light); document.documentElement.classList.add(theme); })(); /script这段代码不引用外部变量执行得极快能在浏览器第一次绘制前就把根节点的class挂上从而避免“浅色闪一下再变深色”的问题。等主JS加载后再走完整的initTheme逻辑做状态同步。4. 实操过程中的细节与坑图片、阴影、滚动条、第三方库4.1 图片怎么处理才不刺眼我踩过最深的坑就是图片和深色背景的搭配。白底图片放在深色背景上就像黑夜里的白纸特别突兀。处理方案没有唯一标准按场景分图标类素材通常是有透明底的PNG或SVG这种情况优先用filter处理。SVG图标可以设置currentColor跟随文字颜色位图图标可以加一层透明度让它融入背景。内容配图/照片不要做暴力反色。照片是有语义的反色之后会变得诡异。比较温和的做法是调低默认亮度或者加一层半透明遮罩。社区里流行一个“智能图片暗化”思路用CSS滤镜把图片调暗但不改变色相.dark img { filter: brightness(0.8) contrast(1.1); }温和但不适用于所有图片。有些图片本来就是深色系的再暗化就看不清了。更稳妥的方案是在HTML层面准备两套图或用picture标签配合media属性picture source srcsetimage-dark.jpg media(prefers-color-scheme: dark) img srcimage-light.jpg alt描述 /picture这套方案语义清晰、可维护性好缺点是需要后端配合准备两套资源适合图片不多但品牌要求高的场景。4.2 阴影在深色模式下会失效Box-shadow在浅色模式下是“物体悬浮在纸面上”的感觉——阴影越深物体越显得高。但深色模式下背景本身已经接近黑色黑色阴影几乎看不见。如果你原样把浅色模式的阴影带到深色模式卡片会失去层次感看起来像一张张平贴的色块。我的做法是深色模式下减小阴影面积改用“高光”来区分层级。也就是给卡片加一个非常淡的亮色边框或者用一个向上偏移的微弱高光模拟“物体被从上方照亮”的感觉html.dark .card { box-shadow: 0 1px 3px rgba(255, 255, 255, 0.05); border: 1px solid rgba(255, 255, 255, 0.06); }当然这也要看设计风格。如果是新拟态设计的项目深色模式玩法会更复杂。记住一条原则“层次感靠什么表达取决于背景色”。浅色下靠投影深色下靠描边和高光。4.3 滚动条和原生控件的统一前面提到color-scheme: dark能让原生控件自动变深色但这只是浏览器默认风格。如果你想自定义滚动条就需要自己匹配深色主题。以WebKit内核为例::-webkit-scrollbar { width: 8px; height: 8px; } ::-webkit-scrollbar-thumb { background: var(--scrollbar-thumb); border-radius: 4px; } ::-webkit-scrollbar-track { background: var(--scrollbar-track); }变量仍然使用全局主题变量这样滚动条的颜色会自动跟随主题切换。注意不要只写html.dark ::-webkit-scrollbar-thumb因为如果用户走的是纯媒体查询方案没有class滚动条就漏了。始终围绕变量来写。4.4 第三方UI库的主题覆盖我用过Element Plus这类组件库它的主题切换机制和原生CSS变量的思路基本一致。Element Plus支持通过CSS变量覆盖主题色也支持在html.dark下自动切换暗色变量。但它有个坑覆盖变量时你要同时覆盖它那个庞大的变量清单不是一两个变量就能搞定的。我建议的做法是按官方文档引入dark/css-vars.css。自己定义一套业务变量映射到组件库变量上。局部样式优先使用var(--el-bg-color)这类组件库导出的变量而不是硬编码十六进制色值。这样组件库升级的时候你的覆盖代码也能尽量稳定。如果你用的是React生态的组件库比如Ant Design或MUI基本也是同样的套路只是变量命名不同。5. 常见问题与排查技巧实录5.1 查“为什么这个元素还是白的”的思路我排查这类问题的顺序基本固定第一步打开DevTools看这个元素的计算样式Computed。第二步看CSS变量有没有生效如果变量的值是var(--xxx)且没被解析出来多半是变量没定义到或者定义在了错误的节点上。第三步看是不是被某个更高优先级的规则覆盖了。媒体查询本身不增加优先级但很多人会把html.dark的写法写成html.dark .card这优先级高于.card会覆盖我们的预期。第四步看是否有内联样式。JS动态设置样式时内联样式的优先级是最高的经常是它把主题变量压住了。5.2 表单控件在深色模式下仍旧白底这个几乎每个项目都遇到过。原因就俩要么没写color-scheme要么color-scheme写的位置不对。color-scheme要写在:root上不是写在body上甚至要写在html上。另外如果用了三方组件库的Select、DatePicker之类组件内部可能没有继承这个属性需要在组件根节点单独补.dark .el-select__wrapper { color-scheme: dark; }5.3 系统切主题时页面没反应可能性有两个方向。第一你监听了matchMedia但用户之前手动选择过主题localStorage里有值所以页面强制用用户选择的结果系统变动被忽略了——这是“预期行为”。第二你没加事件监听或者监听时机太晚。注意addEventListener(change...)在某些旧浏览器上不支持需要退回到addListener。如果你为了兼容旧浏览器可以这样写const mql window.matchMedia((prefers-color-scheme: dark)); const handler (e) { /* 处理逻辑 */ }; if (mql.addEventListener) { mql.addEventListener(change, handler); } else { mql.addListener(handler); }5.4 排查表格速查症状可能原因解决方案切换时整页白闪主题脚本在首帧后才执行在head内联设置根节点class某些卡片还是浅色阴影在深色下不可见视觉上像浅的深色下改用边框/高光区分层级表单控件是白底未声明color-scheme在:root上加color-scheme: light dark图标颜色不跟随图标硬编码了颜色用currentColor或CSS变量控制背景色正确但文字看不清对比度不够用工具检测对比度调整变量色值三方库组件颜色不协调组件库变量未覆盖映射组件库主题变量到全局变量切主题后某些class残留JS只添加不删除class操作改为“先清除再添加”系统切主题页面无响应未监听matchMedia的change事件补监听同时处理无手动偏好情况6. 关于主题系统设计的一些个人建议做深色模式做到后面你会意识到它本质上不是“加几行媒体查询”而是一个设计系统问题。颜色、阴影、边框、状态、动效全部要纳入变量体系里才会越做越顺。在项目初期就建议做的几件事把颜色命名从“白色”“黑色”这类物理名词改成“背景-层级”“文字-主要/次要”这类语义名词。这样主题切换时不会出现“黑色文字在深色背景上”的尴尬你的代码会感谢你。在prefers-color-scheme之外尽量保留手动覆盖的逃生舱。系统偏好不该是唯一入口尤其产品面向的是对视觉敏感的用户群。给主题切换动效加一个0.2~0.3秒的过渡但不要全局加transition: all会引发性能问题。我一般只针对background-color和color加过渡并且在新载入首帧时临时关闭过渡避免闪烁。最后再分享一个小技巧切主题时如果发现部分元素颜色迟迟不更新检查一下是不是transition的延迟或will-change属性在作祟。这两个属性在某些浏览器里会把元素渲染提升到独立图层导致变量更新不及时。遇到这种玄学问题先删掉will-change再看效果多半就好了。