主题切换与响应式样式
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
主题切换解决三个问题:
- 持久化:用户的选择必须跨会话保留。实现上使用
localStorage的theme键存储明暗模式、hue键存储自定义色相。 - 无闪烁应用:切换时不仅要改
dark类,还要同步代码块主题(data-theme指向github-light/github-dark),否则代码区与页面会出现明暗不一致。 - 可感知但不刺眼的过渡:直接切换会造成颜色跳变,因此优先使用
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-carrier | DOM 元素 | 构建期配置(如默认 hue)注入客户端的载体 |
is-theme-transitioning | documentElement 类 | 切换期间抑制无关 CSS 过渡 |
use-view-transition | documentElement 类 | 标记当前处于 View Transition 渲染路径 |
Architecture
架构说明:
- 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
核心控制流:一次点击如何完成主题切换
第一步:入口与防抖
ThemeSwitch.svelte 以 seq: LIGHT_DARK_MODE[] = [LIGHT_MODE, DARK_MODE] 定义循环顺序。点击触发 toggleScheme():
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 抖动:
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 极薄,只做两件事:
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 写入:
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
三个设计要点:
default分支保持现状:传入未知值时targetIsDark = currentIsDark,函数退化为"仅校对代码主题",这是防御式编码——脏数据不会把用户踢出当前模式。- 双重差异判断:
needsThemeChange(页面明暗)与needsCodeThemeUpdate(代码块主题)是两个独立信号。修复"页面已暗但代码块仍亮"这类不一致状态时,本函数依然能只更新缺失的那一项。 - 字符串字面量
"github-dark"/"github-light"与astro.config.mjs中注册的 Expressive Code 主题对应,data-theme属性变化后代码块样式随之切换。
第四步:双路径过渡(View Transition 与降级)
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 稳定后重新对账:
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:
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 中对应的配置面:
1themeColor: {
2 hue: 210, // 0–360
3 fixed: false, // Hide the visitor theme-color picker when true
4},Source: README.en.md
响应式样式与过渡表现
切换按钮的图标动画
按钮内叠加了两个绝对定位的图标(太阳 / 月亮),通过 Svelte 的 class: 指令按当前模式控制透明度与旋转:
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 的覆盖:
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.hue | number | 由配置文件给出(示例 210) | 构建期默认主题色相,0–360;写入 #config-carrier.dataset.hue 供客户端读取 |
siteConfig.themeColor.fixed | boolean | false | 为 true 时隐藏访客取色器,getHue() 恒返回构建期默认值 |
localStorage.theme | LIGHT_DARK_MODE | 无(回落 DEFAULT_THEME) | 用户上次选择的明暗模式 |
localStorage.hue | number(字符串形式) | 无(回落 dataset.hue) | 访客自定义色相;fixed 模式下被忽略 |
DEFAULT_THEME | LIGHT_DARK_MODE | 常量(@constants/constants) | 无任何存储时的最终主题回落 |
CSS 变量 --hue | number | 250(代码内硬编码回落) | 写入 :root 内联样式,供 HSL 派生色消费 |
data-theme 属性 | "github-light" | "github-dark" | 跟随明暗状态 | 驱动 Expressive Code 代码块主题 |
Swup theme 选项 | boolean | false(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 事实源在客户端初始化时同步;配合 Swuptheme: 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-carrierdataset → 硬编码回落)即可获得同等健壮性;同文件中的壁纸/遮罩系列(getStoredWallpaperMode、setOverlayOpacity等)复用了同一范式。 - 复用切换按钮:
ThemeSwitch.svelte无 prop、无外部依赖副作用,可作为独立控件嵌入任意布局;其 Swup 对账逻辑已内建。
Related Links
- ThemeSwitch.svelte — 切换控件本体与图标动画
- setting-utils.ts — 主题/色相/壁纸设置的全部运行时工具函数
- astro.config.mjs — Swup
theme: false与 Expressive Code 双主题注册 - README.en.md —
themeColor配置说明(hue / fixed)