refactor(hooks): 新增 use_event_listener 通用事件监听 hook

把 theme.rs 与 footer.rs 两处手写的 "use_hook 持有 Closure+target、
use_effect 注册 add_event_listener、use_drop 移除" 样板收口到一个
通用 hook,按 Dioxus 0.7 custom hooks 范式封装。

hook 设计:
- target 通过 acquire 闭包在 use_effect 首次运行时获取(此时 DOM 一定
  可用),调用方无需再自己包 use_effect。
- 泛型 AsRef<EventTarget> 统一 Window / Element / MediaQueryList。
- 非 wasm32 整体编译为 noop,调用方用 #[cfg] 隔离 web_sys 调用即可。

footer 的 "注册后首次同步" 业务逻辑保留在 hook 之外(语义不属于
事件监听)。theme 的 handler 改为重新 match_media 读取 matches,
功能等价(低频事件,开销可忽略)。
This commit is contained in:
xfy 2026-07-02 16:02:51 +08:00
parent 5a00ef31f3
commit f00c2bf963
4 changed files with 131 additions and 92 deletions

View File

@ -3,9 +3,9 @@
//! 提供站点版权信息,并在用户向下滚动超过一屏后显示"回到顶部"悬浮按钮。 //! 提供站点版权信息,并在用户向下滚动超过一屏后显示"回到顶部"悬浮按钮。
//! 回到顶部的滚动监听与平滑滚动逻辑仅在 WASM 前端生效。 //! 回到顶部的滚动监听与平滑滚动逻辑仅在 WASM 前端生效。
#[cfg(target_arch = "wasm32")]
use crate::hooks::event_listener::use_event_listener;
use dioxus::prelude::*; use dioxus::prelude::*;
use std::cell::RefCell;
use std::rc::Rc;
/// 页脚与回到顶部按钮组件。 /// 页脚与回到顶部按钮组件。
/// ///
@ -19,67 +19,38 @@ use std::rc::Rc;
pub fn Footer() -> Element { pub fn Footer() -> Element {
let mut visible = use_signal(|| false); let mut visible = use_signal(|| false);
// WASM 下保存 scroll 事件闭包与 window用于后续清理 // 根据 window 当前滚动位置同步 visible注册监听后立即调用一次避免首屏漏判
#[cfg(target_arch = "wasm32")] // 滚动事件回调里也复用同一份判断逻辑。
let listener_state = use_hook(|| { let sync_visible = move || {
Rc::new(RefCell::new(
None::<(wasm_bindgen::prelude::Closure<dyn FnMut()>, web_sys::Window)>,
))
});
// 非 WASM 下保持类型一致,避免编译错误
#[cfg(not(target_arch = "wasm32"))]
let _listener_state = use_hook(|| Rc::new(RefCell::new(None::<()>)));
#[cfg(target_arch = "wasm32")]
let listener_state_for_effect = listener_state.clone();
// 挂载时注册 scroll 监听器,并根据当前滚动位置初始化按钮可见性
use_effect(move || {
#[cfg(target_arch = "wasm32")] #[cfg(target_arch = "wasm32")]
{ {
if let Some(window) = web_sys::window() { if let Some(w) = web_sys::window() {
let closure = wasm_bindgen::prelude::Closure::wrap(Box::new(move || { let threshold = w
if let Some(w) = web_sys::window() {
let threshold = w
.inner_height()
.ok()
.and_then(|h| h.as_f64())
.unwrap_or(0.0);
let scroll_y = w.scroll_y().unwrap_or(0.0);
let new_visible = scroll_y > threshold;
visible.set(new_visible);
}
})
as Box<dyn FnMut()>);
let _ = window.add_event_listener_with_callback(
"scroll",
wasm_bindgen::JsCast::unchecked_ref(closure.as_ref()),
);
let threshold = window
.inner_height() .inner_height()
.ok() .ok()
.and_then(|h| h.as_f64()) .and_then(|h| h.as_f64())
.unwrap_or(0.0); .unwrap_or(0.0);
let scroll_y = window.scroll_y().unwrap_or(0.0); let scroll_y = w.scroll_y().unwrap_or(0.0);
visible.set(scroll_y > threshold); visible.set(scroll_y > threshold);
*listener_state_for_effect.borrow_mut() = Some((closure, window));
} }
} }
}); };
// 卸载时移除 scroll 监听器,防止内存泄漏 // 注册 scroll 监听:注册 / 卸载清理由 use_event_listener 负责。
// 仅 WASM 端调用server 端 use_event_listener 是 noop但 acquire 闭包内的
// web_sys 在非 wasm 下不可解析,故整块 cfghook 数量在 server build 中不影响,
// 因为 server 端该组件只跑一次 SSR
#[cfg(target_arch = "wasm32")] #[cfg(target_arch = "wasm32")]
use_drop(move || { use_event_listener(
if let Some((closure, window)) = listener_state.borrow_mut().take() { || web_sys::window(),
let _ = window.remove_event_listener_with_callback( "scroll",
"scroll", // 滚动事件触发时复用同样的阈值判断。
wasm_bindgen::JsCast::unchecked_ref(closure.as_ref()), move || sync_visible(),
); );
}
// 挂载时根据当前滚动位置初始化一次按钮可见性。
use_effect(move || {
sync_visible();
}); });
// 根据 visible 动态切换按钮显示/隐藏样式 // 根据 visible 动态切换按钮显示/隐藏样式

View File

@ -0,0 +1,84 @@
//! 通用 DOM 事件监听 hook封装 add/remove_event_listener 生命周期。
//!
//! 用 `use_hook` 持有 `(Closure, target)``use_effect` 注册,`use_drop` 清理,
//! 把 theme/footer 两处手写的样板收口到一处。非 wasm32 目标下整体编译为空操作
//! SSR 无 DOM调用方无需再写占位变量或 `#[cfg]`。
//!
//! 设计要点:
//! - `target` 通过一个初始化闭包 `acquire` 提供,在 `use_effect` 首次运行时调用
//! (此时 DOM 一定可用);这样调用方无需自己再包一层 `use_effect`。
//! - `event` 要求 `&'static str`add 与 remove 复用同一字符串字面量。
//! - handler 是 `FnMut()`(无参);若调用方需要 target 信息(如 `media.matches()`
//! 应在 `acquire` 里 clone 一份后 move 进 handler。
/// 注册一次性事件监听器,组件卸载时自动移除。
///
/// - `acquire`:在 `use_effect` 首次运行DOM 已就绪)时调用,返回要监听的 target
/// 及其克隆handler 内若要读 target用这个克隆。返回 `None` 则不注册。
/// - `event`:事件名(如 `"scroll"` / `"change"`),需为 `&'static str`。
/// - `handler`:事件触发时的回调,`FnMut()`(无参)。
///
/// 仅在 wasm32 执行真实注册;非 wasm 为 noop。
///
/// # 例
///
/// ```ignore
/// use_event_listener(
/// || web_sys::window().unwrap(),
/// "scroll",
/// move || { visible.set(/* ... */); },
/// );
/// ```
#[cfg(target_arch = "wasm32")]
pub fn use_event_listener<T, A, F>(acquire: A, event: &'static str, mut handler: F)
where
T: AsRef<web_sys::EventTarget> + Clone + 'static,
A: FnOnce() -> Option<T>,
F: FnMut() + 'static,
{
use std::cell::RefCell;
use std::rc::Rc;
// 用 use_hook 持有 (Closure, target),在整个组件生命周期内复用;
// use_drop 时 take 出来移除监听,防止泄漏。
let state: Rc<RefCell<Option<(wasm_bindgen::prelude::Closure<dyn FnMut()>, T)>>> =
use_hook(|| Rc::new(RefCell::new(None)));
let state_for_drop = state.clone();
use_effect(move || {
let Some(target) = acquire() else { return };
let target_clone = target.clone();
let closure = wasm_bindgen::prelude::Closure::wrap(Box::new(move || {
handler();
})
as Box<dyn FnMut()>);
let _ = target_clone.as_ref().add_event_listener_with_callback(
event,
wasm_bindgen::JsCast::unchecked_ref(closure.as_ref()),
);
*state.borrow_mut() = Some((closure, target_clone));
});
use_drop(move || {
if let Some((closure, tgt)) = state_for_drop.borrow_mut().take() {
let _ = tgt.as_ref().remove_event_listener_with_callback(
event,
wasm_bindgen::JsCast::unchecked_ref(closure.as_ref()),
);
}
});
}
/// 非 wasm 占位SSR 无 DOM编译为空操作。
///
/// 保留与 wasm 版相同的泛型形态,调用方代码两端完全一致。
#[cfg(not(target_arch = "wasm32"))]
#[allow(unused_variables, dead_code)]
pub fn use_event_listener<T, A, F>(acquire: A, event: &'static str, handler: F)
where
T: Clone + 'static,
A: FnOnce() -> Option<T>,
F: FnMut() + 'static,
{
// SSR 无 DOM空实现。
}

View File

@ -3,9 +3,13 @@
//! 该模块集中管理可在组件树中复用的自定义 Hook包括 //! 该模块集中管理可在组件树中复用的自定义 Hook包括
//! - 评论草稿在浏览器 localStorage 中的持久化WASM 端) //! - 评论草稿在浏览器 localStorage 中的持久化WASM 端)
//! - 骨架屏延迟加载状态 //! - 骨架屏延迟加载状态
//! - 通用 DOM 事件监听(注册 + 自动卸载清理)
/// 评论草稿持久化 Hook基于浏览器的 localStorage仅在 WASM 端有效)。 /// 评论草稿持久化 Hook基于浏览器的 localStorage仅在 WASM 端有效)。
pub mod comment_storage; pub mod comment_storage;
/// 骨架屏延迟加载状态 Hook。 /// 延迟加载状态 Hook。
pub mod delayed_loading; pub mod delayed_loading;
/// 通用 DOM 事件监听 Hookadd/remove_event_listener 生命周期封装)。
pub mod event_listener;

View File

@ -196,48 +196,28 @@ pub fn use_theme_provider() -> Signal<Theme> {
// WASM 端监听系统颜色偏好变化(仅 System 模式有意义,但无论何种模式都更新 // WASM 端监听系统颜色偏好变化(仅 System 模式有意义,但无论何种模式都更新
// system_dark signalresolved memo 会决定是否真正改变 ResolvedTheme // system_dark signalresolved memo 会决定是否真正改变 ResolvedTheme
// 用 use_hook 持有 Closure + MediaQueryListuse_drop 时清理,防止泄漏。 // 注册 / 卸载清理由 use_event_listener 统一负责target 在其内部 use_effect
// 首次运行时通过 acquire 闭包获取(此时 DOM 一定可用)。
#[cfg(target_arch = "wasm32")] #[cfg(target_arch = "wasm32")]
{ {
use std::cell::RefCell; use crate::hooks::event_listener::use_event_listener;
use std::rc::Rc;
let listener_state = use_hook(|| { use_event_listener(
Rc::new(RefCell::new(None::<( || {
wasm_bindgen::prelude::Closure<dyn FnMut()>, let window = web_sys::window()?;
web_sys::MediaQueryList, window.match_media("(prefers-color-scheme: dark)").ok().flatten()
)>)) },
}); "change",
let listener_state_for_effect = listener_state.clone(); move || {
// handler 需要重新读取当前 matches 值MediaQueryList 的事件回调
use_effect(move || { // 不带参,只能自行重新查询)。
let Some(window) = web_sys::window() else { if let Some(window) = web_sys::window() {
return; if let Ok(Some(media)) = window.match_media("(prefers-color-scheme: dark)") {
}; system_dark.set(media.matches());
let Ok(Some(media)) = window.match_media("(prefers-color-scheme: dark)") else { }
return; }
}; },
// 初始化时同步一次read_system_dark 已设过初值,这里保持一致即可)。 );
let media_for_closure = media.clone();
let closure = wasm_bindgen::prelude::Closure::wrap(Box::new(move || {
system_dark.set(media_for_closure.matches());
})
as Box<dyn FnMut()>);
let _ = media.add_event_listener_with_callback(
"change",
wasm_bindgen::JsCast::unchecked_ref(closure.as_ref()),
);
*listener_state_for_effect.borrow_mut() = Some((closure, media));
});
use_drop(move || {
if let Some((closure, media)) = listener_state.borrow_mut().take() {
let _ = media.remove_event_listener_with_callback(
"change",
wasm_bindgen::JsCast::unchecked_ref(closure.as_ref()),
);
}
});
} }
use_context_provider(|| theme); use_context_provider(|| theme);