refactor(skeletons): 统一骨架屏延迟机制,200ms 内加载不显示

- DelayedSkeleton 从「延迟 pulse 动画」改为「延迟渲染」:
  前 200ms 完全不渲染,超时后才显示并带 pulse 动画
- admin_layout 从 use_delayed_loading hook 改为 DelayedSkeleton 包裹
- CommentListSkeleton 的两处裸用(section.rs / post_detail.rs)
  补上 DelayedSkeleton 包裹
- 移除不再使用的 use_delayed_loading hook 模块
This commit is contained in:
xfy 2026-07-08 14:18:08 +08:00
parent 5b9e65bf72
commit 61c64841f9
5 changed files with 35 additions and 29 deletions

View File

@ -8,9 +8,9 @@ use dioxus::router::components::Link;
use crate::api::auth::{get_current_user, logout}; use crate::api::auth::{get_current_user, logout};
use crate::components::admin_skeleton::AdminDashboardSkeleton; use crate::components::admin_skeleton::AdminDashboardSkeleton;
use crate::components::skeletons::delayed_skeleton::DelayedSkeleton;
use crate::components::write_skeleton::WriteSkeleton; use crate::components::write_skeleton::WriteSkeleton;
use crate::context::UserContext; use crate::context::UserContext;
use crate::hooks::delayed_loading::use_delayed_loading;
use crate::router::Route; use crate::router::Route;
use crate::theme::ThemeToggle; use crate::theme::ThemeToggle;
@ -19,7 +19,7 @@ pub fn AdminLayout() -> Element {
let mut ctx: UserContext = use_context(); let mut ctx: UserContext = use_context();
let navigator = dioxus::router::navigator(); let navigator = dioxus::router::navigator();
let route = use_route::<Route>(); let route = use_route::<Route>();
let show_skeleton = use_delayed_loading(move || !(ctx.checked)());
use_effect(move || { use_effect(move || {
if !(ctx.checked)() { if !(ctx.checked)() {
@ -145,7 +145,8 @@ pub fn AdminLayout() -> Element {
div { class: "flex-1 flex flex-col min-w-0 h-screen p-2 md:p-4", div { class: "flex-1 flex flex-col min-w-0 h-screen p-2 md:p-4",
div { class: "flex-1 bg-[var(--color-paper-theme)] rounded-[2rem] shadow-sm border border-[var(--color-paper-border)] overflow-hidden relative flex flex-col", div { class: "flex-1 bg-[var(--color-paper-theme)] rounded-[2rem] shadow-sm border border-[var(--color-paper-border)] overflow-hidden relative flex flex-col",
main { class: "{main_class}", main { class: "{main_class}",
div { class: if show_skeleton() { "p-10" } else { "opacity-0" }, DelayedSkeleton {
div { class: "p-10",
{ {
match route { match route {
Route::Write {} => rsx! { WriteSkeleton {} }, Route::Write {} => rsx! { WriteSkeleton {} },
@ -161,3 +162,4 @@ pub fn AdminLayout() -> Element {
} }
} }
} }
}

View File

@ -9,6 +9,7 @@ use crate::api::comments::{check_pending_status, get_comments, CommentTreeRespon
use crate::components::comments::form::CommentForm; use crate::components::comments::form::CommentForm;
use crate::components::comments::list::CommentList; use crate::components::comments::list::CommentList;
use crate::components::skeletons::comment_skeleton::CommentListSkeleton; use crate::components::skeletons::comment_skeleton::CommentListSkeleton;
use crate::components::skeletons::delayed_skeleton::DelayedSkeleton;
use crate::utils::comment_storage::{self, PendingComment}; use crate::utils::comment_storage::{self, PendingComment};
/// 评论上下文,供评论相关组件共享状态。 /// 评论上下文,供评论相关组件共享状态。
@ -118,7 +119,7 @@ pub fn CommentSection(post_id: i32) -> Element {
} }
} }
None => rsx! { None => rsx! {
CommentListSkeleton {} DelayedSkeleton { CommentListSkeleton {} }
}, },
} }
} }

View File

@ -1,36 +1,39 @@
//! 延迟骨架屏包装组件 //! 延迟骨架屏包装组件
//! //!
//! 让骨架屏先以静态灰色块立即显示,延迟一段时间后再启动 pulse 动画, //! 200ms 内完全不渲染骨架屏,避免快速加载时骨架屏一闪而过;
//! 避免快速加载时动画一闪而过带来的视觉抖动 //! 超过 200ms 后渲染骨架屏并带有 pulse 动画,提示正在加载
use crate::utils::time::sleep_ms; use crate::utils::time::sleep_ms;
use dioxus::prelude::*; use dioxus::prelude::*;
/// 骨架屏 pulse 动画延迟(毫秒)。 /// 骨架屏显示延迟(毫秒)。
/// ///
/// 加载时间低于此值时骨架屏只显示静态灰色块,避免 pulse 动画一闪而过 /// 加载时间低于此值时完全不显示骨架屏,避免闪烁
const SKELETON_PULSE_DELAY_MS: u32 = 200; const SKELETON_DELAY_MS: u32 = 200;
/// 延迟 pulse 动画的骨架屏包装组件。 /// 延迟渲染的骨架屏包装组件。
/// ///
/// 骨架屏区域**立即显示**(灰色静态占位块),避免空白闪烁。 /// 前 `SKELETON_DELAY_MS` 毫秒内不渲染任何内容;
/// 延迟 `SKELETON_PULSE_DELAY_MS` 毫秒后,如果仍在加载,则启动 pulse 动画。 /// 超过延迟后渲染子元素并启动 pulse 动画。
/// ///
/// 快加载(< 200ms用户只看到静态灰色块一闪而过,无动画感知 /// 快加载(< 200ms组件在渲染前就被卸载,用户完全看不到骨架屏
/// 慢加载:灰色块正常 pulse提示正在加载。 /// 慢加载:骨架屏正常显示并 pulse提示正在加载。
#[component] #[component]
pub fn DelayedSkeleton(children: Element) -> Element { pub fn DelayedSkeleton(children: Element) -> Element {
let mut pulsing = use_signal(|| false); let mut visible = use_signal(|| false);
// 延迟启动 pulse 动画
use_effect(move || { use_effect(move || {
spawn(async move { spawn(async move {
sleep_ms(SKELETON_PULSE_DELAY_MS).await; sleep_ms(SKELETON_DELAY_MS).await;
pulsing.set(true); visible.set(true);
}); });
}); });
if visible() {
rsx! { rsx! {
div { class: if pulsing() { "animate-pulse" } else { "" }, {children} } div { class: "animate-pulse", {children} }
}
} else {
rsx! {}
} }
} }

View File

@ -1,15 +1,13 @@
//! 共享的 Dioxus Hooks 模块。 //! 共享的 Dioxus Hooks 模块。
//! //!
//! 该模块集中管理可在组件树中复用的自定义 Hook包括 //! 该模块集中管理可在组件树中复用的自定义 Hook包括
//! - 骨架屏延迟加载状态
//! - 通用 DOM 事件监听(注册 + 自动卸载清理) //! - 通用 DOM 事件监听(注册 + 自动卸载清理)
//! - 客户端分页数据加载 //! - 客户端分页数据加载
//! //!
//! 评论草稿持久化localStorage 读写)已迁至 `crate::utils::comment_storage` //! 评论草稿持久化localStorage 读写)已迁至 `crate::utils::comment_storage`
//! 因为它是纯工具函数而非渲染期 Hook。 //! 因为它是纯工具函数而非渲染期 Hook。
//!
/// 延迟加载状态 Hook。 //! 骨架屏延迟渲染已统一至 `crate::components::skeletons::delayed_skeleton::DelayedSkeleton` 组件。
pub mod delayed_loading;
/// 通用 DOM 事件监听 Hookadd/remove_event_listener 生命周期封装)。 /// 通用 DOM 事件监听 Hookadd/remove_event_listener 生命周期封装)。
pub mod event_listener; pub mod event_listener;

View File

@ -63,7 +63,9 @@ pub fn PostDetail(slug: String) -> Element {
div { class: "mt-12 border-t border-gray-200 dark:border-gray-700 pt-8", div { class: "mt-12 border-t border-gray-200 dark:border-gray-700 pt-8",
SuspenseBoundary { SuspenseBoundary {
fallback: move |_| rsx! { fallback: move |_| rsx! {
DelayedSkeleton {
crate::components::skeletons::comment_skeleton::CommentListSkeleton {} crate::components::skeletons::comment_skeleton::CommentListSkeleton {}
}
}, },
crate::components::comments::section::CommentSection { post_id: post.id } crate::components::comments::section::CommentSection { post_id: post.id }
} }