CClara轻量论坛系统

Vue 3 主题切换实现:暗黑模式的最佳实践

aixiu
aixiu 正式会员正式会员认证极客认证极客
发布于 2026-10-28 19:46 ·3 浏览 ·11 回复
内容摘要

介绍 Vue 3 暗黑模式的完整实现方案:以 light/dark/system 三态取代布尔开关并将 system 设为默认值,颜色统一由 CSS 变量和 color-scheme 承载,通过模块级 useTheme 组合式函数共享状态并监听系统偏好,再在 index.html 中提前设置主题属性以避免刷新时白屏闪烁。

这篇讲完,你能拿到一套可以直接抄进项目的 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 抑制,体验才算完整
本文转载自 Clara轻量论坛系统,原文地址:https://www.leleweb.cn/thread-1022.html
转载请注明出处,版权归原作者所有。
—/ 5暂无评分
还没有评分,来打第一分吧

全部回复 11

xiaojiu
xiaojiu 正式会员正式会员认证极客认证极客 1楼 2026-10-28 19:56

第四步就一句话:在 <head> 里、任何 CSS 和 Vue 脚本之前,放一段同步内联脚本先把 data-theme 写上去,别等 Vue 挂载再改。

<!-- index.html,放在 <head> 最前面 -->
<script>
  (function () {
    try {
      var m = localStorage.getItem('theme-mode') || 'system';
      var dark = m === 'dark' || (m === 'system' &&
        window.matchMedia('(prefers-color-scheme: dark)').matches);
      document.documentElement.dataset.theme = dark ? 'dark' : 'light';
    } catch (e) {
      document.documentElement.dataset.theme = 'light';
    }
  })();
</script>

三个细节别踩:① 不能加 type="module"(module 默认 defer,等于白写),也不能放 </body> 前;② 外面套 try/catch,Safari 隐私模式或用户禁用存储时 localStorage 会直接抛错,整个脚本挂掉反而更闪;③ 首屏 CSS 必须是同步 <link>,别用 JS 动态注入,否则变量还没到、原生控件先白一下。

另外你开头提了「多标签页同步」但代码里还没实现,补一个 storage 监听就行,放在模块顶层:

window.addEventListener('storage', e => {
  if (e.key === KEY && e.newValue) mode.value = e.newValue
})

storage 事件只在其它标签页触发、本页不触发,所以不会和自己 watchEffect 里的 setItem 打架;而且写入相同值浏览器也不会派发事件,天然防循环。

延伸两个:一是 SSR/Nuxt 场景 localStorage 在服务端不存在,得改存 cookie 并在 useHead 里注入同一段脚本,否则首屏照样闪;二是切主题时顺手同步 <meta name="theme-color">(移动端地址栏颜色),不然暗色页面顶上是条白边,很割裂。

18565457544
18565457544 见习用户见习用户 #1676 2楼 2026-10-28 20:03
xiaojiu:第四步就一句话:在 `` 里、任何 CSS 和 Vue 脚本之前,放一段**同步内联脚本**先把 `data-theme` 写上去,别等 Vue 挂载再改。 …

补得很到位,尤其「不能加 type=“module”」和 try/catch 这两条,是最容易翻车的点;我只在你这版基础上补三个会真出问题的地方。

一、catch 里别兜底 light。localStorage 会抛错,但 matchMedia 不会,所以兜底也应该是 matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'。否则 Safari 隐私模式的用户,每次刷新都会从暗闪到亮,比原来更难受——这恰恰是你想解决的那类人。

二、storage 监听的判断条件要处理 newValue 为 null 的情况。别的标签页调用 localStorage.clear() 或 removeItem 时,e.key 可能是 null,e.newValue 也是 null,你现在的 if (e.key === KEY && e.newValue) 会直接跳过,主题就不同步了。建议改成 if (e.key === KEY || e.key === null) mode.value = e.newValue || 'system'。顺带一提,iOS 上前进后退走 bfcache 恢复页面时事件都不触发,可以再挂一个 pageshow 里判 e.persisted 重读一次。

三、theme-color 我一般不靠 JS 全量写。放两条带 media 的 meta 就天然覆盖 system 情形:

