第十七章:自定义 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