第十七章:自定义 Hooks 与组合式抽象

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

第十七章:自定义 Hooks 与组合式抽象

1. 什么是自定义 Hook

Hook 是以 use_ 开头的函数,它可以调用 Dioxus 内置的 Signal、use_memo、use_effect 等。自定义 Hook 让你把组件中的状态逻辑提取出来,形成可复用的函数。

// 内置 Hook
let count = use_signal(|| 0);

// 自定义 Hook —— 封装了计数器的完整逻辑
fn use_counter(initial: i32) -> (Signal<i32>, impl Fn(), impl Fn()) {
    let count = use_signal(|| initial);
    let increment = move || count += 1;
    let decrement = move || count -= 1;
    (count, increment, decrement)
}

// 使用
#[component]
fn Counter() -> Element {
    let (count, inc, dec) = use_counter(0);
    rsx! {
        button { onclick: move |_| dec(), "-" }
        span { "{count}" }
        button { onclick: move |_| inc(), "+" }
    }
}

命名规范

  • 函数名以 use_ 开头
  • 可以调用其他 Hook(包括内置和自定义)
  • 每次组件渲染时按相同顺序调用
  • 不要在条件或循环中调用 Hook

2. 常见自定义 Hook 模式

2.1 use_local_storage —— 持久化状态

fn use_local_storage<T>(key: &str, default: T) -> Signal<T>
where
    T: Clone + PartialEq + serde::Serialize + serde::de::DeserializeOwned + 'static,
{
    let state = use_signal(|| {
        // 尝试从 localStorage 读取
        if let Some(stored) = get_local_storage(key) {
            serde_json::from_str(&stored).unwrap_or(default.clone())
        } else {
            default
        }
    });

    let key = key.to_string();
    // 状态变化时自动写回 localStorage
    use_effect(move || {
        if let Ok(json) = serde_json::to_string(&*state.read()) {
            set_local_storage(&key, &json);
        }
    });

    state
}

// 使用
#[component]
fn Settings() -> Element {
    let theme = use_local_storage("theme", "light".to_string());
    let font_size = use_local_storage("font-size", 16u32);

    rsx! {
        select {
            value: "{theme}",
            oninput: move |e| theme.set(e.value()),
            option { value: "light", "浅色" }
            option { value: "dark", "深色" }
        }
    }
}

2.2 use_debounce —— 防抖

fn use_debounce<T: Clone + PartialEq + 'static>(
    source: impl Fn() -> T + 'static,
    delay_ms: u64,
) -> Signal<T> {
    let debounced = use_signal(|| source());

    use_effect(move || {
        let value = source();
        spawn(async move {
            tokio::time::sleep(std::time::Duration::from_millis(delay_ms)).await;
            debounced.set(value);
        });
    });

    debounced
}

// 使用:搜索框防抖
#[component]
fn Search() -> Element {
    let query = use_signal(String::new);
    let debounced_query = use_debounce(move || query(), 300);

    // 当 debounced_query 变化时发起搜索
    let results = use_resource(move || async move {
        let q = debounced_query();
        if q.is_empty() { return Vec::new(); }
        search_api(&q).await.unwrap_or_default()
    });

    rsx! {
        input {
            value: "{query}",
            oninput: move |e| query.set(e.value()),
            placeholder: "搜索...",
        }
        // 显示结果(只有防抖后的值变化才更新)
        for article in results().unwrap_or_default() {
            div { "{article.title}" }
        }
    }
}

2.3 use_interval —— 定时器

fn use_interval<F>(ms: u64, callback: F)
where
    F: Fn() + 'static,
{
    use_effect(move || {
        spawn(async move {
            loop {
                tokio::time::sleep(std::time::Duration::from_millis(ms)).await;
                callback();
            }
        });
    });
}

// 使用
#[component]
fn Clock() -> Element {
    let time = use_signal(|| chrono::Local::now().format("%H:%M:%S").to_string());

    use_interval(1000, move || {
        time.set(chrono::Local::now().format("%H:%M:%S").to_string());
    });

    rsx! { div { class: "font-mono text-2xl", "{time}" } }
}

2.4 use_media_query —— 响应式检测

fn use_media_query(query: &str) -> Signal<bool> {
    let matches = use_signal(|| false);
    let query = query.to_string();

    use_effect(move || {
        // 通过 web-sys 创建 matchMedia 监听
        // 当媒体查询结果变化时更新 matches
    });

    matches
}