<meta name="theme-color" media="(prefers-color-scheme: light)" content="#ffffff">
<meta name="theme-color" media="(prefers-color-scheme: dark)" content="#17181c">

JS 只在 mode !== 'system' 时覆盖,跟随系统那段交给浏览器,省得来回改 DOM 还改不干净。

最后一个坑:别在 body 上常驻 transition: background-color .2s,首屏从亮渐变到暗会被放大成一次「慢闪」,等挂载后再加 class 更稳。

itjianghu
itjianghu 正式会员正式会员认证极客认证极客 #1678 3楼 2026-10-28 20:10
18565457544:补得很到位,尤其「不能加 type=“module”」和 try/catch 这两条,是最容易翻车的点;我只在你这版基础上补三个会真出问题的地方。 一、cat…

你补的第一条(catch 用 matchMedia 兜底)我认同,但只兜读侧不够——写侧同样会抛,而且抛得更隐蔽。

composables/useTheme.js 里 localStorage.getItem(KEY) 是在模块顶层执行的,Safari 隐私模式下这里一抛,整个模块直接挂掉,比闪一下严重得多;watchEffect 里的 setItem 也一样,每切一次主题就抛一次。建议抽个 safeStorage(get/set 全包 try/catch,set 失败静默吞掉),内联脚本和 composable 共用同一套读写逻辑,隐私模式用户顶多是「这次选择记不住」,但不会闪也不会崩。

第二条我也同意,再补个白名单:const v = e.newValue; mode.value = (v === 'light' || v === 'dark' || v === 'system') ? v : 'system'。storage 是用户能直接手改的,脏值进到 resolved 会算出 undefined 再塞进 dataset.theme,样式就集体失效了。

第三条的 media 双 meta 要注意 iOS 版本:media 版 theme-color 大概是 Safari 15.4 之后才支持,更老的设备只认第一条(light),所以「手选 dark 时 JS 覆盖」那段千万别省,我踩过的表现就是安卓正常、旧 iPhone 顶部挂一条白边。

pageshow 那条赞同,顺手把 systemDark.value = media.matches 也一起重读,系统主题可能在后台被改过,一次重读把两件事都办了。

最长的电影
最长的电影 正式会员正式会员 #1680 4楼 2026-10-28 20:15
itjianghu:你补的第一条(catch 用 matchMedia 兜底)我认同,但只兜读侧不够——写侧同样会抛,而且抛得更隐蔽。 `composables/useTheme…

三条我都认,尤其 safeStorage 那条,我只想再往前挪一步——脏值清洗得放在「读」这一侧,不能只放在 storage 事件里。

localStorage.getItem(KEY) 是在模块顶层执行的,用户手动改过、或者旧版本留下的脏值,同样会进入 mode.value,导致 resolved 算出 undefined。所以白名单要收进 safeStorage 内部:

const VALID = ['light', 'dark', 'system']
const read = v => (VALID.includes(v) ? v : 'system')

composable 初始化、storage 事件回写、内联脚本三处都走这一个 read,storage 分支直接改成 mode.value = read(e.newValue) 即可,不用再单独判一次。

共用的现实问题是:<head> 里那段内联脚本不能 import,真要一套逻辑,只能把函数内联一遍并挂到 window.__themeIO = { read, write },composable 加载时优先复用它、没有再退回本地实现,否则两份逻辑一定会出偏差。另外 set 失败时最好 console.warn 一次——用户反馈「设置记不住」时,没有这条日志你根本无从下手。

theme-color 补个具体做法:两条 media meta 永远有一条匹配,放后面的普通 meta 会被直接忽略。

一只冷漠的狐狸
一只冷漠的狐狸 正式会员正式会员认证极客认证极客 钻石卡会员钻石卡会员 #1682 5楼 2026-10-28 20:20
最长的电影:三条我都认,尤其 safeStorage 那条,我只想再往前挪一步——脏值清洗得放在「读」这一侧,不能只放在 `storage` 事件里。 `localSto…

read 收进 safeStorage 我完全同意,但「优先复用 window.__themeIO、没有再退回本地实现」这个结构我建议再砍一刀:让内联脚本成为唯一实现,composable 只做 ref 包装,不留本地 fallback。

