Q19: 如何为动态内容添加加载状态(Loading UI)?loading.js 文件是如何自动工作的?

13 阅读5分钟

Next.js 面试题详细答案 - Q19

Q19: 如何为动态内容添加加载状态(Loading UI)?loading.js 文件是如何自动工作的?

loading.js 文件自动工作

1. 基本用法
// app/blog/loading.js
export default function Loading() {
  return (
    <div className="loading">
      <div className="spinner"></div>
      <p>加载中...</p>
    </div>
  )
}

// app/blog/page.js
async function BlogPage() {
  const posts = await fetch('/api/posts')
  const data = await posts.json()

  return (
    <div>
      <h1>博客</h1>
      <ul>
        {data.map(post => (
          <li key={post.id}>{post.title}</li>
        ))}
      </ul>
    </div>
  )
}

export default BlogPage
2. 嵌套加载状态
// app/layout.js - 根布局
export default function RootLayout({ children }) {
  return (
    <html>
      <body>
        <header>全局头部</header>
        {children}
        <footer>全局底部</footer>
      </body>
    </html>
  )
}

// app/loading.js - 全局加载状态
export default function GlobalLoading() {
  return (
    <div className="global-loading">
      <div className="loading-spinner"></div>
      <p>页面加载中...</p>
    </div>
  )
}

// app/blog/loading.js - 博客页面加载状态
export default function BlogLoading() {
  return (
    <div className="blog-loading">
      <div className="blog-skeleton">
        <div className="skeleton-header"></div>
        <div className="skeleton-content">
          <div className="skeleton-line"></div>
          <div className="skeleton-line"></div>
          <div className="skeleton-line short"></div>
        </div>
      </div>
    </div>
  )
}

// app/blog/[slug]/loading.js - 文章详情加载状态
export default function PostLoading() {
  return (
    <div className="post-loading">
      <div className="post-skeleton">
        <div className="skeleton-title"></div>
        <div className="skeleton-meta"></div>
        <div className="skeleton-content">
          <div className="skeleton-line"></div>
          <div className="skeleton-line"></div>
          <div className="skeleton-line"></div>
          <div className="skeleton-line short"></div>
        </div>
      </div>
    </div>
  )
}

加载状态设计

1. 骨架屏设计
// app/products/loading.js
export default function ProductsLoading() {
  return (
    <div className="products-loading">
      <div className="products-header">
        <div className="skeleton-title"></div>
        <div className="skeleton-filters"></div>
      </div>

      <div className="products-grid">
        {Array.from({ length: 12 }).map((_, i) => (
          <div key={i} className="product-skeleton">
            <div className="skeleton-image"></div>
            <div className="skeleton-content">
              <div className="skeleton-line"></div>
              <div className="skeleton-line short"></div>
              <div className="skeleton-price"></div>
            </div>
          </div>
        ))}
      </div>
    </div>
  )
}

// 对应的 CSS
const styles = `
.products-loading {
  padding: 20px;
}

.products-header {
  margin-bottom: 30px;
}

.skeleton-title {
  height: 32px;
  background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
  background-size: 200% 100%;
  animation: loading 1.5s infinite;
  border-radius: 4px;
  margin-bottom: 16px;
}

.skeleton-filters {
  height: 40px;
  background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
  background-size: 200% 100%;
  animation: loading 1.5s infinite;
  border-radius: 4px;
}

.products-grid {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
  gap: 20px;
}

.product-skeleton {
  border: 1px solid #e0e0e0;
  border-radius: 8px;
  padding: 16px;
}

.skeleton-image {
  height: 200px;
  background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
  background-size: 200% 100%;
  animation: loading 1.5s infinite;
  border-radius: 4px;
  margin-bottom: 12px;
}

.skeleton-line {
  height: 16px;
  background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
  background-size: 200% 100%;
  animation: loading 1.5s infinite;
  border-radius: 4px;
  margin-bottom: 8px;
}

.skeleton-line.short {
  width: 60%;
}

.skeleton-price {
  height: 20px;
  width: 40%;
  background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
  background-size: 200% 100%;
  animation: loading 1.5s infinite;
  border-radius: 4px;
}

@keyframes loading {
  0% {
    background-position: 200% 0;
  }
  100% {
    background-position: -200% 0;
  }
}
`
2. 进度条设计
// app/dashboard/loading.js
export default function DashboardLoading() {
  return (
    <div className="dashboard-loading">
      <div className="loading-header">
        <h2>仪表盘</h2>
        <div className="progress-bar">
          <div className="progress-fill"></div>
        </div>
      </div>

      <div className="loading-content">
        <div className="metrics-skeleton">
          {Array.from({ length: 4 }).map((_, i) => (
            <div key={i} className="metric-skeleton">
              <div className="skeleton-value"></div>
              <div className="skeleton-label"></div>
            </div>
          ))}
        </div>

        <div className="chart-skeleton">
          <div className="skeleton-chart"></div>
        </div>
      </div>
    </div>
  )
}

