阅读提要
主题切换的难点不是「怎么改变量」,是「怎么不闪」。这篇讲全局变量、组件变量、运行时切换三层结构,以及首屏闪烁的真正原因。
三层结构
一套好维护的主题系统,通常分三层:
- 基础变量:调色板本身,
--blue-500、--gray-900,语义中立; - 语义变量:
--bg、--text、--border,指向基础变量; - 组件变量:局部覆盖,
--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) 关掉。
一个务实的实现顺序
- 先定语义变量(
--bg、--text、--border、--accent),不超过 15 个; - 组件全部改成引用语义变量;
- 加
[data-theme='dark']覆盖; - 内联脚本处理首屏;
- 加
color-scheme; - 最后再考虑过渡动画。
第 1 步的变量数量是关键。语义变量超过 30 个,说明抽象层次没找对,后面维护会很痛苦。
收尾
主题切换看着是样式问题,实际是「状态初始化时机」问题。CSS 变量只负责「怎么变」,内联脚本负责「什么时候变」。
两件事分开想,就不会陷入「改了半天还是闪一下」的困境。
你认为这篇文章怎么样?
还没有表情反馈

一起聊聊
一个想法、一点补充,或一句简单的问候。
评论与回复
友善交流,彼此尊重第一句话,留给你。
读完有什么感受?欢迎成为第一个分享的人。