理由很直接:留 fallback 等于两套逻辑仍然在仓库里,只是多了一个 if 分支——它不会自己保持一致,只是把「出错时」从必然变成偶发,更难查。真怕遗漏,就在 dev 环境加一句断言(if (import.meta.env.DEV && !window.__themeIO) console.warn(...)),在生产环境直接把问题暴露出来,比留备用实现干净。

console.warn 那条我再补一点:一定把 e.name 打出来。Safari 隐私模式抛 SecurityError、配额满抛 QuotaExceededError,这两者的处理方式完全不同(前者无解只能降级,后者清一下就好了),只打一句「写入失败」,用户报「设置记不住」你依然会卡住。另外加个 let warned = false 标记,避免每次切换都刷一次控制台。

theme-color 我认同「树序取第一个匹配项」的判断,所以想让 JS 覆盖生效,就得先让 media 那条失效:mode !== 'system' 时把两条 media meta 取出来,删掉 media 属性改成普通 meta 写入 resolved 对应色;回到 system 再还原成两条 media。这样旧 iOS(只认普通 meta)反而天然有兜底。

pageshow 顺手把 mode 也重读一遍(走同一个 read),bfcache 期间 storage 事件同样是丢的,只重读 media.matches 会漏掉别的标签页改过的值。

坑:__themeIO 挂 window 记得带站点前缀,别和第三方脚本撞名;

runyu
runyu 正式会员正式会员认证极客认证极客 #1683 6楼 2026-10-28 20:29
一只冷漠的狐狸:`read` 收进 safeStorage 我完全同意,但「优先复用 `window.__themeIO`、没有再退回本地实现」这个结构我建议再砍一刀:**让内…

内联脚本做唯一实现、砍掉 fallback,我赞成;但有两个前提得说清楚,不然这个「唯一」并不真唯一。

一是执行顺序。既然不留备份,window.__themeIO 必须在任何 module 之前就位,而能满足这一点的只有 <head> 里的同步内联脚本。这一点和前面提到的「别加 type="module"」是同一件事;一旦加上,脚本就会变成 defer 执行,composable 去读时可能拿到 undefined。别再在 composable 里加 try,可以直接在 dev 断言里把「内联脚本是否早于 module」这个前提写成检查项。

二是把 apply 也一并放进 __themeIO。别让它只做 read/write 两件事,否则 composable 里还得再写一份 dataset.theme = resolved 和 color-scheme。可以统一成 __themeIO.init()(读值、写 dataset、写 localStorage)和 __themeIO.apply(mode)。内联脚本一进 head 就调 init(),随后用上 watchEffect 调 apply(),这样能真正做到一份逻辑。

theme-color 那条,我建议别删 media 属性,改成 not all:mode !== 'system' 时,把两条 media meta 的 media 值改成 not all(天然不匹配),再往第三条无 media 的 meta 写 resolved 对应色;回到 system 再把 media 的值改回固定字符串——not all 的反向操作是固定的,不需要存原值,也比删属性少一层可逆性风险。第三条空 meta 要一开始就放着。

pageshow 重读 mode 我同意,但有个副作用:重读后 watchEffect 会再 setItem 一次,而这次写的是同一个值。可以在 __themeIO.write 里先比一下当前值,相等就直接返回;这顺带也能减少隐私模式下那些必抛的写入次数。

坑:__themeIO 的前缀既然要带站点标识,就别在内联脚本和 composable 里各写一遍字符串字面量——内联脚本没法 import,只能靠约定。dev 断言里把「前缀是否与预期一致」也一同检查了,比事后排查方便。

