前端主题切换怎么做:从深色模式到多主题

同一个页面,浏览器把它当成两种状态:一种是 prefers-color-scheme 告诉你的系统偏好,另一种是用户在页面里手动点的开关。这两个状态谁说了算、什么时候该听谁的,是主题切换里最容易埋雷的地方——我们团队的后台系统最近半年陆续加了深色模式、品牌色切换,这个优先级问题几乎每次都要重新想一遍。

表面上看是视觉问题,往深了做会牵扯到 CSS 组织方式、组件库怎么接、SSR 首屏怎么不闪。做得好,后面加深色模式、多品牌、节日皮肤都比较自然;做不好,就会变成到处覆盖样式、到处写特殊判断。

先想清楚要管理的是变量,不是颜色

主题切换不是简单地把背景色改成黑色,真正要管理的是一组设计变量:主色、背景色、文本色、边框色、阴影、禁用态颜色、成功/警告/错误等状态色。这些颜色如果散落在各个 CSS 文件里,后面改主题会很痛苦。

我是吃过亏才认真做这件事的。早期接手一个后台项目,色值全是硬编码,光是 #1677ff 这一个蓝色在工程里就出现了三十多处,还混着 #1677FFrgb(22,119,255) 几种写法。后来要把主色从蓝改成品牌绿,编辑器全局替换不敢一把梭,怕误伤注释和图片占位色,最后是一处一处肉眼确认改的,改完还漏了两个 hover 态,上线被设计师抓出来。那次之后定了规矩:业务代码里不允许出现裸色值,CI 里加了一条正则检查,提交里带 # 开头六位十六进制颜色的 CSS 就报警,逼着大家走变量。规则看着粗暴,但确实把"随手写个颜色"的坏习惯掐掉了。

不要让组件直接依赖具体颜色,而是依赖语义变量:

1.button {
2  background: #1677ff;
3}

改成:

1.button {
2  background: var(--color-primary);
3}

这样组件关心的是"主色"这个角色,而不是某个固定色值。

token 分三层,色板和语义分开

token 我现在粗略分三层:基础色阶(blue-500gray-100 这类颜色原料)、语义 token(color-text-primarycolor-bg-panel 这类业务语义)、组件 token(button-bgtable-border 这类组件细节)。基础色阶适合设计侧管理,业务组件用语义 token。否则深色模式下继续写 gray-100 会很别扭,因为这个名字描述的是颜色本身,不描述它承担的角色。

具体做法是底下一层放"原始色板",纯描述颜色本身;上面一层是语义 token,引用色板:

1:root {
2  --gray-50: #f8fafc;
3  --gray-900: #0f172a;
4  --blue-500: #1677ff;
5
6  /* 语义层引用色板 */
7  --color-bg-page: var(--gray-50);
8  --color-text-primary: var(--gray-900);
9  --color-primary: var(--blue-500);
10}
11
12.theme-dark {
13  /* 深色模式只重新映射语义层,色板基本不动 */
14  --color-bg-page: var(--gray-900);
15  --color-text-primary: var(--gray-50);
16}

切主题时改的是"语义指向哪个色阶",而不是改色值本身。色板像调色盘,语义层像贴在上面的标签,组件永远只碰语义层。这套分层一开始会觉得啰嗦,但当主题数量从 2 套涨到 4 套时,好处会很明显——不用再为每套主题重抄一遍所有色值,只维护那张映射表就行。

最近整理这套变量时,顺手把优先级混乱的几处覆盖样式挪进了 @layer。Chrome 99(3 月)、Safari 15.4 之后 @layer 已经落地大半年了,用来管理"主题 token 覆盖层"和"业务组件层"的优先级关系正合适:

1@layer tokens, components, overrides;
2
3@layer tokens {
4  :root {
5    --color-primary: #1677ff;
6  }
7  .theme-dark {
8    --color-primary: #4096ff;
9  }
10}
11
12@layer components {
13  .button {
14    background: var(--color-primary);
15  }
16}

