做深色模式适配这几年已经从前端需求里的“加分项”变成了“标配项”。产品经理很少问我要不要做而是直接说“别人家有深色模式我们也得有”。说实话单纯给页面写一套深色样式并不难难的是把这套能力沉淀成真正的“多主题”体系正常模式、深色模式可能还有护眼模式、高对比度模式切换还要顺滑、不闪烁、不遗漏。我今天想聊的不是某一个小技巧而是围绕“多主题下正常模式和深色模式适配”这条主线从颜色令牌怎么设计、切换策略怎么选、状态怎么存、组件怎么兜底一直讲到平时最容易踩的坑。你按这个思路去做后面扩展第三套、第四套主题时基本不用返工。1. 先搭好颜色体系语义化颜色令牌才是底层地基很多项目做深色模式翻车不是切换逻辑写错了而是颜色本身就没法被“切换”。我见过最典型的场景业务代码里先搜了一下#fff和#333打算全局替换成深色值一替换发现图表、弹窗、表格背景全乱了按钮像是被“补丁”补出来的。原因很简单颜色散落在每一个组件里没有一个统一出口。1.1 为什么直接写死色值会翻车先想一个生活化的问题如果家里所有插座都需要换新国标你是打算把每个插孔都改造一遍还是先把总电箱的统一接线方式改了主题适配也是一样。颜色一旦写死在业务组件里主题切换就只能靠“再次覆盖”覆盖逻辑会越堆越多最后变成一坨谁都不敢动的历史代码。更隐蔽的问题是“同一个视觉含义用了不同的色值”。比如浅色模式下页面背景用的是#ffffff卡片背景用的是#fafafa输入框背景又用#f8f8f8。肉眼看起来都是“浅灰白”但到了深色模式你想要它们分别对应#121212、#1e1e1e、#252525这种有层级感的深色就没办法用一个规则批量完成因为它们的含义在浅色模式下没有对齐。项目刚起步时还看不出来等到组件多了、第三方插件进来了维护成本会指数级上升。我自己带过的一个项目就是这样第一版没有做语义化第二版做深色模式时整整花了一个迭代去清理颜色。所以不管你是谁、代码规模多大做主题适配的第一步永远是“先让颜色变得可以被切换”。1.2 用 CSS 变量定义语义化令牌推荐的方案是用 CSS 自定义属性通常叫 CSS 变量来做一层“语义化颜色令牌token”。所谓语义化就是变量名不叫--blue-500而是叫--color-primary或--bg-surface变量名描述的是“这个颜色在界面里承担什么职责”而不是“它具体长什么样”。先看一组基础定义:root { --bg-primary: #ffffff; --bg-secondary: #f5f5f5; --bg-card: #ffffff; --text-primary: #1a1a1a; --text-secondary: #666666; --text-disabled: #999999; --border-color: #d9d9d9; --accent-color: #3b82f6; --accent-text: #ffffff; --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.05); --shadow-md: 0 4px 12px rgba(0, 0, 0, 0.08); }然后深色模式只需要重新赋值[data-themedark] { --bg-primary: #121212; --bg-secondary: #1e1e1e; --bg-card: #1c1c1e; --text-primary: #ececec; --text-secondary: #a1a1aa; --text-disabled: #555555; --border-color: #3a3a3a; --accent-color: #60a5fa; --accent-text: #0f172a; --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.4); --shadow-md: 0 4px 12px rgba(0, 0, 0, 0.5); }业务组件里只许使用语义变量不允许再写裸色值.card { background-color: var(--bg-card); color: var(--text-primary); border: 1px solid var(--border-color); box-shadow: var(--shadow-sm); }这样做的好处是主题切换时只有一个“源头”。你想调整深色模式的主色改[data-themedark]下的变量就够了业务代码一行都不用动。而且这种方案天然支持动态切换因为 CSS 变量是运行时求值的>:root { --bg-primary: #ffffff; } media (prefers-color-scheme: dark) { :root { --bg-primary: #121212; } }只要用户的系统打开了深色模式页面就会自动切到深色。这个方案最大的优点是零成本联动用户不需要到网站里手动设置系统切网站跟着切特别适合后台管理系统、资讯类产品这类“用户偏好跟随系统就行”的场景。但它的缺点也很明显用户没有自主选择权。举个例子一个用户系统是深色模式但他打开你的网站就是想看浅色内容他根本找不到关掉的开关。有些活动页运营希望所有人看到的都是浅色节庆氛围结果用户系统是深色页面直接变黑底体验就很糟糕。所以纯跟系统只适合“不需要精细化运营”的简单场景。如果你的产品有设置中心或者有品牌统一视觉的需求一定要给用户手动选择的能力。2.2 手动切换data-theme 方案手动切换的核心逻辑是在根元素html上设置一个自定义属性比如>:root, html[data-themelight] { --bg-primary: #ffffff; } html[data-themedark] { --bg-primary: #121212; }切换时用 JS 改变属性值document.documentElement.setAttribute(data-theme, dark);有一个细节我要特别强调主题标记一定要放在html上而不是放在body或某个容器上。原因有两个。第一CSS 变量是有继承性的根节点上定义全站可访问放到body上虽然也能全局继承但某些浏览器滚动条、::selection、原生弹层的行为不一定走body这条链路容易出现漏网之鱼。第二把>function getInitialTheme() { const saved localStorage.getItem(app-theme); if (saved light || saved dark) { return saved; } const prefersDark window.matchMedia((prefers-color-scheme: dark)).matches; return prefersDark ? dark : light; } function applyTheme(theme) { document.documentElement.setAttribute(data-theme, theme); } const theme getInitialTheme(); applyTheme(theme);当用户手动点切换时我们把选择存起来而不是直接用当前值覆盖系统偏好function switchTheme(mode) { // mode: light | dark | system localStorage.setItem(app-theme, mode); if (mode system) { const prefersDark window.matchMedia((prefers-color-scheme: dark)).matches; applyTheme(prefersDark ? dark : light); } else { applyTheme(mode); } }这个方案的优先级很清晰手动选择 系统偏好 默认浅色。你既不会违背用户的明确意图又能让大部分懒得配置的用户自动获得系统级的一致性。我建议产品需求书里直接按这个逻辑来写免得开发做到一半又要求“系统变深色时已经手动选过浅色的用户也要跟着变”那种需求会让状态管理变得非常混乱。3. 从双主题走向多主题设计一套可扩展的主题注册表聊到这里我们一直在说“正常模式和深色模式”但实际产品中主题往往不止两个。护眼模式、高对比度模式、品牌节日皮肤、夜间模式变体……都可能出现。所以设计时最好别把逻辑写死在“if 是 dark else light”里而是抽象成一个可扩展的注册表。3.1 主题文件组织方式我习惯把主题定义抽成独立文件而不是全部堆在一个theme.css里。常见的目录结构可以是styles/ tokens/ color-light.css color-dark.css color-sepia.css color-contrast.css base.css components/每个主题文件只负责改变量html[data-themesepia] { --bg-primary: #f5ead1; --bg-secondary: #e8d9b8; --bg-card: #faf3e3; --text-primary: #5b4636; --text-secondary: #8b7a66; --border-color: #cbb99d; --accent-color: #b08968; }切换逻辑完全通用。比如“设置页有四个主题卡片”用户点了哪个就往html上写哪个>html[data-themehigh-contrast] { --bg-primary: #000000; --bg-card: #000000; --text-primary: #ffffff; --border-color: #ffffff; --focus-ring-width: 3px; }“颜色之外的 token”并不少见。多主题不仅是调色盘它还可以覆盖间距、圆角、字幕字号等。如果你在基础 token 里定义了 “focus ring 宽度” 这类设计变量高对比度主题就可以轻松强化焦点提示而不需要重新写一遍所有组件的 focus 样式。3.3 超出颜色的适配阴影、圆角、字体、间距也能主题化吗能但要分层次。颜色是最基础的一层再往上还有圆角、阴影、字体、间距。主题化这些属性时我强烈建议把 token 分成三层基础 token如--radius-sm、--space-4、--font-size-md描述设计系统的基本粒度。语义 token如--card-radius、--card-shadow、--control-height描述组件角色。组件私有 token如--button-primary-bg、--input-border-focus描述特定组件的细节。多主题适配时我们改的是语义 token 和组件私有 token而不是直接改业务代码。举个例子深色模式下阴影本来就该更重因为深色背景下阴影如果没有明显边界层次感会丢失html[data-themedark] { --card-shadow: 0 8px 24px rgba(0, 0, 0, 0.6); } html[data-themelight] { --card-shadow: 0 2px 8px rgba(0, 0, 0, 0.06); }字体也是一样有些阅读类产品在“纸张模式”下会把字重调低、行高调大。如果一开始就把行高语义化成一个 token做起来就非常简单。这个点很多人会忽略等做到第三套主题才发现改不动因为间距行距全写死在组件里了。4. JS 侧的状态管理与持久化主题切换不只有 CSS更关键的是 JS 侧什么时候应用主题、怎么保存状态、怎么响应系统变化。这一部分最容易出问题尤其是页面闪烁。4.1 localStorage 存储用户偏好用户的选择需要持久化最常见的做法是存localStorage。key 的命名我建议带项目前缀比如app-theme避免同一域名下多个应用互相干扰。需要注意的是localStorage只能在浏览器环境里读取服务端渲染SSR场景下要加判断否则会直接报错。即使是纯客户端渲染读取时机也很关键不要在应用挂载完成后再去读否则页面已经以默认主题渲染了一帧用户会看到明显的闪烁。4.2 防止页面闪烁内联脚本的细节深色模式适配里“页面闪烁”是最杀体验感的问题。浅色页面闪过一秒钟再变成深色在暗光环境下特别刺眼。解决思路是让主题生效时机尽可能提前提前到什么程度要在浏览器完成首次绘制之前也就是脚本要内联在head里并且放在任何 CSS 或业务脚本之前。具体写法如下!DOCTYPE html html head script (function () { var saved localStorage.getItem(app-theme); var theme saved light || saved dark ? saved : window.matchMedia((prefers-color-scheme: dark)).matches ? dark : light; document.documentElement.setAttribute(data-theme, theme); })(); /script link relstylesheet hrefstyles/theme.css / /head这段代码在 CSS 加载前执行html上的>const mql window.matchMedia((prefers-color-scheme: dark)); function handleSystemThemeChange(e) { const saved localStorage.getItem(app-theme); if (saved system || saved null) { applyTheme(e.matches ? dark : light); } } mql.addEventListener(change, handleSystemThemeChange);这里有一个逻辑细节只有用户没有手动指定主题时才对系统变化做出响应。如果用户手动选了深色不管系统切到什么页面都不能变。跨标签页同步则要监听storage事件。当用户在一个标签页里切换主题其他标签页应该立刻跟着变window.addEventListener(storage, function (e) { if (e.key app-theme) { applyTheme(getInitialTheme()); } });storage事件只在其他标签页触发当前标签页不必额外处理因为当前标签页的用户操作已经主动调用了applyTheme。4.4 与框架Vue / React配合的实践如果你用的是 Vue 或 React 这类框架有一点要特别注意不要在“组件挂载完成后再设置主题”而是要在框架渲染前就已经把主题打在html上。上面的内联脚本方案依然要用框架代码只负责“用户切换操作”和“状态同步”。在框架内部建议把主题状态收敛成一个全局 store 或者一个 composable/hook业务组件只调用useTheme()内部返回当前主题名和切换函数。不要在几十个页面里散落setAttribute的调用否则后面的主题数量一多维护就是场灾难。我自己比较习惯的做法是内联脚本负责“初始渲染正确”框架层负责“交互切换 持久化 同步”。两者各司其职互不干扰。框架内部的代码不需要再读一次system因为那套逻辑已经在内联脚本里处理过了框架只需在启动时同步一份内存状态。5. 组件适配的实战细节主题框架搭好之后真正的战场在组件层。最常见的现象是全局主题切换成功但有一堆第三方组件、图标、图片、表单控件仍然“顽固不化”。5.1 第三方组件库的暗坑接入第三方组件库时它自带的样式往往只有浅色主题或者虽然提供了暗色变量但触发机制和你的>html[data-themedark] .third-party-select { --select-bg: var(--bg-card); --select-active: var(--accent-color); }如果组件库完全不支持变量你只能在主题文件里用“更高优先级的选择器”覆盖。注意这里的覆盖要放在主题变量层不要散落到业务组件里。还有一个容易踩的坑组件库自带的动画、过渡、层级类名在深色模式下会继承默认的浅色变量覆盖时要把选择器写完整避免因为样式优先级不够导致改了没用。5.2 图片、图标和背景资源如何处理图标是最好处理的前提是使用 SVG 并让颜色继承currentColorsvg classicon path fillcurrentColor d... / /svgcurrentColor会跟随 CSS 的color属性而color又来自我们的语义变量所以图标的配色会自动跟随主题。如果是 PNG 图片、插画、截图这类位图深色模式下直接反转或者叠加遮罩通常效果不好。比较稳妥的做法是设计师出两套资源然后用>img src/img/banner-light.png>.banner { background-image: url(/img/banner-light.png); } html[data-themedark] .banner { background-image: url(/img/banner-dark.png); }对于实在没法出两套资源的位图退而求其次可以加一层不会太影响观感的滤镜html[data-themedark] .banner-img { filter: brightness(0.8) contrast(1.1); }但这只能当临时方案因为滤镜会改变图片原本的质感容易让产品看起来“灰蒙蒙的”。5.3 兼容性处理与渐进增强CSS 变量在现代浏览器里已经全面支持但如果你的项目还在维护比较老的运行环境比如企业内部老旧内核浏览器就需要做一层兜底。最简单的写法是先给一个原始色值再给 CSS 变量值覆盖.card { background-color: #ffffff; background-color: var(--bg-card); }这样即使浏览器不认识var()也还能显示默认的浅色样式。深色模式在这种环境下自然不可用但页面至少不会白屏。另一个渐进增强的思路是用supports检查浏览器是否支持 CSS 变量不支持时干脆隐藏“颜色模式切换”按钮只展示默认主题避免用户点了没有任何反馈。6. 高频问题与排查记录主题适配做完之后真正耗时间的往往是排查各种“漏网之鱼”。我整理几个高频问题和对应的排查思路可以当速查表用。6.1 为什么深色模式总有一处“漏网之鱼”漏网之鱼大多来自这几个地方硬编码在 JS 里的颜色比如element.style.background #fff。第三方地图、图表、富文本编辑器它们有独立的渲染层和主题系统。浏览器默认样式比如input的background-color、select的下拉箭头。带内联样式的后端富文本内容特别是从编辑器粘贴进来的 HTML。排查时我推荐先用浏览器开发者工具强制切到深色然后逐个模块走一遍。重点看弹窗、抽屉、Toast 这类“临时挂载”的元素它们很可能挂在body下但主题标记在html上某些浮层因为父级上下文不同没有继承到预期变量。如果每次改动都怕漏可以给所有根级容器加一个“主题切换后的基础背景校验”html[data-themedark] body { background-color: var(--bg-primary); color: var(--text-primary); }这能兜住大部分底色跑偏的情况。6.2 浏览器自动填充、滚动条和原生控件深色模式下浏览器表单自动填充的背景色往往还是浅黄色或白色看着特别突兀。处理自动填充的办法比较固定input:-webkit-autofill, input:-webkit-autofill:hover, input:-webkit-autofill:focus { -webkit-box-shadow: 0 0 0 1000px var(--bg-card) inset; -webkit-text-fill-color: var(--text-primary); transition: background-color 9999s ease-in-out 0s; }这里用了一个很取巧的方式用一个巨大的内阴影覆盖自动填充的背景再让颜色过渡时间非常长避免用户聚焦时样式抖动。至于滚动条深色模式下也应该跟着主题走统一给scrollbar-color赋值html[data-themedark] { scrollbar-color: #555 #1e1e1e; }原生select、日期选择器、文件上传控件在不同系统下样式差异巨大深色模式下尤其明显。如果产品对视觉一致性要求高这几类控件最好全部换成自绘组件不要指望浏览器原生适配。6.3 无头浏览器测试多主题的自动化方法人工走查总会有疏漏建议把主题适配纳入自动化测试。用无头浏览器可以很方便地模拟深色模式await page.emulateMedia({ colorScheme: dark }); await page.setViewportSize({ width: 1440, height: 900 }); await page.screenshot({ path: dark-homepage.png });如果你的主题是通过>await page.evaluate(() { document.documentElement.setAttribute(data-theme, dark); });自动化测试里的核心不是“截个图”而是做“颜色断言”。可以写一个简单的巡检脚本遍历所有页面检查是否出现了不该出现的浅色底色值比如rgb(255, 255, 255)。这种粗糙的断言反而能快速定位漏网之鱼。等到主题越来越多再引入视觉回归对比工具把每个主题下的页面快照当作基准存起来每次改动后自动对比。6.4 给未来的扩展留好口子多主题轮播主题适配做到这一步最后一件事是“别把路堵死”。比如将来要加“品牌日主题”“节日主题”如果业务代码里到处是if (theme dark)的判断就又要重构了。正确的姿势是业务代码永远不认识主题名它只认识 token你要新增主题只需要新增一份 token 文件并在切换配置里注册这个主题名。我甚至会在项目里放一个“当前激活主题”的元信息变量html[data-themefestival] { --theme-name: festival; --bg-primary: #fff8f0; }虽然这个变量业务代码很少用但它能支撑一些特殊需求比如给不同主题显示不同的 slogan、底部 logo 等。如果你从一开始就把主题当“注册表”而不是“开关”多主题就是多配置而不是多维护一份代码。最后我再分享一点个人体会。每次做完主题适配我都会找一个全白的页面把深色模式打开然后从顶部往下慢慢滚一遍重点看背景层级、卡片边界、文字对比度。其实最常见的问题往往不是 CSS 写不出来而是组件层的习惯没有改。不要急着写深色样式先花一两个版本把颜色变量清理干净。回头你会发现多主题适配其实就是一个改配置的过程真正耗时间的是把历史债务还清的那部分。