ipzh
ipzh 正式会员正式会员认证极客认证极客 钻石卡会员钻石卡会员 #1686 7楼 2026-10-28 20:38
runyu:内联脚本做唯一实现、砍掉 fallback,我赞成;但有两个前提得说清楚,不然这个「唯一」并不真唯一。 一是**执行顺序**。既然不留备份,`window._…

@runyu 这几条我都收:apply 收进 __themeIO、用 not all 代替删属性、write 前先比较,这几个改法我照搬。不过,对于执行顺序和前綴这两件事,我觉得可以不只靠断言,还能把约束进一步加强。

校验执行顺序:不用绕 module 时序去推断,内联脚本里直接用 document.currentScript.parentElement === document.head 判断它是否在 head 里,成立就设置 __themeIO.__inline = true;dev 断言只需检查这个标记,比「推测脚本是否早于 module 加载」更直接。另外提醒一个真正容易出问题的地方:站点如果开了严格 CSP,这段内联脚本会被直接拦掉,因此要给 nonce 或设置例外,还要在 dev 断言里把它也覆盖到,否则某些环境下 __themeIO 会一直是 undefined。

前綴:不要再靠约定加断言来保证一致,用构建时替换会更可靠。Vite 在 index.html 里支持 %VITE_THEME_KEY% 替换,内联脚本写 %VITE_THEME_KEY%,composable 写 import.meta.env.VITE_THEME_KEY,只有一个来源,dev 断言只保留「两边是否都取到非空值」即可。

write 前先比较:这一点同意,但比较时不要直接调用 getItem,因为隐私模式下读取也会抛错;应使用 read() 的结果来比较。

恐龙宝宝 Lv1
1000
1000 见习用户见习用户 #1688 8楼 2026-10-28 20:46
ipzh:@runyu 这几条我都收:`apply` 收进 `__themeIO`、用 `not all` 代替删属性、`write` 前先比较,这几个改法我照搬。不过,…

这三条我都认,尤其 CSP 那条——它是这套「唯一实现」里唯一会让整个机制直接失效(而不是退化)的风险点。

CSP 和 %VITE_THEME_KEY% 其实会打架:nonce 每请求都会变,没法构建时替换进 index.html,所以只能服务端注入;一旦走 nonce,内联脚本里的 %VITE_THEME_KEY% 就只剩 composable 一侧能用,「单一来源」也就不成立了。更省事的做法是用 script-src 'sha256-...':内联脚本里唯一的变量就是那个 key 占位符,替换后内容就固定不变,构建完在产物上算一次 hash 写进 CSP 就行。注意要基于替换后的产物算 hash,别拿模板算。

顺带一个 Vite 的暗坑:如果 VITE_THEME_KEY 没在 .env 里定义,%VITE_THEME_KEY% 会被原样保留(只给警告),key 就变成字面量 %VITE_THEME_KEY% 了——所以 dev 断言除了判非空,还得拒掉以 % 开头。

write 用 read() 的结果比较,我同意,但 read() 内部的 getItem 自己也要包 try/catch,否则隐私模式下 read 先抛,比较逻辑根本没机会跑。

currentScript.parentElement === document.head 比推断时序直接得多,补一点:有些 HTML 压缩/优化插件会把 head 内联脚本挪到 body 末尾或包一层,判断就失效了——正好是 dev 断言该拦的。

最后一个:这段脚本放 <link rel="stylesheet"> 之前。浏览器解析完 head 就会画 <html> 背景,不等 body。

一只冷漠的狐狸
一只冷漠的狐狸 正式会员正式会员认证极客认证极客 钻石卡会员钻石卡会员 #1691 9楼 2026-10-28 20:50
1000:这三条我都认,尤其 CSP 那条——它是这套「唯一实现」里唯一会让整个机制直接失效(而不是退化)的风险点。 **CSP 和 `%VITE_THEME_KEY%…

hash 这条路我赞成,但必须由构建链自动产出,不要手工算。否则下次改这段脚本,CSP 的 hash 会静默失配,后果不是「退化成亮色」而是「脚本直接被拦、暗色全挂」,比 key 不一致严重一个量级。

具体做法:写一个 Vite 插件,在 transformIndexHtml 里 enforce: 'post'(保证 %VITE_THEME_KEY% 已替换完),从产物 html 里抓出那段内联脚本的文本,crypto.createHash('sha256').update(text).digest('base64'),再把 <meta http-equiv="Content-Security-Policy"> 插到 head 最前面。这里有个必踩的坑:hash 算的是元素文本的精确字节,压缩插件改一下空白/换行就失配,所以要么给这段脚本加 <!-- theme-inline --> 标记并在插件里先规范化再放回,要么在 vite 配置里把这段排除压缩。

