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 文件的自动工作机制:
- 自动触发:当页面或路由段正在加载时自动显示
- 嵌套支持:支持嵌套的加载状态,子路由的加载状态会覆盖父路由
- 布局保持:加载状态会替换页面内容,但保持布局结构
- 用户体验:提供视觉反馈,让用户知道内容正在加载
- 性能优化:与 Suspense 结合使用,提供更好的感知性能
最佳实践:
- 设计骨架屏:保持与真实内容相同的布局结构
- 渐进式加载:优先显示关键内容,次要内容后加载
- 错误处理:提供错误状态和重试机制
- 动画效果:使用适当的动画提升用户体验
- 响应式设计:确保加载状态在不同设备上都能正常显示
这些技术让 Next.js 应用能够提供更好的加载体验和用户反馈。