前端开发
Next.js App Router 完全指南
深入探索 Next.js 13+ 的 App Router,了解新的路由系统、布局、数据获取方式以及最佳实践。
Zee
2024-01-12
15分钟
3 个标签
Next.jsReactApp Router
Next.js App Router 完全指南
Next.js 13 引入了全新的 App Router,基于 React Server Components 构建,提供了更强大和灵活的路由系统。
App Router vs Pages Router
Pages Router (传统方式)
pages/
index.js -> /
about.js -> /about
blog/
index.js -> /blog
[slug].js -> /blog/:slug
App Router (新方式)
app/
page.js -> /
about/
page.js -> /about
blog/
page.js -> /blog
[slug]/
page.js -> /blog/:slug
核心概念
1. 文件约定
App Router 使用特殊的文件名来定义 UI:
page.js
- 页面组件layout.js
- 布局组件loading.js
- 加载 UIerror.js
- 错误 UInot-found.js
- 404 页面
2. 嵌套布局
typescript// app/layout.tsx - 根布局
export default function RootLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<html lang="zh-CN">
<body>
<header>网站头部</header>
{children}
<footer>网站尾部</footer>
</body>
</html>
)
}
// app/blog/layout.tsx - 博客布局
export default function BlogLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<div className="blog-container">
<aside>博客侧边栏</aside>
<main>{children}</main>
</div>
)
}
路由组织
基础路由
app/
page.tsx -> /
about/
page.tsx -> /about
contact/
page.tsx -> /contact
动态路由
typescript// app/blog/[slug]/page.tsx
interface PageProps {
params: { slug: string }
}
export default function BlogPost({ params }: PageProps) {
const { slug } = params
return <h1>博客文章: {slug}</h1>
}
可选捕获路由
typescript// app/shop/[[...slug]]/page.tsx
interface PageProps {
params: { slug?: string[] }
}
export default function Shop({ params }: PageProps) {
const { slug = [] } = params
if (slug.length === 0) {
return <div>商店首页</div>
}
if (slug.length === 1) {
return <div>分类: {slug[0]}</div>
}
return <div>产品: {slug.join(' / ')}</div>
}
路由组 (Route Groups)
使用 ()
创建路由组,不影响 URL 结构:
app/
(auth)/
login/
page.tsx -> /login
register/
page.tsx -> /register
(dashboard)/
analytics/
page.tsx -> /analytics
settings/
page.tsx -> /settings
Server Components vs Client Components
Server Components (默认)
typescript// 在服务器端渲染,可以直接访问数据库
import { db } from '@/lib/db'
export default async function BlogPosts() {
const posts = await db.post.findMany()
return (
<div>
{posts.map(post => (
<article key={post.id}>
<h2>{post.title}</h2>
<p>{post.summary}</p>
</article>
))}
</div>
)
}
Client Components
typescript'use client' // 必须添加此指令
import { useState } from 'react'
export default function Counter() {
const [count, setCount] = useState(0)
return (
<div>
<p>Count: {count}</p>
<button onClick={() => setCount(count + 1)}>
增加
</button>
</div>
)
}
数据获取
Server Components 中的数据获取
typescript// 直接在组件中获取数据
async function getPosts() {
const res = await fetch('https://api.example.com/posts', {
// Next.js 扩展了 fetch API
next: { revalidate: 60 } // 60秒后重新验证
})
if (!res.ok) {
throw new Error('Failed to fetch posts')
}
return res.json()
}
export default async function BlogPage() {
const posts = await getPosts()
return (
<div>
{posts.map(post => (
<div key={post.id}>{post.title}</div>
))}
</div>
)
}
流式传输和 Suspense
typescriptimport { Suspense } from 'react'
async function SlowComponent() {
// 模拟慢速数据获取
await new Promise(resolve => setTimeout(resolve, 3000))
return <div>慢速组件已加载</div>
}
export default function Page() {
return (
<div>
<h1>页面标题</h1>
<Suspense fallback={<div>正在加载...</div>}>
<SlowComponent />
</Suspense>
</div>
)
}
缓存策略
1. 请求记忆化
typescript// 在同一个渲染周期中,相同的请求会被自动缓存
async function getUser(id: string) {
const res = await fetch(`https://api.example.com/users/${id}`)
return res.json()
}
export default async function Page() {
// 这两个请求会被记忆化,实际只发送一次
const user1 = await getUser('1')
const user2 = await getUser('1')
return <div>用户: {user1.name}</div>
}
2. 数据缓存
typescript// 静态数据 - 构建时获取,永不过期
fetch('https://api.example.com/static-data')
// 重新验证 - 60秒后过期
fetch('https://api.example.com/data', {
next: { revalidate: 60 }
})
// 不缓存 - 每次请求都获取新数据
fetch('https://api.example.com/dynamic-data', {
cache: 'no-store'
})
3. 完整路由缓存
typescript// 静态渲染 - 构建时生成
export default async function StaticPage() {
const data = await fetch('https://api.example.com/static')
return <div>{data.title}</div>
}
// 动态渲染 - 每次请求时生成
export const dynamic = 'force-dynamic'
export default async function DynamicPage() {
const data = await fetch('https://api.example.com/dynamic')
return <div>{data.title}</div>
}
加载和错误处理
Loading UI
typescript// app/blog/loading.tsx
export default function Loading() {
return (
<div className="animate-pulse">
<div className="h-4 bg-gray-200 rounded w-3/4 mb-4"></div>
<div className="h-4 bg-gray-200 rounded w-1/2 mb-4"></div>
<div className="h-4 bg-gray-200 rounded w-5/6"></div>
</div>
)
}
Error UI
typescript// app/blog/error.tsx
'use client'
export default function Error({
error,
reset,
}: {
error: Error & { digest?: string }
reset: () => void
}) {
return (
<div>
<h2>出现错误了!</h2>
<p>{error.message}</p>
<button onClick={reset}>重试</button>
</div>
)
}
Not Found
typescript// app/blog/[slug]/not-found.tsx
export default function NotFound() {
return (
<div>
<h2>文章未找到</h2>
<p>请检查 URL 或返回首页</p>
</div>
)
}
// 在页面组件中触发 not-found
import { notFound } from 'next/navigation'
export default async function BlogPost({ params }) {
const post = await getPost(params.slug)
if (!post) {
notFound() // 触发 not-found.tsx
}
return <div>{post.title}</div>
}
元数据管理
静态元数据
typescriptimport type { Metadata } from 'next'
export const metadata: Metadata = {
title: '我的博客',
description: '这是我的个人技术博客',
keywords: ['Next.js', 'React', 'TypeScript'],
}
export default function Page() {
return <div>页面内容</div>
}
动态元数据
typescriptimport type { Metadata } from 'next'
interface Props {
params: { slug: string }
}
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const post = await getPost(params.slug)
return {
title: post.title,
description: post.summary,
openGraph: {
title: post.title,
description: post.summary,
images: [post.coverImage],
},
}
}
export default function BlogPost({ params }: Props) {
// 页面组件
}
静态生成
generateStaticParams
typescript// 生成静态路径
export async function generateStaticParams() {
const posts = await getAllPosts()
return posts.map((post) => ({
slug: post.slug,
}))
}
export default async function BlogPost({ params }) {
const post = await getPost(params.slug)
return <div>{post.title}</div>
}
中间件
typescript// middleware.ts
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'
export function middleware(request: NextRequest) {
// 重定向到小写 URL
if (request.nextUrl.pathname !== request.nextUrl.pathname.toLowerCase()) {
return NextResponse.redirect(
new URL(request.nextUrl.pathname.toLowerCase(), request.url)
)
}
// 添加安全头
const response = NextResponse.next()
response.headers.set('X-Frame-Options', 'DENY')
response.headers.set('X-Content-Type-Options', 'nosniff')
return response
}
export const config = {
matcher: [
'/((?!api|_next/static|_next/image|favicon.ico).*)',
],
}
最佳实践
1. 组件组织
typescript// 保持 Server Components 和 Client Components 分离
// components/server/
// post-list.tsx
// components/client/
// search-box.tsx
// 在 Server Component 中组合
export default async function BlogPage() {
const posts = await getPosts()
return (
<div>
<SearchBox /> {/* Client Component */}
<PostList posts={posts} /> {/* Server Component */}
</div>
)
}
2. 数据获取优化
typescript// 并行数据获取
export default async function Page() {
// 并行执行
const [posts, categories] = await Promise.all([
getPosts(),
getCategories(),
])
return (
<div>
<Categories categories={categories} />
<Posts posts={posts} />
</div>
)
}
3. 错误边界
typescript// 为不同级别设置错误边界
app/
error.tsx # 全局错误
blog/
error.tsx # 博客错误
[slug]/
error.tsx # 文章错误
page.tsx
总结
App Router 带来了许多优势:
- 更好的性能:Server Components 减少了客户端 JavaScript
- 更灵活的布局:嵌套布局支持
- 更好的开发体验:更直观的文件组织
- 内置优化:自动代码分割、预加载等
从 Pages Router 迁移到 App Router 虽然有学习成本,但新的特性和性能优势使其成为 Next.js 应用的推荐选择。