// 使用
#[component]
fn ResponsiveSidebar() -> Element {
    let is_large = use_media_query("(min-width: 1024px)");
    let sidebar_open = use_signal(|| false);

    rsx! {
        // 大屏:始终显示侧边栏
        // 小屏:通过按钮控制显示
        if is_large() || sidebar_open() {
            aside { class: "w-60", "侧边栏" }
        }
    }
}

3. 组合式抽象

3.1 表单 Hook 族

// use_field —— 单个表单字段
#[derive(Clone)]
struct Field {
    value: String,
    error: Option<String>,
    touched: bool,
}

fn use_field(validators: Vec<Box<dyn Fn(&str) -> Option<String>>>) -> (
    Signal<Field>,
    impl Fn(FormEvent),
    impl Fn() -> bool,
) {
    let field = use_signal(|| Field {
        value: String::new(),
        error: None,
        touched: false,
    });

    let on_input = {
        let validators = validators;
        move |e: FormEvent| {
            let val = e.value();
            let error = validators.iter().find_map(|v| v(&val));
            field.set(Field {
                value: val,
                error,
                touched: true,
            });
        }
    };

    let validate = move || -> bool {
        let f = field.read();
        if f.value.is_empty() {
            return false;
        }
        f.error.is_none()
    };

    (field, on_input, validate)
}

// use_form —— 组合多个字段
fn use_form() -> (
    Vec<Signal<Field>>,
    impl Fn() -> bool,
    impl Fn() -> FormData,
) {
    // 组合多个 use_field
    // 略...
}

3.2 API 请求 Hook

#[derive(Clone)]
enum ApiState<T> {
    Idle,
    Loading,
    Success(T),
    Error(String),
}

impl<T> ApiState<T> {
    fn data(&self) -> Option<&T> {
        match self {
            ApiState::Success(d) => Some(d),
            _ => None,
        }
    }

    fn is_loading(&self) -> bool {
        matches!(self, ApiState::Loading)
    }
}

fn use_api<T>(url: impl Fn() -> String + 'static) -> (Signal<ApiState<T>>, impl Fn())
where
    T: Clone + PartialEq + serde::de::DeserializeOwned + 'static,
{
    let state = use_signal(|| ApiState::<T>::Idle);

    let fetch = move || {
        let url = url();
        state.set(ApiState::Loading);

        spawn(async move {
            match reqwest::get(&url).await {
                Ok(resp) => {
                    match resp.json::<T>().await {
                        Ok(data) => state.set(ApiState::Success(data)),
                        Err(e) => state.set(ApiState::Error(e.to_string())),
                    }
                }
                Err(e) => state.set(ApiState::Error(e.to_string())),
            }
        });
    };

    // 首次调用
    use_effect({
        let f = fetch.clone();
        move || f()
    });

    (state, fetch)
}

// 使用
#[component]
fn ArticleList() -> Element {
    let (articles, refresh) = use_api::<Vec<ArticleSummary>>(|| "/api/articles".into());

    rsx! {
        button { onclick: move |_| refresh(), "刷新" }
        match articles() {
            ApiState::Loading => rsx! { "加载中..." },
            ApiState::Success(list) => rsx! {
                for a in list { ArticleCard { summary: a } }
            },
            ApiState::Error(msg) => rsx! { "错误: {msg}" },
            _ => rsx! { div {} },
        }
    }
}

3.3 分页 Hook

fn use_pagination<T: Clone + 'static>(
    items: impl Fn() -> Vec<T> + 'static,
    page_size: usize,
) -> (Signal<Vec<T>>, Signal<usize>, usize, impl Fn(), impl Fn()) {
    let page = use_signal(|| 0usize);

    let page_items = use_memo(move || {
        let all = items();
        let total_pages = (all.len() + page_size - 1) / page_size;
        let p = page().min(total_pages.max(1) - 1);
        let start = p * page_size;
        all.iter().skip(start).take(page_size).cloned().collect::<Vec<_>>()
    });

    let total = use_memo(move || {
        let all = items();
        (all.len() + page_size - 1) / page_size
    });

    let next = move || {
        if page() + 1 < total() {
            page += 1;
        }
    };

    let prev = move || {
        if page() > 0 {
            page -= 1;
        }
    };

    (page_items, page, total(), next, prev)
}