好处是不用再靠选择器权重或者 !important 去压主题覆盖样式,@layer 声明的顺序天然决定了优先级,tokens 层的变量定义永远不会因为选择器写得随意而被业务组件层意外压过去。

用 class 控制当前主题,注意三态

实际项目里把主题 class 放在 document.documentElement 上:

1function setTheme(theme) {
2  document.documentElement.classList.remove("theme-light", "theme-dark");
3  document.documentElement.classList.add(`theme-${theme}`);
4  localStorage.setItem("theme", theme);
5}

页面初始化时读取用户选择,没有则跟随系统:

1function getInitialTheme() {
2  const savedTheme = localStorage.getItem("theme");
3  if (savedTheme) return savedTheme;
4
5  const prefersDark = window.matchMedia("(prefers-color-scheme: dark)").matches;
6  return prefersDark ? "dark" : "light";
7}

这里有个细节最早没处理好:用户选的是"跟随系统"这个选项,还是明确选了"深色",这两件事不一样。最早只存 light/dark,结果用户白天选了浅色,晚上系统自动切深色,他期待页面也跟着变,但因为 localStorage 里压着一个 light,页面就一直是浅的,还以为是坏了。后来把存储值改成三态:lightdarksystem。选了 system 才实时监听 matchMedia

1const media = window.matchMedia("(prefers-color-scheme: dark)");
2
3media.addEventListener("change", (e) => {
4  if (localStorage.getItem("theme") !== "system") return;
5  applyTheme(e.matches ? "dark" : "light");
6});

顺带提一句,matchMedia(...).addListener 这个老 API 已经废弃了,要用 addEventListener("change", ...),旧写法在新版 Safari 上会有兼容告警。

如果用户已经手动选过主题,就不要再被系统变化覆盖,这个优先级要一开始想清楚,不然会出现"我明明选了浅色,系统一变又给我切回深色"的问题。

避免首屏闪烁

主题切换里常见问题是页面先显示浅色,随后 JavaScript 运行后再切到深色,用户会看到明显闪烁。

解决思路是尽早在 HTML 加载阶段设置主题 class,在页面头部放一小段内联脚本:

