Repository Wiki
LyraVoid/Mizuki

主题切换与响应式样式

Mizuki 的明暗主题切换是一套纯客户端运行时机制:由 ThemeSwitch.svelte 组件触发,src/utils/setting-utils.ts 负责状态持久化与 DOM 应用,通过 documentElement 上的 dark 类、data-theme 属性与 --hue CSS 变量驱动整套样式系统,并在支持时使用 View Transition API 提供平滑过渡。响应式表现则依托 Tailwind 工具类、CSS 变量与 prefers-reduced-motion 媒体查询降级。

Purpose and Scope

本页覆盖「主题切换与响应式样式」这一前端运行时能力的完整闭环:

  • 主题状态模型(LIGHT_DARK_MODE)与 localStorage 持久化键(theme、hue)
  • 切换控制流:toggleScheme() → switchScheme() → setTheme() → applyThemeToDocument()
  • View Transition 动画路径与 prefers-reduced-motion / 不支持该 API 时的降级路径
  • 代码高亮(Expressive Code)双主题与 data-theme 属性的联动
  • 主题色相(hue)的默认值解析、访客自定义与 fixed 配置锁定
  • Swup 页面切换(content:replace)后主题状态的自愈同步
  • 响应式/过渡样式在主题切换场景中的具体落点(按钮图标动画、过渡抑制类)

以下相邻能力有意留给兄弟页面,本页只做边界性引用:

  • 页面级过渡与 Swup 路由机制本身(见前端运行时相关页面)
  • 全屏壁纸与遮罩(wallpaperMode、overlayOpacity、overlayBlur)——与主题同属 setting-utils.ts,但属于独立子系统
  • 站点级构建配置(astro.config.mjs 的整体结构)

Overview

主题切换解决三个问题:

  1. 持久化:用户的选择必须跨会话保留。实现上使用 localStorage 的 theme 键存储明暗模式、hue 键存储自定义色相。
  2. 无闪烁应用:切换时不仅要改 dark 类,还要同步代码块主题(data-theme 指向 github-light / github-dark),否则代码区与页面会出现明暗不一致。
  3. 可感知但不刺眼的过渡:直接切换会造成颜色跳变,因此优先使用 document.startViewTransition() 包裹 DOM 变更;对开启"减少动态效果"的用户或不支持该 API 的浏览器,退回到用 is-theme-transitioning 类控制的传统 CSS 过渡。

关键概念:

概念载体作用
LIGHT_DARK_MODE@/types/config 中的类型明暗模式联合类型(LIGHT_MODE / DARK_MODE)
DEFAULT_THEME@constants/constants无本地存储时的回落主题
dark 类documentElement驱动 Tailwind dark 变体与全局暗色样式
data-theme 属性documentElement驱动 Expressive Code 代码块主题
--hue 变量:root 内联样式站点主题色相,供 HSL 派生色消费
#config-carrierDOM 元素构建期配置(如默认 hue)注入客户端的载体
is-theme-transitioningdocumentElement 类切换期间抑制无关 CSS 过渡
use-view-transitiondocumentElement 类标记当前处于 View Transition 渲染路径

Architecture

Loading diagram...

架构说明:

  • UI 与逻辑分离:ThemeSwitch.svelte 只负责交互态(当前模式、防抖标志)与图标动画,所有副作用(存储、DOM 写入)都下沉到 setting-utils.ts 的纯函数式导出。这使同一套工具函数可以被设置面板等其他组件复用,也让组件本身保持无状态副作用。
  • DOM 是唯一的"主题总线":明暗状态从不通过框架 store 广播,而是以 documentElement 的 dark 类 + data-theme 属性作为事实来源;Swup 内容替换后组件通过 getStoredTheme() 重新对账(详见下文"Swup 同步")。
  • 配置经 #config-carrier 传递:默认 hue 等构建期配置写入一个 DOM 元素的 dataset,客户端用 Number.parseInt 解析并带回落值,避免直接把站点配置打包进客户端逻辑的硬耦合。
  • ASTro 侧配合:astro.config.mjs 中 Swup 集成显式设置 theme: false(关闭其内置主题处理),Expressive Code 注册明暗双主题,为 data-theme 切换提供样式基础。

