3.7.3 加载状态
一句话破题
Skeleton 让用户知道内容马上就来,比 Spinner 更能缓解等待焦虑。
核心价值
空白页面让用户以为页面坏了。合适的加载状态可以:降低感知等待时间、保持视觉布局稳定、提升用户信心。
Spinner vs Skeleton
| 特性 | Spinner | Skeleton |
|---|---|---|
| 适用场景 | 操作反馈、短时等待 | 页面加载、列表加载 |
| 视觉效果 | 集中注意力 | 分散注意力 |
| 布局稳定性 | 可能跳动 | 预留位置,无跳动 |
| 感知时间 | 感觉更长 | 感觉更短 |
Skeleton 组件
tsx
// components/Skeleton.tsx
import { cn } from '@/lib/utils'
interface SkeletonProps {
className?: string
width?: string | number
height?: string | number
}
export function Skeleton({ className, width, height }: SkeletonProps) {
return (
<div
className={cn(
'animate-pulse rounded bg-gray-200',
className
)}
style={{ width, height }}
/>
)
}
// 文本骨架
export function SkeletonText({ lines = 3 }: { lines?: number }) {
return (
<div className="space-y-2">
{Array.from({ length: lines }).map((_, i) => (
<Skeleton
key={i}
className="h-4"
style={{ width: i === lines - 1 ? '60%' : '100%' }}
/>
))}
</div>
)
}
// 头像骨架
export function SkeletonAvatar({ size = 40 }: { size?: number }) {
return (
<Skeleton
className="rounded-full"
width={size}
height={size}
/>
)
}页面级 Skeleton
tsx
// components/skeletons/PostCardSkeleton.tsx
export function PostCardSkeleton() {
return (
<div className="p-4 border rounded-lg">
<div className="flex items-center gap-3 mb-4">
<SkeletonAvatar size={32} />
<div className="flex-1">
<Skeleton className="h-4 w-24 mb-1" />
<Skeleton className="h-3 w-16" />
</div>
</div>
<Skeleton className="h-6 w-3/4 mb-2" />
<SkeletonText lines={2} />
<div className="flex gap-4 mt-4">
<Skeleton className="h-4 w-12" />
<Skeleton className="h-4 w-12" />
</div>
</div>
)
}
// 列表骨架
export function PostListSkeleton({ count = 3 }: { count?: number }) {
return (
<div className="space-y-4">
{Array.from({ length: count }).map((_, i) => (
<PostCardSkeleton key={i} />
))}
</div>
)
}Next.js loading.tsx
tsx
// app/posts/loading.tsx
import { PostListSkeleton } from '@/components/skeletons/PostCardSkeleton'
export default function Loading() {
return (
<div className="container py-8">
<Skeleton className="h-8 w-48 mb-6" />
<PostListSkeleton count={5} />
</div>
)
}Spinner 组件
tsx
// components/Spinner.tsx
import { cn } from '@/lib/utils'
interface SpinnerProps {
size?: 'sm' | 'md' | 'lg'
className?: string
}
const sizes = {
sm: 'w-4 h-4',
md: 'w-6 h-6',
lg: 'w-8 h-8',
}
export function Spinner({ size = 'md', className }: SpinnerProps) {
return (
<svg
className={cn('animate-spin text-blue-500', sizes[size], className)}
fill="none"
viewBox="0 0 24 24"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
/>
</svg>
)
}
// 全屏加载
export function FullPageSpinner() {
return (
<div className="fixed inset-0 flex items-center justify-center bg-white/80">
<Spinner size="lg" />
</div>
)
}按钮加载状态
tsx
// components/Button.tsx
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
loading?: boolean
children: React.ReactNode
}
export function Button({ loading, children, disabled, ...props }: ButtonProps) {
return (
<button
disabled={loading || disabled}
className="relative px-4 py-2 bg-blue-500 text-white rounded disabled:opacity-50"
{...props}
>
{loading && (
<span className="absolute inset-0 flex items-center justify-center">
<Spinner size="sm" className="text-white" />
</span>
)}
<span className={loading ? 'invisible' : ''}>{children}</span>
</button>
)
}
// 使用
<Button loading={isSubmitting}>
提交
</Button>Suspense 与加载状态
tsx
// app/posts/page.tsx
import { Suspense } from 'react'
import { PostListSkeleton } from '@/components/skeletons/PostCardSkeleton'
export default function PostsPage() {
return (
<div className="container py-8">
<h1 className="text-2xl font-bold mb-6">文章列表</h1>
<Suspense fallback={<PostListSkeleton />}>
<PostList />
</Suspense>
</div>
)
}
// 数据获取组件
async function PostList() {
const posts = await fetchPosts()
return (
<ul className="space-y-4">
{posts.map((post) => (
<PostCard key={post.id} post={post} />
))}
</ul>
)
}渐进式加载
tsx
// 先显示重要内容,再加载次要内容
export default function PostPage({ params }: { params: { id: string } }) {
return (
<article>
{/* 核心内容先加载 */}
<Suspense fallback={<ArticleSkeleton />}>
<ArticleContent id={params.id} />
</Suspense>
{/* 评论后加载 */}
<Suspense fallback={<CommentsSkeleton />}>
<Comments postId={params.id} />
</Suspense>
{/* 推荐内容最后加载 */}
<Suspense fallback={<RecommendationsSkeleton />}>
<RelatedPosts postId={params.id} />
</Suspense>
</article>
)
}加载状态最佳实践
1. 匹配真实布局:
tsx
// 差:通用骨架,加载完成后布局跳动
<Skeleton className="h-40" />
// 好:精确匹配最终布局
<div className="flex gap-4">
<Skeleton className="w-24 h-24" />
<div className="flex-1">
<Skeleton className="h-6 w-3/4 mb-2" />
<Skeleton className="h-4 w-1/2" />
</div>
</div>2. 避免闪烁:
tsx
// 对于快速加载的内容,延迟显示加载状态
function useDelayedLoading(isLoading: boolean, delay = 200) {
const [showLoading, setShowLoading] = useState(false)
useEffect(() => {
if (isLoading) {
const timer = setTimeout(() => setShowLoading(true), delay)
return () => clearTimeout(timer)
}
setShowLoading(false)
}, [isLoading, delay])
return showLoading
}AI 协作指南
核心意图:让 AI 帮你创建匹配页面布局的 Skeleton 组件。
需求定义公式:
- 目标页面:[页面名称] 的加载骨架
- 布局描述:[卡片/列表/表格] 布局
- 关键元素:[头像/标题/图片/段落]
示例 Prompt:
请为电商商品卡片创建 Skeleton 组件:
1. 包含商品图片(正方形)
2. 商品标题(一行)
3. 价格(较大字号)
4. 两个标签(如包邮、新品)
使用 Tailwind CSS 的 animate-pulse验收清单
- [ ] 页面级加载有 loading.tsx
- [ ] Skeleton 匹配真实布局
- [ ] 按钮有 loading 状态
- [ ] 快速请求无闪烁