1<script>
2  var theme = localStorage.getItem('theme') ||
3    (window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light');
4  document.documentElement.classList.add('theme-' + theme);
5</script>

这段脚本要尽量小,执行越早越好,目的不是承载业务逻辑,而是在 CSS 加载前给页面一个正确主题状态。注意一定要内联,不能用 <script src> 外链,外链脚本要等额外的网络请求回来才执行,闪烁照样发生。

我在一个用 SSR 的内部工具项目里为这个折腾过一阵——服务端渲染出来的 HTML 根本不知道用户的 localStorage,所以默认渲染浅色,等客户端脚本跑完才切深色,深色用户每次刷新都会"闪白一下"。最后的办法是在 <head> 里塞一段内联脚本,赶在页面主体渲染之前先把 class 打到 documentElement 上,脚本本身不依赖任何框架状态,纯读 localStoragematchMedia。这段脚本会在每个页面 HTML 里重复出现,所以越短越好,别写花活。

另外可以给浏览器一个原生控件的主题提示:

1:root {
2  color-scheme: light;
3}
4
5.theme-dark {
6  color-scheme: dark;
7}

color-scheme 会影响滚动条、表单控件这些浏览器原生 UI 的默认配色,不能替代业务主题,但能减少"页面是深色,输入框还是亮白"的违和感。如果用 prefers-color-scheme 决定初值,最好把 <meta name="color-scheme" content="light dark"> 也加上,效果类似,双保险。

跨标签页同步

如果用户打开了多个标签页,一个标签页切换主题,其他标签页最好也同步。可以监听 storage 事件:

1window.addEventListener("storage", function (event) {
2  if (event.key !== "theme") return;
3  setTheme(event.newValue || "light");
4});

注意 storage 事件通常不会在当前写入的标签页触发,当前页切换主题时仍然要立即更新 DOM。

组件库主题要单独处理

如果项目用 Element Plus 这类组件库,主题切换不能只改业务 CSS,还要看组件库支持哪种方式:是否支持 CSS 变量、是否提供暗黑主题、是否支持运行时切换 token、是否需要单独引主题样式文件。

最理想的情况是业务样式和组件库主题都由同一套变量或 token 驱动。以 Element Plus 为例,它本身有一套 CSS 变量,如果业务样式用自己的变量、组件库用另一套,就要建立映射关系:

1:root {
2  --color-primary: #1677ff;
3  --el-color-primary: var(--color-primary);
4}

实际接 Element Plus 深色模式时还有几个坑。它的暗色样式要单独引 element-plus/theme-chalk/dark/css-vars.css,并且要求深色 class 必须加在 html 上、名字固定是 dark,不是随便起的 theme-dark。一开始我用自己的 theme-dark,结果业务样式都变黑了,组件库还是亮的,对着控制台找了半天才发现是 class 名对不上。后来干脆让两个 class 一起挂:

1function setTheme(theme) {
2  var el = document.documentElement;
3  el.classList.toggle("dark", theme === "dark");        // 给组件库用
4  el.classList.toggle("theme-dark", theme === "dark");  // 给业务样式用
5}

另外要小心 --el-color-primary 这种映射只覆盖了主色,组件库还会基于主色派生出一堆 --el-color-primary-light-3light-5light-7 之类的浅色变体,用于 hover、disabled 等态。只覆盖主色没覆盖这些派生色,换品牌色后会发现按钮 hover 还是原来那个蓝。这些派生色要么手动算一遍补上,要么用组件库提供的 SCSS 变量在构建期生成,运行时硬靠 CSS 变量覆盖全部派生色比较费劲。

不建议写两套完全独立样式

有些项目会写 light.cssdark.css 两套完整文件,短期看很直接,长期容易出问题:两套样式内容重复、新组件容易漏写暗黑样式、修改一个细节要改两处、主题之间差异越来越难控制。如果只是颜色不同,CSS 变量通常更容易维护。

当然也不是所有差异都适合用变量解决。如果主题之间布局结构、插图资源、动效规则都完全不同,那可能已经不是"主题切换",而是不同页面皮肤或不同产品形态。这时候硬塞进一套变量里,反而会让系统复杂。

图片、阴影和图表也要考虑

深色模式里最容易漏的是非 CSS 颜色:图片和 logo 是否适合深色背景、图表颜色是否仍然可读、阴影在深色背景下是否看得见、代码高亮主题是否同步切换。

浅色模式下常用的阴影,在深色模式里可能几乎不可见,可以把阴影也做成变量:

1:root {
2  --shadow-panel: 0 8px 24px rgb(15 23 42 / 12%);
3}
4
5.theme-dark {
6  --shadow-panel: 0 8px 24px rgb(0 0 0 / 40%);
7}

图表这块趟过一个坑。CSS 变量是给 DOM 用的,ECharts 这类图表颜色是写在 JS 配置里的,靠 getComputedStyle 取不了那么顺手,更麻烦的是切主题时图表不会自己重画。我的做法是切主题后主动读出变量再重设配置:

1function getVar(name) {
2  return getComputedStyle(document.documentElement)
3    .getPropertyValue(name)
4    .trim();
5}
6
7function applyChartTheme(chart) {
8  chart.setOption({
9    backgroundColor: getVar("--color-bg-panel"),
10    textStyle: { color: getVar("--color-text-primary") },
11  });
12}

然后在主题切换的回调里把页面上所有图表实例遍历一遍调用它。要点是"切主题"这个动作得有个统一的事件出口,让图表、代码高亮、第三方组件都能挂上去重渲染,而不是各自去监听 class 变化。不要只换背景色,坐标轴、网格线、tooltip、legend、数据色板都要一起换,数据可视化页面深色模式下图表对比度不够会直接影响读数,不是单纯"不好看"。

图片一般用两招:纯色 logo 直接做成 SVG 用 currentColor 或变量上色,跟着文字色走;带阴影、有底色的位图就准备两份,用 <picture> 配合媒体查询切换:

1<picture>
2  <source srcset="/logo-dark.png" media="(prefers-color-scheme: dark)">
3  <img src="/logo-light.png" alt="logo">
4</picture>

不过 <picture> 只认系统偏好,跟前面说的"用户手动选主题"那套对不上,手动切换为主的项目还是得在 JS 里换 src

:has() 处理暗色模式下的细节判断

Safari 15.4 三月份起就支持了 :has(),Chrome 105 在 9 月也跟了上来,现在两边基本都能用了,之前只敢在自己内部小工具里试的写法,现在可以放心搬进业务项目。它比较适合处理一些以前只能靠 JS 才能判断的暗色模式细节。

比如深色模式下,如果一个卡片里没有配图,留白会显得特别空,之前得靠 JS 判断子元素再加 class,现在可以直接用选择器表达:

1.theme-dark .card:has(> .card-cover) {
2  padding-top: 0;
3}
4
5.theme-dark .card:not(:has(> .card-cover)) {
6  background: var(--color-bg-panel-empty);
7}

再比如输入框在深色模式下配合校验状态展示边框色,以前要么在 JS 里手动切类,要么用兄弟选择器绕一圈,现在可以直接用 :has() 判断表单项内部状态:

1.theme-dark .form-item:has(input:invalid) {
2  border-color: var(--color-danger);
3}

不过 Chrome 105 是 9 月刚发的版本,公司内部还有一批用户停留在稍旧的自动更新节奏上,我暂时只敢把 :has() 用在一些非关键的视觉细节上,核心交互逻辑还是保留 JS 兜底,等这个版本铺开得再久一点再放开手用。

容器查询:还太新,先记下来

九月份 Chrome 105/106 把容器查询也一起带出来了,这个东西目前还太新,我还没敢真的用在主题相关的样式里,但顺手记一下,因为它和主题切换有点关系:以前深色模式下卡片内部要不要收窄内边距、要不要换成单栏,只能靠视口宽度的媒体查询判断,容器查询理论上可以让组件按自己所在容器的宽度响应,而不是死盯着整个视口。等生态和浏览器覆盖率再成熟一些,团队计划评估要不要拿它替代一部分和主题联动的响应式判断,现在先观望。

可访问性和对比度

主题切换不是只看好不好看,还要看文字对比度是否足够。深色模式里常见问题是正文灰度太低,长时间阅读很吃力;浅色模式里则可能是浅灰文字过淡。设计 token 时要把主文本、次文本、禁用文本分清楚。

不要只通过颜色表达状态,错误、成功、警告最好同时有文字、图标或明确上下文,避免用户只能靠颜色判断。

判断对比度别只靠肉眼,用浏览器 DevTools 的对比度检查或在线工具量一下,正文至少要到 WCAG AA 的 4.5:1。深色模式下踩过的典型反例是"纯黑底配纯白字",对比度拉满反而刺眼、发虚,主流做法是底色用接近 #121212 的深灰、正文用略带灰的白,眼睛会舒服很多。

切换动画别想当然加

很多人喜欢给切换加个过渡,在根节点写 * { transition: background-color .3s; },不建议这么干。给所有元素加全局过渡,首屏加载和路由切换时所有颜色都会"渐变进场",看着像页面没加载好,元素一多这个过渡的性能开销也很可观。

更稳的做法是只给确实需要的容器加过渡,并且尊重用户的"减弱动效"偏好:

1@media (prefers-reduced-motion: no-preference) {
2  .panel {
3    transition: background-color .2s ease, color .2s ease;
4  }
5}

切换那一瞬间如果想完全关掉过渡(比如初始化设主题时不希望看到动画),可以在切换前给 html 临时加一个 no-transition 类,下一帧再移除,避开首次设值的闪变。

主题切换的关键是提前抽象颜色变量,而不是到处写固定色值。简单项目用 class + CSS 变量 就够,复杂项目还要结合组件库主题能力、@layer 管理优先级、SSR 首屏防闪这些手段,让业务样式和组件库样式保持一致。现在做主题系统,会先设计语义 token,再处理主题 class、持久化、系统偏好和首屏防闪,切换按钮只是最后一步,真正的工作是让颜色、组件、图表和状态都能被同一套主题规则驱动。