阅读提要

主题切换的难点不是「怎么改变量」,是「怎么不闪」。这篇讲全局变量、组件变量、运行时切换三层结构,以及首屏闪烁的真正原因。

开始阅读

字号

三层结构

一套好维护的主题系统,通常分三层:

  1. 基础变量:调色板本身,--blue-500、--gray-900,语义中立;
  2. 语义变量:--bg、--text、--border,指向基础变量;
  3. 组件变量:局部覆盖,--card-padding、--btn-radius。

切换主题时只改第二层,组件代码完全不用动。

:root {
  --blue-500: #4976ee;
  --gray-50: #f8fafc;
  --gray-900: #0f172a;

  --bg: var(--gray-50);
  --text: var(--gray-900);
  --border: #e2e8f0;
}

[data-theme='dark'] {
  --bg: #0b1220;
  --text: #e5e7eb;
  --border: #1f2937;
}

组件的样式只引用语义变量:

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

这条规则坚持住,加主题就只是加一段变量声明。

变量分层

运行时切换:用属性,不用 class

data-theme 属性比 class 更好,原因有两个:语义清晰(不是「样式类」而是「状态」),优先级可控(属性选择器和类选择器同级,不会互相打架)。

function setTheme(theme) {
  document.documentElement.dataset.theme = theme
  localStorage.setItem('theme', theme)
}

三态设计(亮色 / 暗色 / 跟随系统)需要多一层:

const prefersDark = window.matchMedia('(prefers-color-scheme: dark)')

function apply(theme) {
  const resolved = theme === 'system' ? (prefersDark.matches ? 'dark' : 'light') : theme
  document.documentElement.dataset.theme = resolved
  document.documentElement.dataset.themePreference = theme
}

prefersDark.addEventListener('change', () => {
  if (localStorage.getItem('theme') === 'system') apply('system')
})

注意存的是「偏好」(system/light/dark),解析出来的才是「实际主题」。存混了,用户切回系统时就不生效。

闪烁的真正原因

首屏闪一下白再变黑,原因不是 CSS,是脚本执行时机。

浏览器解析 HTML → 渲染首屏 → 执行 JS → JS 读到 localStorage 才知道该用暗色。这中间已经画了一帧亮色。

解决办法是把这段逻辑内联到 <head> 里,在样式生效前执行:

<script>
  (function () {
    var t = localStorage.getItem('theme') || 'system'
    var dark = t === 'dark' || (t === 'system' && matchMedia('(prefers-color-scheme: dark)').matches)
    document.documentElement.dataset.theme = dark ? 'dark' : 'light'
  })()
</script>

要点:同步、内联、放在 <head> 靠前的位置。异步加载或放到 body 末尾都不行。

内联脚本会被 CSP 拦。要么加 nonce,要么用 <meta name="color-scheme"> 配合 prefers-color-scheme 媒体查询做纯 CSS 的默认值,再由 JS 覆盖。

color-scheme 别忘

:root { color-scheme: light dark; }

这一行影响的是浏览器自己画的 UI:滚动条、表单控件、<select> 下拉、自动填充的背景色。

漏了它,暗色主题下会出现白色的滚动条和白底的下拉框,非常割裂。

组件级覆盖

有些组件需要在特定区域强制某种主题,比如代码块始终用暗色:

.code-block {
  --bg: #0d1117;
  --text: #c9d1d9;
  background: var(--bg);
  color: var(--text);
}

这种「局部重定义变量」的方式,比给每个子元素写覆盖样式干净得多——子元素自然继承新的值。

过渡动画:慎重

给 background-color 加 transition 会带来一个副作用:切换瞬间所有元素一起渐变,包括那些本该立刻变的。

更麻烦的是性能:大面积的颜色过渡会触发重绘,低端设备上会卡顿。

建议:默认不加过渡,或者只给少量关键元素加。真要加,用 @media (prefers-reduced-motion: reduce) 关掉。

一个务实的实现顺序

  1. 先定语义变量(--bg、--text、--border、--accent),不超过 15 个;
  2. 组件全部改成引用语义变量;
  3. 加 [data-theme='dark'] 覆盖;
  4. 内联脚本处理首屏;
  5. 加 color-scheme;
  6. 最后再考虑过渡动画。

第 1 步的变量数量是关键。语义变量超过 30 个,说明抽象层次没找对,后面维护会很痛苦。

收尾

主题切换看着是样式问题,实际是「状态初始化时机」问题。CSS 变量只负责「怎么变」,内联脚本负责「什么时候变」。

两件事分开想,就不会陷入「改了半天还是闪一下」的困境。

你认为这篇文章怎么样?

还没有表情反馈

分享文章