4. 实战:完整的主题管理 Hook

#[derive(Clone, PartialEq)]
struct Theme {
    mode: String,        // "light" | "dark"
    primary: String,     // 主色
    font_size: u32,      // 基础字号
}

impl Default for Theme {
    fn default() -> Self {
        Self {
            mode: "light".to_string(),
            primary: "#1e66f5".to_string(),
            font_size: 16,
        }
    }
}

fn use_theme() -> (Signal<Theme>, impl Fn(), impl Fn(), impl Fn(String)) {
    // 初始化:从 localStorage 读取
    let theme = use_signal(|| {
        get_local_storage("blog-theme")
            .and_then(|v| serde_json::from_str::<Theme>(&v).ok())
            .unwrap_or_default()
    });

    // 自动持久化
    use_effect(move || {
        if let Ok(json) = serde_json::to_string(&*theme.read()) {
            set_local_storage("blog-theme", &json);
        }
    });

    // 同步 HTML 的 data-theme 属性
    use_effect(move || {
        let mode = theme.read().mode.clone();
        set_html_attr("data-theme", &mode);
    });

    let toggle_mode = move || {
        theme.write().mode = if theme.read().mode == "light" {
            "dark".to_string()
        } else {
            "light".to_string()
        };
    };

    let set_primary = move |color: String| {
        theme.write().primary = color;
    };

    let reset = move || {
        theme.set(Theme::default());
    };

    (theme, toggle_mode, reset, set_primary)
}

// 在 App 中使用
#[component]
fn App() -> Element {
    let (theme, toggle, reset, _) = use_theme();

    rsx! {
        div {
            style: "
                --primary: {theme.read().primary};
                font-size: {theme.read().font_size}px;
            ",
            button { onclick: move |_| toggle(), "切换主题" }
            button { onclick: move |_| reset(), "重置" }
            Router::<Route> {}
        }
    }
}

5. Hook 设计原则

5.1 单一职责

// ❌ 一个 Hook 做太多事
fn use_user_manager() -> (Signal<User>, Signal<bool>, Signal<String>, impl Fn()) {
    // 用户数据、加载状态、错误信息、刷新方法
    // 这四个应该拆成独立的 Hook
}

// ✅ 拆分为多个专一 Hook
fn use_user() -> Signal<Option<User>>;
fn use_loading() -> Signal<bool>;
fn use_error() -> Signal<Option<String>>;
fn use_refresh() -> impl Fn();

5.2 Hook 返回值的风格

// 风格一:元组(适合少量返回值)
fn use_counter() -> (Signal<i32>, impl Fn(), impl Fn());

// 风格二:具名结构体(适合多个返回值)
struct UseAuth {
    user: Signal<Option<User>>,
    login: Signal<UseAuthFn>,
    logout: Signal<UseAuthFn>,
    is_authenticated: Signal<bool>,
}
fn use_auth() -> UseAuth;

// 风格三:返回 Signal 让调用者自行派生
fn use_articles() -> Signal<Vec<ArticleSummary>>;
let articles = use_articles();
let recent = use_memo(move || {
    articles().iter().take(5).cloned().collect::<Vec<_>>()
});

6. Hook 组合示例

#[component]
fn Dashboard() -> Element {
    // 组合多个 Hook
    let articles = use_api::<Vec<ArticleSummary>>(|| "/api/articles".into());
    let (page, _, total, next, prev) = use_pagination(
        move || articles().data().cloned().unwrap_or_default(),
        10,
    );
    let search = use_debounce(move || query(), 300);

    rsx! {
        input {
            value: "{query}",
            oninput: move |e| query.set(e.value()),
        }
        div { class: "pagination",
            button { onclick: move |_| prev(), "←" }
            span { "{page() + 1} / {total}" }
            button { onclick: move |_| next(), "→" }
        }
    }
}

7. 小结

  • 自定义 Hook 是提取复用逻辑的基本单位,以 use_ 命名
  • 常见模式:持久化、防抖、定时器、媒体查询、表单、API 请求
  • 单一职责原则:一个 Hook 只做一件事
  • 通过组合多个 Hook 构建复杂功能,保持组件代码精简
  • Hook 返回值清晰命名,让调用者一目了然
  • 好的 Hook 设计是 Dioxus 应用架构的基础
dioxushookscompositionabstractionpatterns