// 对应的 CSS
const dashboardStyles = `
.dashboard-loading {
  padding: 20px;
}

.loading-header {
  margin-bottom: 30px;
}

.progress-bar {
  width: 100%;
  height: 4px;
  background-color: #e0e0e0;
  border-radius: 2px;
  overflow: hidden;
  margin-top: 16px;
}

.progress-fill {
  height: 100%;
  background: linear-gradient(90deg, #4f46e5, #7c3aed);
  animation: progress 2s ease-in-out infinite;
}

@keyframes progress {
  0% {
    width: 0%;
  }
  50% {
    width: 70%;
  }
  100% {
    width: 100%;
  }
}

.metrics-skeleton {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
  gap: 20px;
  margin-bottom: 30px;
}

.metric-skeleton {
  padding: 20px;
  border: 1px solid #e0e0e0;
  border-radius: 8px;
}

.skeleton-value {
  height: 32px;
  background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
  background-size: 200% 100%;
  animation: loading 1.5s infinite;
  border-radius: 4px;
  margin-bottom: 8px;
}

.skeleton-label {
  height: 16px;
  width: 60%;
  background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
  background-size: 200% 100%;
  animation: loading 1.5s infinite;
  border-radius: 4px;
}

.chart-skeleton {
  height: 300px;
  background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
  background-size: 200% 100%;
  animation: loading 1.5s infinite;
  border-radius: 8px;
}
`

动态加载状态

1. 使用 Suspense
// app/blog/page.js
import { Suspense } from 'react'

export default function BlogPage() {
  return (
    <div>
      <h1>博客</h1>

      <Suspense fallback={<BlogListSkeleton />}>
        <BlogList />
      </Suspense>

      <Suspense fallback={<RecommendedSkeleton />}>
        <RecommendedPosts />
      </Suspense>
    </div>
  )
}

function BlogListSkeleton() {
  return (
    <div className="blog-list-skeleton">
      {Array.from({ length: 6 }).map((_, i) => (
        <div key={i} className="blog-item-skeleton">
          <div className="skeleton-image"></div>
          <div className="skeleton-content">
            <div className="skeleton-title"></div>
            <div className="skeleton-excerpt"></div>
            <div className="skeleton-meta"></div>
          </div>
        </div>
      ))}
    </div>
  )
}

function RecommendedSkeleton() {
  return (
    <div className="recommended-skeleton">
      <h3>推荐文章</h3>
      {Array.from({ length: 3 }).map((_, i) => (
        <div key={i} className="recommended-item-skeleton">
          <div className="skeleton-line"></div>
          <div className="skeleton-line short"></div>
        </div>
      ))}
    </div>
  )
}

async function BlogList() {
  const posts = await fetch('/api/posts')
  const data = await posts.json()

  return (
    <div className="blog-list">
      {data.map((post) => (
        <article key={post.id} className="blog-item">
          <img src={post.image} alt={post.title} />
          <div className="content">
            <h3>{post.title}</h3>
            <p>{post.excerpt}</p>
            <time>{post.publishedAt}</time>
          </div>
        </article>
      ))}
    </div>
  )
}

async function RecommendedPosts() {
  const posts = await fetch('/api/recommended')
  const data = await posts.json()

  return (
    <div className="recommended-posts">
      <h3>推荐文章</h3>
      {data.map((post) => (
        <div key={post.id} className="recommended-item">
          <h4>{post.title}</h4>
          <p>{post.excerpt}</p>
        </div>
      ))}
    </div>
  )
}
2. 客户端加载状态
'use client'
import { useState, useEffect } from 'react'

function ClientLoadingExample() {
  const [data, setData] = useState(null)
  const [loading, setLoading] = useState(true)
  const [error, setError] = useState(null)

  useEffect(() => {
    const fetchData = async () => {
      try {
        setLoading(true)
        const response = await fetch('/api/data')
        const result = await response.json()
        setData(result)
      } catch (err) {
        setError(err.message)
      } finally {
        setLoading(false)
      }
    }

    fetchData()
  }, [])

  if (loading) {
    return <LoadingSpinner />
  }

  if (error) {
    return <ErrorMessage error={error} />
  }

  return <DataDisplay data={data} />
}

