⚠️ Alpha内测版本警告:此为早期内部构建版本,尚不完整且可能存在错误,欢迎大家提Issue反馈问题或建议
Skip to content

3.7.3 加载状态

一句话破题

Skeleton 让用户知道内容马上就来,比 Spinner 更能缓解等待焦虑。

核心价值

空白页面让用户以为页面坏了。合适的加载状态可以:降低感知等待时间、保持视觉布局稳定、提升用户信心。

Spinner vs Skeleton

特性SpinnerSkeleton
适用场景操作反馈、短时等待页面加载、列表加载
视觉效果集中注意力分散注意力
布局稳定性可能跳动预留位置,无跳动
感知时间感觉更长感觉更短

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 状态
  • [ ] 快速请求无闪烁