前端开发

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 - 加载 UI
  • error.js - 错误 UI
  • not-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

typescript
import { 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> }

元数据管理

静态元数据

typescript
import type { Metadata } from 'next' export const metadata: Metadata = { title: '我的博客', description: '这是我的个人技术博客', keywords: ['Next.js', 'React', 'TypeScript'], } export default function Page() { return <div>页面内容</div> }

动态元数据

typescript
import 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 带来了许多优势:

  1. 更好的性能:Server Components 减少了客户端 JavaScript
  2. 更灵活的布局:嵌套布局支持
  3. 更好的开发体验:更直观的文件组织
  4. 内置优化:自动代码分割、预加载等

从 Pages Router 迁移到 App Router 虽然有学习成本,但新的特性和性能优势使其成为 Next.js 应用的推荐选择。