Source: astro.config.mjs

核心控制流:一次点击如何完成主题切换

Loading diagram...

第一步:入口与防抖

ThemeSwitch.svelte 以 seq: LIGHT_DARK_MODE[] = [LIGHT_MODE, DARK_MODE] 定义循环顺序。点击触发 toggleScheme():

ts
1function toggleScheme() { 2 if (isChanging) { 3 return; 4 } 5 6 let i = 0; 7 for (; i < seq.length; i++) { 8 if (seq[i] === mode) { 9 break; 10 } 11 } 12 switchScheme(seq[(i + 1) % seq.length]); 13}

Source: ThemeSwitch.svelte

seq[(i + 1) % seq.length] 的取模写法让顺序表天然可扩展——若未来加入"跟随系统"作为第三态,只需在 seq 中追加元素,切换逻辑零改动。isChanging 标志配合 switchScheme() 内 50ms 的 setTimeout 重置,防止连击造成的存储与 DOM 抖动:

ts
1function switchScheme(newMode: LIGHT_DARK_MODE) { 2 // 防止连续快速点击 3 if (isChanging) { 4 return; 5 } 6 7 isChanging = true; 8 mode = newMode; 9 setTheme(newMode); 10 11 // 50ms 后重置状态,防止过速切换 12 setTimeout(() => { 13 isChanging = false; 14 }, 50); 15}

Source: ThemeSwitch.svelte

注意 mode = newMode 与 setTheme(newMode) 同时执行:前者更新 Svelte 5 $state 以驱动图标动画,后者完成持久化与 DOM 应用,两者职责正交。

第二步:持久化与幂等性

setTheme 极薄,只做两件事:

ts
1export function setTheme(theme: LIGHT_DARK_MODE): void { 2 localStorage.setItem("theme", theme); 3 applyThemeToDocument(theme); 4} 5 6export function getStoredTheme(): LIGHT_DARK_MODE { 7 return (localStorage.getItem("theme") as LIGHT_DARK_MODE) || DEFAULT_THEME; 8}

Source: setting-utils.ts

getStoredTheme() 的 || DEFAULT_THEME 兜底了首次访问(无存储)与存储被清除两种情况——这是"系统偏好检测"之外的显式默认策略,即未存储时回落到站点配置的默认主题而非读取 prefers-color-scheme。

第三步:applyThemeToDocument 的差异计算

该函数先做三组比较再决定是否动手,避免任何多余的 DOM 写入:

ts
1export function applyThemeToDocument(theme: LIGHT_DARK_MODE) { 2 const currentIsDark = document.documentElement.classList.contains("dark"); 3 const currentTheme = document.documentElement.getAttribute("data-theme"); 4 5 let targetIsDark = false; 6 switch (theme) { 7 case LIGHT_MODE: 8 targetIsDark = false; 9 break; 10 case DARK_MODE: 11 targetIsDark = true; 12 break; 13 default: 14 targetIsDark = currentIsDark; 15 break; 16 } 17 18 const needsThemeChange = currentIsDark !== targetIsDark; 19 const expectedTheme = targetIsDark ? "github-dark" : "github-light"; 20 const needsCodeThemeUpdate = currentTheme !== expectedTheme; 21 22 if (!needsThemeChange && !needsCodeThemeUpdate) { 23 return; 24 }

Source: setting-utils.ts

三个设计要点:

  1. default 分支保持现状:传入未知值时 targetIsDark = currentIsDark,函数退化为"仅校对代码主题",这是防御式编码——脏数据不会把用户踢出当前模式。
  2. 双重差异判断:needsThemeChange(页面明暗)与 needsCodeThemeUpdate(代码块主题)是两个独立信号。修复"页面已暗但代码块仍亮"这类不一致状态时,本函数依然能只更新缺失的那一项。
  3. 字符串字面量 "github-dark" / "github-light" 与 astro.config.mjs 中注册的 Expressive Code 主题对应,data-theme 属性变化后代码块样式随之切换。

第四步:双路径过渡(View Transition 与降级)

ts
1 if ( 2 needsThemeChange && 3 document.startViewTransition && 4 !window.matchMedia("(prefers-reduced-motion: reduce)").matches 5 ) { 6 document.documentElement.classList.add( 7 "is-theme-transitioning", 8 "use-view-transition", 9 ); 10 11 const transition = document.startViewTransition(() => { 12 performThemeChange(); 13 }); 14 15 transition.finished 16 .then(() => { 17 queueMicrotask(() => { 18 document.documentElement.classList.remove( 19 "is-theme-transitioning", 20 "use-view-transition", 21 ); 22 }); 23 }) 24 .catch(() => { 25 document.documentElement.classList.remove( 26 "is-theme-transitioning", 27 "use-view-transition", 28 ); 29 }); 30 } else { 31 if (needsThemeChange) { 32 document.documentElement.classList.add("is-theme-transitioning"); 33 } 34 35 performThemeChange(); 36 37 if (needsThemeChange) { 38 requestAnimationFrame(() => { 39 document.documentElement.classList.remove("is-theme-transitioning"); 40 }); 41 } 42 } 43}

Source: setting-utils.ts

进入 View Transition 路径需同时满足三个条件:确有明暗翻转、浏览器实现 document.startViewTransition、且用户未开启"减少动态效果"(prefers-reduced-motion: reduce)。任何一条不满足即走降级路径——降级路径不加 use-view-transition 类,仅在变更前后用 is-theme-transitioning 圈定窗口,用 requestAnimationFrame 在下一帧清理,把类生命周期压缩到一帧之内,避免残留类长期影响样式。

清理逻辑的健壮性体现在两处:成功分支用 queueMicrotask 延后到微任务阶段(确保过渡真正收尾),失败分支用 .catch() 兜住 transition.finished 被中断(如用户中途再次切换)导致的 Promise 拒绝,两条路都保证清理类被移除,不会把文档卡在"过渡中"状态。

Swup 页面切换后的状态自愈

Astro + Swup 场景下,页面内容会被整体替换,组件实例随之重建。ThemeSwitch.svelte 在 onMount 中监听 Swup 的 content:replace 钩子,用 requestAnimationFrame 等待 DOM 稳定后重新对账:

ts
1onMount(() => { 2 mode = getStoredTheme(); 3 4 // 监听 Swup 的内容替换事件,确保在页面切换后同步主题状态 5 const handleContentReplace = () => { 6 requestAnimationFrame(() => { 7 const newMode = getStoredTheme(); 8 if (mode !== newMode) { 9 mode = newMode; 10 } 11 }); 12 }; 13 14 let swupHooked = false; 15 16 const setupSwupHook = () => { 17 if (!swupHooked && window.swup?.hooks) { 18 window.swup.hooks.on("content:replace", handleContentReplace); 19 swupHooked = true; 20 } 21 }; 22 23 if (window.swup?.hooks) { 24 setupSwupHook(); 25 } else { 26 document.addEventListener("swup:enable", setupSwupHook, { once: true }); 27 } 28 29 return () => { 30 if (window.swup?.hooks && swupHooked) { 31 window.swup.hooks.off("content:replace", handleContentReplace); 32 } 33 document.removeEventListener("swup:enable", setupSwupHook); 34 }; 35});

Source: ThemeSwitch.svelte

这段代码处理了一个典型的时序竞态:Swup 可能晚于组件挂载才初始化。解法是双通道注册——若 window.swup?.hooks 已就绪则直接挂钩,否则通过一次性 swup:enable DOM 事件({ once: true })延后注册。swupHooked 布尔确保不会重复挂同一钩子。返回的清理函数同时卸载钩子与事件监听,符合 Svelte 5 的 onMount 销毁契约。这样即便另一个入口(如设置面板)改写了主题,切换按钮的图标状态也能在导航后自动纠正。

主题色相(Hue)子系统

除明暗外,Mizuki 还支持访客自定义主题色相,通过 --hue CSS 变量注入 :root:

ts
1export function getDefaultHue(): number { 2 const fallback = "250"; 3 const configCarrier = document.getElementById("config-carrier"); 4 if (!configCarrier) { 5 return Number.parseInt(fallback, 10); 6 } 7 return Number.parseInt(configCarrier.dataset.hue || fallback, 10); 8} 9 10export function getHue(): number { 11 if (siteConfig.themeColor.fixed) return getDefaultHue(); 12 const stored = localStorage.getItem("hue"); 13 return stored ? Number.parseInt(stored, 10) : getDefaultHue(); 14} 15 16export function setHue(hue: number): void { 17 localStorage.setItem("hue", String(hue)); 18 const r = document.querySelector(":root") as HTMLElement; 19 if (!r) { 20 return; 21 } 22 r.style.setProperty("--hue", String(hue)); 23}

Source: setting-utils.ts

配置读取的层级为:siteConfig.themeColor.fixed 为真 → 强制使用构建期默认值(隐藏访客取色器)→ 否则优先 localStorage.hue → 再回落 #config-carrier 的 dataset.hue → 最终硬编码回落 250。四层回落保证任何环境下 getHue() 都返回合法数字,setHue 对 :root 缺失也做了空值短路。README 中对应的配置面:

ts
1themeColor: { 2 hue: 210, // 0–360 3 fixed: false, // Hide the visitor theme-color picker when true 4},

Source: README.en.md

响应式样式与过渡表现

切换按钮的图标动画

按钮内叠加了两个绝对定位的图标(太阳 / 月亮),通过 Svelte 的 class: 指令按当前模式控制透明度与旋转:

svelte
1<button 2 aria-label="Light/Dark Mode" 3 class="relative btn-plain scale-animation rounded-lg h-11 w-11 active:scale-90 theme-switch-btn z-50" 4 id="scheme-switch" 5 onclick={toggleScheme} 6 data-mode={mode} 7> 8 <div 9 class="absolute transition-all duration-300 ease-in-out" 10 class:opacity-0={mode !== LIGHT_MODE} 11 class:rotate-180={mode !== LIGHT_MODE} 12 > 13 <Icon 14 icon="material-symbols:wb-sunny-outline-rounded" 15 class="text-[1.25rem]" 16 ></Icon> 17 </div> 18 <div 19 class="absolute transition-all duration-300 ease-in-out" 20 class:opacity-0={mode !== DARK_MODE} 21 class:rotate-180={mode !== DARK_MODE} 22 > 23 <Icon 24 icon="material-symbols:dark-mode-outline-rounded" 25 class="text-[1.25rem]" 26 ></Icon> 27 </div> 28</button>

Source: ThemeSwitch.svelte

  • data-mode 属性:把当前模式暴露给 CSS / 测试选择器,外部无需读取 Svelte 内部状态。
  • aria-label="Light/Dark Mode":保证屏幕阅读器可识别按钮用途。
  • 响应式尺寸:h-11 w-11(2.75rem 方形)+ active:scale-90 按压反馈 + z-50 保证在堆叠上下文中始终可点击。
  • 过渡时长 300ms ease-in-out:与页面级主题过渡节奏分离,图标动画即使被全局 is-theme-transitioning 影响也保持独立观感。

过渡抑制:为什么需要 theme-switch-btn 专属样式

按钮样式块中有一条带 !important 的覆盖:

css
1/* 确保主题切换按钮的背景色即时更新 */ 2.theme-switch-btn::before { 3 transition: 4 transform 75ms ease-out, 5 background-color 0ms !important; 6}

Source: ThemeSwitch.svelte

btn-plain 通用样式会给伪元素背景色加过渡;切到暗色时若背景色也走 300ms 渐变,按钮会短暂呈现"半亮半暗"的脏色。这里用 background-color 0ms !important 把背景色钉为即时更新,只保留 75ms 的形变过渡——这是"全局过渡"与"局部即时"混合策略的典型取舍:大面积色块适合渐变,小控件背景适合立即收敛。

响应式降级链

本能力的"响应式"体现在三个层面,且各有独立的降级路径:

层面机制检测方式降级行为
用户偏好减少动态View Transition 关闭matchMedia("(prefers-reduced-motion: reduce)")改用一帧 is-theme-transitioning 窗口,无整页动画
浏览器能力缺失document.startViewTransition 为 undefined属性存在性检查同上,且不添加 use-view-transition 类
视口无关的明暗适配dark 类 + Tailwind dark 变体documentElement 类切换所有消费 dark: 前缀的组件同步换肤

值得强调的是:Mizuki 的暗色模式不依赖 prefers-color-scheme 自动翻转(读取 localStorage.theme 失败即回落 DEFAULT_THEME),系统偏好只在 README 特性列表中被描述为"检测",实际实现以用户显式选择 + 站点默认值为准。这样避免了"系统翻转导致页面突然变暗"的不可预期行为,代价是需要用户手动切换一次。

Source: README.en.md

Configuration Options

选项类型默认值说明
siteConfig.themeColor.huenumber由配置文件给出(示例 210)构建期默认主题色相,0–360;写入 #config-carrier.dataset.hue 供客户端读取
siteConfig.themeColor.fixedbooleanfalse为 true 时隐藏访客取色器,getHue() 恒返回构建期默认值
localStorage.themeLIGHT_DARK_MODE无(回落 DEFAULT_THEME)用户上次选择的明暗模式
localStorage.huenumber(字符串形式)无(回落 dataset.hue)访客自定义色相;fixed 模式下被忽略
DEFAULT_THEMELIGHT_DARK_MODE常量(@constants/constants)无任何存储时的最终主题回落
CSS 变量 --huenumber250(代码内硬编码回落)写入 :root 内联样式,供 HSL 派生色消费
data-theme 属性"github-light" | "github-dark"跟随明暗状态驱动 Expressive Code 代码块主题
Swup theme 选项booleanfalse(astro.config.mjs 显式关闭)禁用 Swup 内置主题处理,交由本能力接管

API Reference

以下方法均来自 setting-utils.ts,以及 ThemeSwitch.svelte 的组件内部方法。

setTheme(theme: LIGHT_DARK_MODE): void

持久化明暗模式并应用到文档。

Parameters:

  • theme(LIGHT_DARK_MODE):目标模式,LIGHT_MODE 或 DARK_MODE

Returns: 无返回值。

行为: 写入 localStorage.theme → 调用 applyThemeToDocument(theme);幂等,无变更时后者直接短路。

getStoredTheme(): LIGHT_DARK_MODE

读取当前生效的明暗模式。

Returns: localStorage.theme 的值;不存在或为空时返回 DEFAULT_THEME。

applyThemeToDocument(theme: LIGHT_DARK_MODE): void

将明暗状态与代码块主题同步到 documentElement。

Parameters:

  • theme(LIGHT_DARK_MODE):目标模式;未知值时保持当前明暗状态不变

Returns: 无返回值。

副作用与异常路径:

  • 明暗或代码主题任一需要变更时才执行写入,否则立即返回
  • 支持且允许动画时:添加 is-theme-transitioning + use-view-transition,经 startViewTransition 执行变更,finished 的 resolve/reject 两路都会清理类
  • 不支持或不允许动画时:requestAnimationFrame 下一帧清理 is-theme-transitioning
  • 不显式抛出异常;transition.finished 的拒绝由 .catch 内部消化

getDefaultHue(): number

解析构建期注入的默认色相。

Returns: #config-carrier.dataset.hue 的十进制整数;元素缺失或值为空时返回 250。

getHue(): number

读取当前生效的主题色相。

Returns: themeColor.fixed 为真时恒返回构建期默认值;否则优先 localStorage.hue,最终回落默认值。

setHue(hue: number): void

持久化并应用自定义色相。

Parameters:

  • hue(number):0–360 色相值

Returns: 无返回值。

行为: 写入 localStorage.hue → 对 :root 设置 --hue 内联 CSS 变量;:root 查询失败时静默返回。

toggleScheme()(组件内部)

点击处理器:在 seq 序列中定位当前模式并切换到下一个;isChanging 为真时直接忽略。无参数、无返回值。

switchScheme(newMode: LIGHT_DARK_MODE)(组件内部)

应用指定模式并启动 50ms 防抖窗口;isChanging 为真时忽略。无返回值。

Failure Modes, Edge Cases & Concurrency

  • 连击竞态:50ms isChanging 窗口内重复点击被丢弃,而非排队执行——选择"丢帧"而非"排队"是为了避免过渡动画与存储写入在快速连击下互相打架。
  • View Transition 中断:transition.finished 被拒(再次切换、标签页隐藏等)时由 .catch 清理过渡类;由于每次切换都会先重新计算差异,中断后再次调用依然能收敛到正确状态(幂等性兜底)。
  • Swup 晚于组件初始化:swup:enable 一次性事件兜底注册钩子;{ once: true } 防止重复触发,swupHooked 双保险。
  • 多入口写主题:设置面板等其他组件同样调用 setTheme;documentElement 类是唯一事实源,content:replace 后 getStoredTheme() 对账保证按钮图标不会与实际主题脱节。
  • localStorage 不可用/被清空:getStoredTheme() 与 getHue() 均有字面量回落(DEFAULT_THEME / 250),运行时不会抛错;代价是回到站点默认外观。
  • #config-carrier 缺失:getDefaultHue() 与 getConfigDefault() 均做空值短路,返回内置回落值。
  • 明暗与代码主题不一致的脏状态:applyThemeToDocument 的双差异判断可只修缺失项,无需整页重刷。
  • 未知 theme 值:switch 的 default 分支保持当前明暗状态,防御性收敛而非崩溃。

Performance & Operational Notes

  • DOM 写入最小化:差异计算 + 提前 return 保证重复 setTheme(同值) 零 DOM 开销。
  • View Transition 的成本模型:startViewTransition 需要对页面做快照,适合"低频、大视觉变化"的主题切换场景;这正是按钮采用 50ms 防抖而非更长节流的原因——既限制快照频率,又不影响手感。
  • is-theme-transitioning 的一帧生命周期:降级路径中该类只存活一帧,用于抑制无关过渡而非承载动画本身,避免长生命周期类带来的样式泄漏。
  • queueMicrotask 延后清理:把类移除排到微任务,确保在过渡回调链收尾之后执行,降低中途移除导致样式跳变的概率。
  • 无 SSR 主题闪烁问题的来源:明暗状态持久化于 localStorage,DOM 事实源在客户端初始化时同步;配合 Swup theme: false 禁用其内置主题切换,避免双重接管。

Extension Points

  • 新增第三态(如"跟随系统"):向 ThemeSwitch.svelte 的 seq 数组追加元素即可获得循环切换语义;再在 applyThemeToDocument 的 switch 中为新值定义 targetIsDark(例如读取 matchMedia("(prefers-color-scheme: dark)"))。现有取模逻辑无需改动。
  • 新增代码主题:expectedTheme 的三元表达式与 astro.config.mjs 的 themes: [expressiveCodeConfig.lightTheme, expressiveCodeConfig.darkTheme] 是联动点,替换主题名需两处同步。
  • 新增持久化偏好:参照 getHue()/setHue() 的四层回落模式(配置锁定 → 本地存储 → #config-carrier dataset → 硬编码回落)即可获得同等健壮性;同文件中的壁纸/遮罩系列(getStoredWallpaperMode、setOverlayOpacity 等)复用了同一范式。
  • 复用切换按钮:ThemeSwitch.svelte 无 prop、无外部依赖副作用,可作为独立控件嵌入任意布局;其 Swup 对账逻辑已内建。

Sources

(2 files)
src/components/control