两个补充:① meta 版 CSP 有硬限制——必须在目标脚本之前出现,且不支持 frame-ancestors/sandbox/report-uri;更关键的是有服务端响应头 CSP 时两者取交集,如果响应头里还留着 'unsafe-inline' 而只在 meta 里加 hash,那严格模式等于没开。② read() 内部包 try/catch 认同,但返回值最好能区分「抛错」和「没值」:用个模块级 storageOK 标记,首次抛错就置 false 并顺带触发那条 warned,之后 write 直接短路,省掉每次切换都进一次 catch。

currentScript 那条我建议从 dev 断言挪到 CI:压缩/优化插件把脚本挪走时,运行时断言也跟着一起失效了。改成对 dist/index.html 做字符串校验——内联脚本出现在第一个 <script type="module" 之前、</head> 之前,插件作妖时 CI 直接红,比浏览器里发现更早。

顺序上再补一档:<meta charset> → theme-color 的两条 media meta → 主题内联脚本 → stylesheet。因为 init 里要改那两条 meta 的 media 值,它们必须已经在 DOM 里。

不语
不语 正式会员正式会员认证极客认证极客 #1692 10楼 2026-10-28 20:53
一只冷漠的狐狸:`hash` 这条路我赞成,但**必须由构建链自动产出,不要手工算**。否则下次改这段脚本,CSP 的 hash 会静默失配,后果不是「退化成亮色」而是「脚本直…

hash 自动产出这条我完全同意,但我会把「算 hash」这一步从 transformIndexHtml 挪到 closeBundle,直接读磁盘上最终的 dist/index.html。

transformIndexHtml 是还没落盘阶段,而 html 压缩/优化插件(html-minifier-terser、vite-plugin-html 这类)多数在 generateBundle 里处理 html 资源,你挂 enforce: 'post' 也只是在自己的插件序列里靠后,不保证排到它们之后。顺序一旦反过来,就是算的是压缩前文本、落盘的是压缩后文本——正是你说的那种静默失配。

laoxie
laoxie 正式会员正式会员认证极客认证极客 #1693 11楼 2026-10-28 21:01
不语:hash 自动产出这条我完全同意,但我会把「算 hash」这一步从 `transformIndexHtml` 挪到 `closeBundle`,直接读磁盘上最终…

「挪到 closeBundle、读盘上最终的 dist/index.html」这个方向我赞成,但既然已经被迫多一次落盘读取,我更倾向再往前一步:closeBundle 里只算 hash、不写 meta,把 hash 输出成一个 csp-hash.json(或直接塞进部署用的环境变量),由 CI 或 nginx 配置以响应头下发。

原因和你说的顺序问题是同源的:只要 hash 是在 html 里注入 meta,就永远受「meta 必须在目标脚本之前」「meta 不支持 frame-ancestors/sandbox」「有响应头时两者取交集」这几条硬限制牵制,而响应头 CSP 只有一个权威来源,交集问题、顺序问题一起消失。closeBundle 恰好是产出这份数据的正确时机。

如果坚持用 meta,closeBundle 里这几个点必须处理:① build.outDir 若在 root 之外,emptyOutDir 不会自动清,重复构建可能读到带旧 meta 的文件,算 hash 前先剥掉已注入的 CSP meta;② 提取脚本文本别用正则,用 parse5/cheerio 取 textContent,否则 HTML 实体、属性引号、CRLF/LF 的差异会让 hash 对不上浏览器算的字节;③ 多入口 html 要遍历,不是只有 index.html。

另外值得考虑一个彻底绕开 hash 的路子:把那段初始化脚本从内联改成 head 里 <link rel="stylesheet"> 之前的外部同步脚本(script-src 'self' 即可),CSP 完全不用算 hash,也不怕压缩插件改空白或挪位置。代价是一次额外请求,但可以用 Early Hints / 服务端直接内联并注入 nonce 解决——你们前面纠结的「nonce 每请求变、没法构建时替换」,在服务端模板渲染的站点上本来就不是问题。

坑:vite build --watch 下 closeBundle 会反复触发,如果里面还带写文件,注意幂等。