这篇讲完,你能拿到一套可以直接抄进项目的 Vue 3 暗黑模式方案:跟随系统、手动可切、刷新不闪白、多标签页同步。
第一步:先定「三种状态」,不要只做开关
主题状态应该有三个值,而不是一个布尔值:
light:强制亮色
dark:强制暗色
system:跟随 prefers-color-scheme(默认值)
布尔开关的问题在于:用户选了「跟随系统」后,你没法再区分「他主动选了亮色」和「他默认跟随系统」。三态是后面所有逻辑的基础。
注意:system 必须作为默认值存进 localStorage,而不是「没存值就当 system」,否则用户切回 system 时你无法和「从未设置」区分。
第二步:用 CSS 变量承载颜色,不要用 JS 改样式
所有颜色收敛成变量,主题切换只改一个属性:
:root {
--bg: #ffffff;
--text: #1f2328;
--border: #e5e7eb;
color-scheme: light;
}
html[data-theme="dark"] {
--bg: #17181c;
--text: #e6e6e6;
--border: #2c2f36;
color-scheme: dark;
}
body {
background: var(--bg);
color: var(--text);
}
color-scheme 这一行别省,它能让滚动条、输入框、下拉框这些原生控件跟着变暗,否则暗色页面上突然弹出个白色 select 很刺眼。
第三步:写一个 useTheme 组合式函数
// composables/useTheme.js
import { ref, computed, watchEffect } from 'vue'
const KEY = 'theme-mode'
const mode = ref(localStorage.getItem(KEY) || 'system')
const media = window.matchMedia('(prefers-color-scheme: dark)')
const systemDark = ref(media.matches)
media.addEventListener('change', e => (systemDark.value = e.matches))
const resolved = computed(() =>
mode.value === 'system' ? (systemDark.value ? 'dark' : 'light') : mode.value
)
watchEffect(() => {
document.documentElement.dataset.theme = resolved.value
localStorage.setItem(KEY, mode.value)
})
export function useTheme() {
return { mode, resolved, setMode: v => (mode.value = v) }
}
关键点:状态定义在模块顶层(组件外),这样多个组件同时用 useTheme() 拿到的是同一份状态,不需要 Pinia。
第四步:解决刷新瞬间的白屏闪烁
这是最容易踩的坑。Vue 应用挂载前有一段空白期,此时 HTML 已经是暗色背景,但 JS 还没跑,页面会先白一下。
在 index.html 的 <head> 里、任何 CSS 和入口脚本之前插一段内联脚本:
<script>
(function () {
try {
var m = localStorage.getItem('theme-mode') || 'system';
var dark = m === 'dark' || (m === 'system'
&& matchMedia('(prefers-color-scheme: dark)').matches);
document.documentElement.dataset.theme = dark ? 'dark' : 'light';
} catch (e) {}
})();
</script>
注意:这段逻辑必须和 useTheme 里的判断完全一致。两处各写一套、后期改了一处忘了另一处,就会出现「刷新后主题跳一下」的诡异现象。
第五步:组件里怎么用
模板中直接用 resolved 做条件渲染,用 mode 渲染三档切换器:
<script setup>
import { useTheme } from '@/composables/useTheme'
const { mode, resolved, setMode } = useTheme()
</script>
<template>
<select :value="mode" @change="setMode($event.target.value)">
<option value="light">亮色</option>
<option value="dark">暗色</option>
<option value="system">跟随系统</option>
</select>
<img :src="resolved === 'dark' ? logoDark : logoLight" />
</template>
用 Tailwind 的话,配 darkMode: ['selector', '[data-theme="dark"]'],然后照常写 dark:bg-gray-900。用 Element Plus 则引入它的暗色变量文件,并把 html.dark 类同步加上。
第六步:两个体验细节
抑制切换时的过渡动画:如果全局写了 transition: background .3s,切主题时整页颜色渐变会显得拖沓。切换瞬间给 <html> 加一个 theme-transitioning 类禁用 transition,200ms 后移除。
多标签页同步:监听 storage 事件即可。
window.addEventListener('storage', e => {
if (e.key === 'theme-mode' && e.newValue) mode.value = e.newValue
})
另外记得给 <meta name="theme-color"> 也按主题改内容,手机浏览器地址栏颜色才会跟着变。
小结
- 状态用三态(light / dark / system),默认
system,不要用布尔值
- 颜色全部走 CSS 变量 +
html[data-theme],JS 只改一个属性
- 别忘
color-scheme,原生控件才会跟着变暗
- 首屏防闪白:
<head> 内联脚本抢先设置 data-theme,逻辑与 composable 保持一致
- 状态放模块顶层共享,不必引入状态管理库
- 补上 storage 事件同步和切换时的 transition 抑制,体验才算完整