第十章:暗夜模式与主题系统

博客v1.0系列教程(Dioxus)博客 v1.0 系列教程 (Dioxus)

第十章:暗夜模式与主题系统

1. 主题系统设计

用户操作切换 or 系统偏好
        │
        ▼
  主题状态 (Signal)
        │
        ├──> 更新 HTML data-theme 属性
        │        │
        │        ▼
        │    CSS 变量自动切换
        │        │
        │        ▼
        │    全局样式更新
        │
        └──> 保存到 localStorage
                 │
                 ▼
           下次访问自动恢复

2. CSS 变量定义

2.1 亮色与暗色变量

/* main.css */
:root {
    /* 亮色主题 (Catppuccin Latte) */
    --bg: #eff1f5;
    --card: #e6e9ef;
    --border: #ccd0da;
    --text: #4c4f69;
    --secondary: #5c5f77;
    --tertiary: #9ca0b0;
    --primary: #1e66f5;
    --primary-light: #d6e4fb;
    --hover: #ccd0da;
}

[data-theme="dark"] {
    /* 暗色主题 (Catppuccin Mocha) */
    --bg: #1e1e2e;
    --card: #181825;
    --border: #313244;
    --text: #cdd6f4;
    --secondary: #bac2de;
    --tertiary: #6c7086;
    --primary: #89b4fa;
    --primary-light: #2a4a7a;
    --hover: #313244;
}

2.2 在组件中使用

rsx! {
    // 组件只引用变量,不关心具体色值
    div {
        class: "min-h-screen flex flex-col",
        style: "background: var(--bg); color: var(--text);",

        main {
            div {
                class: "rounded-lg border p-5",
                style: "background: var(--card); border-color: var(--border);",
                h3 { "标题" }
                p { style: "color: var(--tertiary);", "次要文本" }
            }
        }
    }
}

3. 主题切换实现

3.1 主题 Context

use dioxus::prelude::*;

#[component]
fn ThemeProvider(children: Element) -> Element {
    // 从 localStorage 读取或使用系统偏好
    let theme = use_signal(|| {
        let saved = get_local_storage("blog-theme");
        saved.unwrap_or_else(detect_system_theme)
    });

    // 暴露给子组件
    use_context_provider(|| theme);

    // 主题变化时同步到 HTML
    use_effect(move || {
        set_html_attr("data-theme", &theme());
    });

    rsx! { {children} }
}

fn detect_system_theme() -> String {
    // 通过 JS 检测系统偏好
    web_sys::window()
        .and_then(|w| w.match_media("(prefers-color-scheme: dark)").ok())
        .flatten()
        .map(|mq| if mq.matches() { "dark".to_string() } else { "light".to_string() })
        .unwrap_or_else(|| "light".to_string())
}

fn get_local_storage(key: &str) -> Option<String> {
    web_sys::window()?
        .local_storage().ok()??
        .get_item(key).ok()?
}

fn set_html_attr(attr: &str, value: &str) {
    if let Some(doc) = web_sys::window().and_then(|w| w.document()) {
        let _ = doc.document_element()
            .map(|el| el.set_attribute(attr, value));
    }
}

3.2 切换按钮

#[component]
fn ThemeToggle() -> Element {
    let mut theme = use_context::<Signal<String>>();

    let toggle = move |_| {
        let next = if theme() == "light" {
            "dark".to_string()
        } else {
            "light".to_string()
        };
        theme.set(next);
        // 持久化到 localStorage
        set_local_storage("blog-theme", &next);
    };

    rsx! {
        button {
            class: "theme-toggle w-[34px] h-[34px] flex items-center justify-center
                    rounded-lg cursor-pointer text-lg transition-all hover:scale-110",
            style: "background: transparent; border: none; color: var(--secondary);",
            onclick: toggle,
            span {
                // 显示对应的图标
                if theme() == "light" { "🌙" } else { "☀️" }
            }
        }
    }
}

fn set_local_storage(key: &str, value: &str) {
    if let Some(window) = web_sys::window() {
        let _ = window.local_storage().ok()
            .flatten()
            .map(|s| s.set_item(key, value));
    }
}

4. 在 App 中使用

#[component]
fn App() -> Element {
    rsx! {
        // 在根组件包裹 ThemeProvider
        ThemeProvider {
            div { class: "min-h-screen",
                style: "background: var(--bg); color: var(--text);",
                Router::<Route> {}
            }
        }
    }
}

5. 防止闪白(FOUC)

在页面加载时,如果主题尚未应用,用户可能会看到一闪的白色背景(Flash of Unstyled Content)。

解决方案:在 index.html<head> 中添加内联脚本:

// 在 <head> 中立即执行,阻塞渲染
(function() {
    var theme = localStorage.getItem('blog-theme');
    if (theme === 'dark' ||
        (!theme && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
        document.documentElement.setAttribute('data-theme', 'dark');
    }
})();
<!DOCTYPE html>
<html>
<head>
    <script>
    (function() {
        var t = localStorage.getItem('blog-theme');
        if (t === 'dark' || (!t && window.matchMedia('(prefers-color-scheme:dark)').matches)) {
            document.documentElement.setAttribute('data-theme', 'dark');
        }
    })();
    </script>
    <!-- 其他样式和脚本 -->
</head>

6. 主题过渡动画

/* 平滑的主题切换过渡 */
html {
    transition: background-color 0.3s, color 0.3s;
}

*, *::before, *::after {
    transition: background-color 0.3s, border-color 0.3s, color 0.3s;
}

/* 如果用户减少了动画效果,则不使用过渡 */
@media (prefers-reduced-motion: reduce) {
    *, *::before, *::after {
        transition: none !important;
    }
}

7. 扩展:多种主题

/* 不仅可以亮/暗,还可以扩展更多主题 */
[data-theme="sepia"] {
    --bg: #fbf1c7;
    --card: #f2e5bc;
    --border: #d5c4a1;
    --text: #3c3836;
    --primary: #d3869b;
}

[data-theme="forest"] {
    --bg: #1b2b1b;
    --card: #243824;
    --border: #3a5a3a;
    --text: #d4e7d4;
    --primary: #7ec87e;
}

8. 图片适配

/* 暗夜模式下调暗图片 */
[data-theme="dark"] img {
    filter: brightness(0.8) contrast(1.1);
    transition: filter 0.3s;
}

/* 或使用 picture 元素提供两套图 */
picture {
    source {
        media: "(prefers-color-scheme: dark)",
        srcset: "{dark_image}",
    }
    img { src: "{light_image}" }
}

9. 主题适配检查清单

  • [ ] 所有 CSS 使用 var(--xxx) 而非硬编码色值
  • [ ] HTML data-theme 属性正确设置
  • [ ] 初始加载无闪白(内联脚本)
  • [ ] 切换动画平滑
  • [ ] 用户偏好持久化到 localStorage
  • [ ] 图片暗夜模式下调暗
  • [ ] 代码块背景色适配
  • [ ] 尊重 prefers-reduced-motion
  • [ ] 第三方组件(如图表库)适配

10. 小结

  • CSS 变量实现了亮/暗主题的声明式管理
  • use_context_provider 将主题状态全局共享
  • use_effect 同步主题到 HTML 和 localStorage
  • 内联脚本防止首次加载闪白
  • 过渡动画提供平滑的切换体验
  • 变量体系可扩展至多主题(如 Sepia、Forest 等)
  • 至此你已完成前十章基础学习,掌握了 Dioxus 开发的核心技能。接下来的章节将深入进阶专题。
dioxusthemedark-modecss-variablespersistent