一次全站暗色模式改造记,CSS变量定义策略是关键

阿乐
阿乐 管理员
发布于 2026-09-13 07:13 ·5 浏览 ·0 回复

去年底,产品突然提了个需求:全站支持暗色模式。一开始我以为这不就是换套颜色的事,结果真正动手才发现,暗色模式改造更像一次颜色系统的重构。如果只是把白色背景改成黑色、黑色文字改成白色,很快就会遇到“这个灰在暗色下看不清”“那个阴影像糊了一层泥”“第三方组件完全不听话”之类的连锁问题。

踩了几轮坑之后,我最大的感受是:暗色模式能不能做好,不取决于你调了多少个色值,而取决于 CSS 变量的定义策略。变量怎么分层、怎么命名、怎么被组件消费,决定了这次改造是两周上线,还是未来两年都在还债。

从“反向滤镜”到“语义变量”

最开始我们也试过偷懒方案:给根节点加 `filter: invert(1)`,再对图片、视频反向一次。演示时效果还行,一上真实页面就崩了。品牌色被反转、图标变色、阴影方向诡异,用户上传的图片也像进了暗房。

后来换成 CSS 变量,但第一版依然很粗糙:组件里直接写 `var(--gray-900)`、`var(--gray-100)`。这等于把“色板”当成了“语义”。问题是,同一个 `--gray-900`,在亮色下可能是正文色,在暗色下却应该变成背景色。色板变量本身没有主题含义,一旦组件直接依赖它,切换主题时就会精神分裂。

真正合理的做法,是把变量分成三层。

三层变量:色板、语义、组件

第一层是基础色板,只描述颜色本身,不描述用途。比如:

:root {
  --gray-0: #ffffff;
  --gray-50: #f8f9fa;
  --gray-900: #111315;
  --brand-500: #3b82f6;
}

第二层是语义变量,描述“这个颜色用在哪里”:

:root {
  --color-bg-page: var(--gray-0);
  --color-bg-surface: var(--gray-50);
  --color-text-primary: var(--gray-900);
  --color-border-default: var(--gray-200);
  --color-brand: var(--brand-500);
}

第三层是组件变量,只在复杂组件内部做局部覆盖:

.card {
  --card-bg: var(--color-bg-surface);
  --card-border: var(--color-border-default);
  background: var(--card-bg);
  border: 1px solid var(--card-border);
}

这样切换主题时,只需要覆盖语义变量:

[data-theme="dark"] {
  --color-bg-page: #0f1115;
  --color-bg-surface: #171a21;
  --color-text-primary: #e8eaed;
  --color-border-default: #2a2f3a;
}

组件完全不用改。这就是策略的价值:把变化收敛到一层,而不是散落到几百个文件里。

命名与作用域:别让色板变量直接进组件

我们后来定了一条团队规约:业务组件里禁止直接使用 `--gray-`、`--blue-` 这类色板变量,只能使用 `--color-*` 语义变量。Code Review 时看到直接写色板变量的,基本都会打回。

这条规约听起来有点教条,但非常有效。因为一旦允许“就近取色”,暗色模式就会变成打地鼠。今天这个卡片用了 `--gray-100`,明天那个弹窗用了 `--gray-50`,暗色下到底是 surface 还是 background,没人说得清。语义变量强制开发者先想清楚“这个颜色是背景、文字、边框还是品牌强调”,而不是“它看起来像哪个灰”。

主题切换与闪烁:SSR 下的细节

主题切换本身不难,难的是不闪。我们用 `data-theme` 挂在 `<html>` 上,配合 `localStorage` 和 `prefers-color-scheme`。关键是在 SSR 页面里,必须在 `<head>` 内联一段极简脚本,在首屏渲染前就把主题写到 DOM 上:

<script>
  const saved = localStorage.getItem('theme');
  const dark = saved ? saved === 'dark' : matchMedia('(prefers-color-scheme: dark)').matches;
  document.documentElement.dataset.theme = dark ? 'dark' : 'light';
</script>

否则等 React 水合后再切,用户会看到一瞬间的白闪。暗色模式下的白闪,比亮色模式下的黑闪更刺眼。

另外别忘了 `color-scheme`:

:root { color-scheme: light; }
[data-theme="dark"] { color-scheme: dark; }

它能让浏览器原生控件、滚动条、输入框自动进入暗色,省掉很多边角料工作。

那些容易被忽略的细节

暗色模式不是简单反色。阴影在暗色下要更弱、更扩散;边框不能纯黑,否则会消失;图片和视频最好保留原色,必要时加一层轻微暗色蒙版;代码高亮、图表、SVG 图标都要单独准备暗色主题。我们甚至给 `--shadow-card` 也做了语义变量,亮色下是 `0 1px 3px rgba(0,0,0,.1)`,暗色下换成 `0 1px 3px rgba(0,0,0,.4)`,观感会自然很多。

迁移策略上,我们没有一次性全量替换,而是先从新组件开始,再按页面逐步替换。每替换一个模块,就在亮色和暗色下各走一遍视觉回归。老代码里的硬编码颜色用脚本扫出来,生成待办清单,避免遗漏。

总结

回头看,这次暗色模式改造最值钱的部分,不是最终那套暗色配色,而是建立了一套可持续的颜色变量策略:色板层只提供原料,语义层定义用途,组件层按需消费,主题切换只覆盖语义层。CSS 变量本身并不复杂,复杂的是如何约束团队不把它用回“全局常量”。如果重来一次,我会更早地定下命名规约和三层结构,少走很多反色、补丁、再反色的弯路。

本文转载自 Clara轻量论坛系统,原文地址:https://www.leleweb.cn/thread-285.html
转载请注明出处,版权归原作者所有。

全部回复 0

还没有回复,来抢沙发~