function LoadingSpinner() {
  return (
    <div className="loading-spinner">
      <div className="spinner"></div>
      <p>加载中...</p>
    </div>
  )
}

function ErrorMessage({ error }) {
  return (
    <div className="error-message">
      <h3>加载失败</h3>
      <p>{error}</p>
      <button onClick={() => window.location.reload()}>重试</button>
    </div>
  )
}

function DataDisplay({ data }) {
  return (
    <div className="data-display">
      <h2>数据展示</h2>
      <pre>{JSON.stringify(data, null, 2)}</pre>
    </div>
  )
}

加载状态最佳实践

1. 保持布局稳定
// app/products/loading.js
export default function ProductsLoading() {
  return (
    <div className="products-loading">
      {/* 保持与真实内容相同的布局结构 */}
      <div className="products-header">
        <h1>产品列表</h1>
        <div className="filters">
          <div className="filter-skeleton"></div>
          <div className="filter-skeleton"></div>
          <div className="filter-skeleton"></div>
        </div>
      </div>

      <div className="products-grid">
        {Array.from({ length: 12 }).map((_, i) => (
          <div key={i} className="product-card-skeleton">
            <div className="product-image-skeleton"></div>
            <div className="product-info-skeleton">
              <div className="product-title-skeleton"></div>
              <div className="product-price-skeleton"></div>
              <div className="product-rating-skeleton"></div>
            </div>
          </div>
        ))}
      </div>
    </div>
  )
}
2. 渐进式加载
// app/dashboard/loading.js
export default function DashboardLoading() {
  return (
    <div className="dashboard-loading">
      {/* 立即显示的关键信息 */}
      <header>
        <h1>仪表盘</h1>
        <nav>
          <a href="/dashboard">概览</a>
          <a href="/dashboard/analytics">分析</a>
          <a href="/dashboard/settings">设置</a>
        </nav>
      </header>

      {/* 加载状态 */}
      <main>
        <div className="loading-metrics">
          <div className="metric-skeleton"></div>
          <div className="metric-skeleton"></div>
          <div className="metric-skeleton"></div>
          <div className="metric-skeleton"></div>
        </div>

        <div className="loading-charts">
          <div className="chart-skeleton"></div>
        </div>

        <div className="loading-data">
          <div className="data-skeleton"></div>
        </div>
      </main>
    </div>
  )
}
3. 错误状态处理
// app/blog/[slug]/loading.js
export default function PostLoading() {
  return (
    <div className="post-loading">
      <div className="post-header-skeleton">
        <div className="title-skeleton"></div>
        <div className="meta-skeleton"></div>
      </div>

      <div className="post-content-skeleton">
        <div className="content-line"></div>
        <div className="content-line"></div>
        <div className="content-line"></div>
        <div className="content-line short"></div>
      </div>

      <div className="post-footer-skeleton">
        <div className="tags-skeleton"></div>
        <div className="actions-skeleton"></div>
      </div>
    </div>
  )
}

// app/blog/[slug]/error.js
'use client'
export default function PostError({ error, reset }) {
  return (
    <div className="post-error">
      <h2>文章加载失败</h2>
      <p>{error.message}</p>
      <div className="error-actions">
        <button onClick={reset}>重试</button>
        <a href="/blog">返回博客列表</a>
      </div>
    </div>
  )
}

总结

loading.js 文件的自动工作机制:

  1. 自动触发:当页面或路由段正在加载时自动显示
  2. 嵌套支持:支持嵌套的加载状态,子路由的加载状态会覆盖父路由
  3. 布局保持:加载状态会替换页面内容,但保持布局结构
  4. 用户体验:提供视觉反馈,让用户知道内容正在加载
  5. 性能优化:与 Suspense 结合使用,提供更好的感知性能

最佳实践:

  1. 设计骨架屏:保持与真实内容相同的布局结构
  2. 渐进式加载:优先显示关键内容,次要内容后加载
  3. 错误处理:提供错误状态和重试机制
  4. 动画效果:使用适当的动画提升用户体验
  5. 响应式设计:确保加载状态在不同设备上都能正常显示

这些技术让 Next.js 应用能够提供更好的加载体验和用户反馈。