From f00c2bf963f51d706ce545b8f1e250745c7954bc Mon Sep 17 00:00:00 2001 From: xfy Date: Thu, 2 Jul 2026 16:02:51 +0800 Subject: [PATCH] =?UTF-8?q?refactor(hooks):=20=E6=96=B0=E5=A2=9E=20use=5Fe?= =?UTF-8?q?vent=5Flistener=20=E9=80=9A=E7=94=A8=E4=BA=8B=E4=BB=B6=E7=9B=91?= =?UTF-8?q?=E5=90=AC=20hook?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 把 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 统一 Window / Element / MediaQueryList。 - 非 wasm32 整体编译为 noop,调用方用 #[cfg] 隔离 web_sys 调用即可。 footer 的 "注册后首次同步" 业务逻辑保留在 hook 之外(语义不属于 事件监听)。theme 的 handler 改为重新 match_media 读取 matches, 功能等价(低频事件,开销可忽略)。 --- src/components/footer.rs | 75 ++++++++++----------------------- src/hooks/event_listener.rs | 84 +++++++++++++++++++++++++++++++++++++ src/hooks/mod.rs | 6 ++- src/theme.rs | 58 +++++++++---------------- 4 files changed, 131 insertions(+), 92 deletions(-) create mode 100644 src/hooks/event_listener.rs diff --git a/src/components/footer.rs b/src/components/footer.rs index bbf8bca..1522ba6 100644 --- a/src/components/footer.rs +++ b/src/components/footer.rs @@ -3,9 +3,9 @@ //! 提供站点版权信息,并在用户向下滚动超过一屏后显示"回到顶部"悬浮按钮。 //! 回到顶部的滚动监听与平滑滚动逻辑仅在 WASM 前端生效。 +#[cfg(target_arch = "wasm32")] +use crate::hooks::event_listener::use_event_listener; use dioxus::prelude::*; -use std::cell::RefCell; -use std::rc::Rc; /// 页脚与回到顶部按钮组件。 /// @@ -19,67 +19,38 @@ use std::rc::Rc; pub fn Footer() -> Element { let mut visible = use_signal(|| false); - // WASM 下保存 scroll 事件闭包与 window,用于后续清理 - #[cfg(target_arch = "wasm32")] - let listener_state = use_hook(|| { - Rc::new(RefCell::new( - None::<(wasm_bindgen::prelude::Closure, 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 || { + // 根据 window 当前滚动位置同步 visible(注册监听后立即调用一次,避免首屏漏判)。 + // 滚动事件回调里也复用同一份判断逻辑。 + let sync_visible = move || { #[cfg(target_arch = "wasm32")] { - if let Some(window) = web_sys::window() { - let closure = wasm_bindgen::prelude::Closure::wrap(Box::new(move || { - 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); - - let _ = window.add_event_listener_with_callback( - "scroll", - wasm_bindgen::JsCast::unchecked_ref(closure.as_ref()), - ); - - let threshold = window + 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 = window.scroll_y().unwrap_or(0.0); + let scroll_y = w.scroll_y().unwrap_or(0.0); 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 下不可解析,故整块 cfg;hook 数量在 server build 中不影响, + // 因为 server 端该组件只跑一次 SSR)。 #[cfg(target_arch = "wasm32")] - use_drop(move || { - if let Some((closure, window)) = listener_state.borrow_mut().take() { - let _ = window.remove_event_listener_with_callback( - "scroll", - wasm_bindgen::JsCast::unchecked_ref(closure.as_ref()), - ); - } + use_event_listener( + || web_sys::window(), + "scroll", + // 滚动事件触发时复用同样的阈值判断。 + move || sync_visible(), + ); + + // 挂载时根据当前滚动位置初始化一次按钮可见性。 + use_effect(move || { + sync_visible(); }); // 根据 visible 动态切换按钮显示/隐藏样式 diff --git a/src/hooks/event_listener.rs b/src/hooks/event_listener.rs new file mode 100644 index 0000000..559fe1e --- /dev/null +++ b/src/hooks/event_listener.rs @@ -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(acquire: A, event: &'static str, mut handler: F) +where + T: AsRef + Clone + 'static, + A: FnOnce() -> Option, + F: FnMut() + 'static, +{ + use std::cell::RefCell; + use std::rc::Rc; + + // 用 use_hook 持有 (Closure, target),在整个组件生命周期内复用; + // use_drop 时 take 出来移除监听,防止泄漏。 + let state: Rc, 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); + 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(acquire: A, event: &'static str, handler: F) +where + T: Clone + 'static, + A: FnOnce() -> Option, + F: FnMut() + 'static, +{ + // SSR 无 DOM,空实现。 +} diff --git a/src/hooks/mod.rs b/src/hooks/mod.rs index 0af7a77..3793359 100644 --- a/src/hooks/mod.rs +++ b/src/hooks/mod.rs @@ -3,9 +3,13 @@ //! 该模块集中管理可在组件树中复用的自定义 Hook,包括: //! - 评论草稿在浏览器 localStorage 中的持久化(WASM 端) //! - 骨架屏延迟加载状态 +//! - 通用 DOM 事件监听(注册 + 自动卸载清理) /// 评论草稿持久化 Hook,基于浏览器的 localStorage(仅在 WASM 端有效)。 pub mod comment_storage; -/// 骨架屏延迟加载状态 Hook。 +/// 延迟加载状态 Hook。 pub mod delayed_loading; + +/// 通用 DOM 事件监听 Hook(add/remove_event_listener 生命周期封装)。 +pub mod event_listener; diff --git a/src/theme.rs b/src/theme.rs index 9b41267..69b6e3c 100644 --- a/src/theme.rs +++ b/src/theme.rs @@ -196,48 +196,28 @@ pub fn use_theme_provider() -> Signal { // WASM 端监听系统颜色偏好变化(仅 System 模式有意义,但无论何种模式都更新 // system_dark signal;resolved memo 会决定是否真正改变 ResolvedTheme)。 - // 用 use_hook 持有 Closure + MediaQueryList,use_drop 时清理,防止泄漏。 + // 注册 / 卸载清理由 use_event_listener 统一负责,target 在其内部 use_effect + // 首次运行时通过 acquire 闭包获取(此时 DOM 一定可用)。 #[cfg(target_arch = "wasm32")] { - use std::cell::RefCell; - use std::rc::Rc; + use crate::hooks::event_listener::use_event_listener; - let listener_state = use_hook(|| { - Rc::new(RefCell::new(None::<( - wasm_bindgen::prelude::Closure, - web_sys::MediaQueryList, - )>)) - }); - let listener_state_for_effect = listener_state.clone(); - - use_effect(move || { - let Some(window) = web_sys::window() else { - return; - }; - 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); - 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_event_listener( + || { + let window = web_sys::window()?; + window.match_media("(prefers-color-scheme: dark)").ok().flatten() + }, + "change", + move || { + // handler 需要重新读取当前 matches 值(MediaQueryList 的事件回调 + // 不带参,只能自行重新查询)。 + if let Some(window) = web_sys::window() { + if let Ok(Some(media)) = window.match_media("(prefers-color-scheme: dark)") { + system_dark.set(media.matches()); + } + } + }, + ); } use_context_provider(|| theme);