Appearance
动态路由与参数
课程概述
本课程深入探讨 Next.js 15 中的动态路由系统和参数处理。动态路由是构建现代 Web 应用的核心功能,允许我们创建灵活的 URL 结构,处理动态内容,并实现复杂的路由逻辑。
学习目标:
- 理解 Next.js 动态路由机制
- 掌握路由参数的获取和使用
- 学习嵌套动态路由
- 理解可选捕获段和捕获所有段
- 掌握路由组和并行路由
- 学习路由拦截和条件路由
- 理解路由优先级
- 掌握静态参数生成
一、动态路由基础
1.1 基础动态路由
在 Next.js 中,使用方括号 [param] 创建动态路由段:
typescript
// app/posts/[id]/page.tsx
interface PageProps {
params: Promise<{
id: string
}>
}
export default async function PostPage({ params }: PageProps) {
const { id } = await params
return (
<div className="container mx-auto p-4">
<h1 className="text-3xl font-bold">Post ID: {id}</h1>
</div>
)
}URL 映射示例:
| URL | 匹配的文件 | params |
|---|---|---|
/posts/1 | app/posts/[id]/page.tsx | { id: '1' } |
/posts/abc | app/posts/[id]/page.tsx | { id: 'abc' } |
/posts/hello-world | app/posts/[id]/page.tsx | { id: 'hello-world' } |
1.2 多个动态段
typescript
// app/blog/[category]/[slug]/page.tsx
interface PageProps {
params: Promise<{
category: string
slug: string
}>
}
export default async function BlogPostPage({ params }: PageProps) {
const { category, slug } = await params
return (
<div className="container mx-auto p-4">
<nav className="mb-4 text-sm text-gray-600">
<a href="/blog">Blog</a> /
<a href={`/blog/${category}`}>{category}</a> /
<span>{slug}</span>
</nav>
<h1 className="text-4xl font-bold">
{category} - {slug}
</h1>
</div>
)
}URL 映射示例:
| URL | params |
|---|---|
/blog/tech/nextjs-15 | { category: 'tech', slug: 'nextjs-15' } |
/blog/design/ui-patterns | { category: 'design', slug: 'ui-patterns' } |
1.3 动态路由数据获取
typescript
// app/products/[id]/page.tsx
import { notFound } from 'next/navigation'
interface Product {
id: string
name: string
price: number
description: string
images: string[]
}
async function getProduct(id: string): Promise<Product | null> {
try {
const res = await fetch(`https://api.example.com/products/${id}`, {
next: { revalidate: 60 }
})
if (!res.ok) {
return null
}
return res.json()
} catch (error) {
console.error('Failed to fetch product:', error)
return null
}
}
interface PageProps {
params: Promise<{ id: string }>
}
export default async function ProductPage({ params }: PageProps) {
const { id } = await params
const product = await getProduct(id)
if (!product) {
notFound()
}
return (
<div className="container mx-auto p-4">
<div className="grid md:grid-cols-2 gap-8">
<div>
<img
src={product.images[0]}
alt={product.name}
className="w-full rounded-lg"
/>
</div>
<div>
<h1 className="text-4xl font-bold mb-4">{product.name}</h1>
<p className="text-3xl text-blue-600 mb-6">${product.price}</p>
<p className="text-gray-600 mb-6">{product.description}</p>
<button className="px-6 py-3 bg-blue-500 text-white rounded-lg hover:bg-blue-600">
Add to Cart
</button>
</div>
</div>
</div>
)
}
// 生成静态参数
export async function generateStaticParams() {
const res = await fetch('https://api.example.com/products')
const products: Product[] = await res.json()
return products.map(product => ({
id: product.id
}))
}
// 生成元数据
export async function generateMetadata({ params }: PageProps) {
const { id } = await params
const product = await getProduct(id)
if (!product) {
return {
title: 'Product Not Found'
}
}
return {
title: product.name,
description: product.description,
openGraph: {
title: product.name,
description: product.description,
images: [product.images[0]]
}
}
}1.4 类型安全的参数
typescript
// types/params.ts
export interface PostParams {
id: string
}
export interface BlogParams {
category: string
slug: string
}
export interface UserParams {
username: string
}
// app/posts/[id]/page.tsx
import type { PostParams } from '@/types/params'
interface PageProps {
params: Promise<PostParams>
}
export default async function PostPage({ params }: PageProps) {
const { id } = await params
// TypeScript 知道 id 是 string 类型
const postId = parseInt(id, 10)
return <div>Post {postId}</div>
}二、捕获所有段
2.1 基础捕获所有段
使用 [...slug] 捕获所有后续路径段:
typescript
// app/docs/[...slug]/page.tsx
interface PageProps {
params: Promise<{
slug: string[]
}>
}
export default async function DocsPage({ params }: PageProps) {
const { slug } = await params
return (
<div className="container mx-auto p-4">
<nav className="mb-4 text-sm text-gray-600">
<a href="/docs">Docs</a>
{slug.map((segment, index) => (
<span key={index}>
{' / '}
<a href={`/docs/${slug.slice(0, index + 1).join('/')}`}>
{segment}
</a>
</span>
))}
</nav>
<h1 className="text-4xl font-bold">
{slug.join(' / ')}
</h1>
</div>
)
}URL 映射示例:
| URL | slug |
|---|---|
/docs/getting-started | ['getting-started'] |
/docs/api/authentication | ['api', 'authentication'] |
/docs/guides/deployment/vercel | ['guides', 'deployment', 'vercel'] |
2.2 可选捕获所有段
使用 [[...slug]] 使捕获段可选:
typescript
// app/shop/[[...slug]]/page.tsx
interface PageProps {
params: Promise<{
slug?: string[]
}>
}
export default async function ShopPage({ params }: PageProps) {
const { slug } = await params
if (!slug || slug.length === 0) {
// 匹配 /shop
return (
<div className="container mx-auto p-4">
<h1 className="text-4xl font-bold mb-6">Shop</h1>
<div className="grid grid-cols-4 gap-4">
{/* 显示所有分类 */}
</div>
</div>
)
}
if (slug.length === 1) {
// 匹配 /shop/electronics
const category = slug[0]
return (
<div className="container mx-auto p-4">
<h1 className="text-4xl font-bold mb-6">
Category: {category}
</h1>
{/* 显示该分类的产品 */}
</div>
)
}
// 匹配 /shop/electronics/phones
const [category, subcategory] = slug
return (
<div className="container mx-auto p-4">
<h1 className="text-4xl font-bold mb-6">
{category} - {subcategory}
</h1>
{/* 显示子分类的产品 */}
</div>
)
}URL 映射示例:
| URL | slug |
|---|---|
/shop | undefined |
/shop/electronics | ['electronics'] |
/shop/electronics/phones | ['electronics', 'phones'] |
/shop/electronics/phones/iphone | ['electronics', 'phones', 'iphone'] |
2.3 文档系统实现
typescript
// app/docs/[[...slug]]/page.tsx
import { notFound } from 'next/navigation'
import { getDocBySlug, getDocNavigation } from '@/lib/docs'
import { MDXRemote } from 'next-mdx-remote/rsc'
interface PageProps {
params: Promise<{
slug?: string[]
}>
}
export default async function DocsPage({ params }: PageProps) {
const { slug = [] } = await params
const path = slug.join('/')
const doc = await getDocBySlug(path || 'index')
if (!doc) {
notFound()
}
const navigation = await getDocNavigation()
return (
<div className="container mx-auto p-4">
<div className="flex gap-8">
<aside className="w-64 flex-shrink-0">
<nav className="sticky top-4">
<h2 className="font-bold mb-4">Documentation</h2>
<ul className="space-y-2">
{navigation.map(item => (
<li key={item.path}>
<a
href={`/docs/${item.path}`}
className={`block px-3 py-2 rounded ${
path === item.path
? 'bg-blue-100 text-blue-700'
: 'hover:bg-gray-100'
}`}
>
{item.title}
</a>
{item.children && (
<ul className="ml-4 mt-2 space-y-1">
{item.children.map(child => (
<li key={child.path}>
<a
href={`/docs/${child.path}`}
className={`block px-3 py-1 rounded text-sm ${
path === child.path
? 'bg-blue-50 text-blue-600'
: 'hover:bg-gray-50'
}`}
>
{child.title}
</a>
</li>
))}
</ul>
)}
</li>
))}
</ul>
</nav>
</aside>
<main className="flex-1 max-w-4xl">
<article className="prose max-w-none">
<h1>{doc.title}</h1>
<MDXRemote source={doc.content} />
</article>
<div className="mt-12 pt-6 border-t flex justify-between">
{doc.prev && (
<a
href={`/docs/${doc.prev.path}`}
className="flex items-center gap-2 text-blue-600 hover:text-blue-700"
>
<span>←</span>
<div>
<div className="text-sm text-gray-600">Previous</div>
<div className="font-semibold">{doc.prev.title}</div>
</div>
</a>
)}
{doc.next && (
<a
href={`/docs/${doc.next.path}`}
className="flex items-center gap-2 text-blue-600 hover:text-blue-700 ml-auto"
>
<div className="text-right">
<div className="text-sm text-gray-600">Next</div>
<div className="font-semibold">{doc.next.title}</div>
</div>
<span>→</span>
</a>
)}
</div>
</main>
</div>
</div>
)
}
export async function generateStaticParams() {
const docs = await getAllDocs()
return docs.map(doc => ({
slug: doc.path.split('/')
}))
}
// lib/docs.ts
import fs from 'fs/promises'
import path from 'path'
import matter from 'gray-matter'
const docsDirectory = path.join(process.cwd(), 'content/docs')
export async function getDocBySlug(slug: string) {
try {
const fullPath = path.join(docsDirectory, `${slug}.mdx`)
const fileContents = await fs.readFile(fullPath, 'utf8')
const { data, content } = matter(fileContents)
return {
title: data.title,
content,
prev: data.prev,
next: data.next
}
} catch (error) {
return null
}
}
export async function getAllDocs() {
async function readDir(dir: string, prefix = ''): Promise<any[]> {
const entries = await fs.readdir(dir, { withFileTypes: true })
const docs = []
for (const entry of entries) {
const fullPath = path.join(dir, entry.name)
const relativePath = prefix ? `${prefix}/${entry.name}` : entry.name
if (entry.isDirectory()) {
const subDocs = await readDir(fullPath, relativePath)
docs.push(...subDocs)
} else if (entry.name.endsWith('.mdx')) {
const slug = relativePath.replace(/\.mdx$/, '')
docs.push({ path: slug })
}
}
return docs
}
return readDir(docsDirectory)
}
export async function getDocNavigation() {
// 实现文档导航结构
return [
{
title: 'Getting Started',
path: 'getting-started',
children: [
{ title: 'Installation', path: 'getting-started/installation' },
{ title: 'Quick Start', path: 'getting-started/quick-start' }
]
},
{
title: 'API Reference',
path: 'api',
children: [
{ title: 'Components', path: 'api/components' },
{ title: 'Hooks', path: 'api/hooks' }
]
}
]
}2.4 文件浏览器实现
typescript
// app/files/[[...path]]/page.tsx
import { getFileTree, getFileContent } from '@/lib/files'
import { notFound } from 'next/navigation'
interface PageProps {
params: Promise<{
path?: string[]
}>
}
export default async function FileBrowserPage({ params }: PageProps) {
const { path = [] } = await params
const currentPath = path.join('/')
const tree = await getFileTree(currentPath)
if (!tree) {
notFound()
}
if (tree.type === 'file') {
const content = await getFileContent(currentPath)
return (
<div className="container mx-auto p-4">
<nav className="mb-4 text-sm text-gray-600">
<a href="/files">Files</a>
{path.map((segment, index) => (
<span key={index}>
{' / '}
<a href={`/files/${path.slice(0, index + 1).join('/')}`}>
{segment}
</a>
</span>
))}
</nav>
<div className="border rounded-lg overflow-hidden">
<div className="bg-gray-100 px-4 py-2 border-b">
<h1 className="font-semibold">{path[path.length - 1]}</h1>
</div>
<pre className="p-4 overflow-auto">
<code>{content}</code>
</pre>
</div>
</div>
)
}
return (
<div className="container mx-auto p-4">
<nav className="mb-4 text-sm text-gray-600">
<a href="/files">Files</a>
{path.map((segment, index) => (
<span key={index}>
{' / '}
<a href={`/files/${path.slice(0, index + 1).join('/')}`}>
{segment}
</a>
</span>
))}
</nav>
<div className="border rounded-lg overflow-hidden">
<table className="w-full">
<thead className="bg-gray-100">
<tr>
<th className="px-4 py-2 text-left">Name</th>
<th className="px-4 py-2 text-left">Type</th>
<th className="px-4 py-2 text-left">Size</th>
<th className="px-4 py-2 text-left">Modified</th>
</tr>
</thead>
<tbody>
{tree.children?.map(item => (
<tr key={item.name} className="border-t hover:bg-gray-50">
<td className="px-4 py-2">
<a
href={`/files/${[...path, item.name].join('/')}`}
className="text-blue-600 hover:underline flex items-center gap-2"
>
<span>{item.type === 'directory' ? '📁' : '📄'}</span>
{item.name}
</a>
</td>
<td className="px-4 py-2 text-gray-600">
{item.type === 'directory' ? 'Folder' : 'File'}
</td>
<td className="px-4 py-2 text-gray-600">
{item.size ? `${(item.size / 1024).toFixed(2)} KB` : '-'}
</td>
<td className="px-4 py-2 text-gray-600">
{item.modified ? new Date(item.modified).toLocaleDateString() : '-'}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)
}三、查询参数 (Search Params)
3.1 基础查询参数
typescript
// app/search/page.tsx
interface PageProps {
searchParams: Promise<{
q?: string
category?: string
sort?: string
page?: string
}>
}
export default async function SearchPage({ searchParams }: PageProps) {
const params = await searchParams
const query = params.q || ''
const category = params.category || 'all'
const sort = params.sort || 'relevance'
const page = parseInt(params.page || '1', 10)
// 执行搜索
const results = await searchProducts({
query,
category,
sort,
page
})
return (
<div className="container mx-auto p-4">
<h1 className="text-3xl font-bold mb-6">
Search Results for "{query}"
</h1>
<div className="flex gap-8">
<aside className="w-64">
<div className="mb-6">
<h3 className="font-semibold mb-2">Category</h3>
<select
value={category}
onChange={(e) => {
const url = new URL(window.location.href)
url.searchParams.set('category', e.target.value)
window.location.href = url.toString()
}}
className="w-full p-2 border rounded"
>
<option value="all">All Categories</option>
<option value="electronics">Electronics</option>
<option value="clothing">Clothing</option>
<option value="books">Books</option>
</select>
</div>
<div className="mb-6">
<h3 className="font-semibold mb-2">Sort By</h3>
<select
value={sort}
onChange={(e) => {
const url = new URL(window.location.href)
url.searchParams.set('sort', e.target.value)
window.location.href = url.toString()
}}
className="w-full p-2 border rounded"
>
<option value="relevance">Relevance</option>
<option value="price-asc">Price: Low to High</option>
<option value="price-desc">Price: High to Low</option>
<option value="rating">Rating</option>
</select>
</div>
</aside>
<main className="flex-1">
<div className="mb-4 text-gray-600">
Found {results.total} results
</div>
<div className="grid md:grid-cols-3 gap-4">
{results.items.map((item: any) => (
<div key={item.id} className="border rounded p-4">
<img
src={item.image}
alt={item.name}
className="w-full h-48 object-cover rounded mb-2"
/>
<h3 className="font-semibold">{item.name}</h3>
<p className="text-blue-600">${item.price}</p>
</div>
))}
</div>
{results.totalPages > 1 && (
<div className="mt-6 flex justify-center gap-2">
{Array.from({ length: results.totalPages }, (_, i) => i + 1).map(p => (
<a
key={p}
href={`?q=${query}&category=${category}&sort=${sort}&page=${p}`}
className={`px-4 py-2 border rounded ${
p === page
? 'bg-blue-500 text-white'
: 'hover:bg-gray-100'
}`}
>
{p}
</a>
))}
</div>
)}
</main>
</div>
</div>
)
}3.2 客户端查询参数操作
typescript
// app/products/page.tsx
'use client'
import { useSearchParams, useRouter, usePathname } from 'next/navigation'
import { useCallback } from 'react'
export default function ProductsPage() {
const router = useRouter()
const pathname = usePathname()
const searchParams = useSearchParams()
const category = searchParams.get('category') || 'all'
const minPrice = searchParams.get('minPrice') || ''
const maxPrice = searchParams.get('maxPrice') || ''
const createQueryString = useCallback(
(name: string, value: string) => {
const params = new URLSearchParams(searchParams.toString())
params.set(name, value)
return params.toString()
},
[searchParams]
)
const handleCategoryChange = (newCategory: string) => {
router.push(pathname + '?' + createQueryString('category', newCategory))
}
const handlePriceFilter = () => {
const params = new URLSearchParams(searchParams.toString())
if (minPrice) params.set('minPrice', minPrice)
if (maxPrice) params.set('maxPrice', maxPrice)
router.push(pathname + '?' + params.toString())
}
const clearFilters = () => {
router.push(pathname)
}
return (
<div className="container mx-auto p-4">
<h1 className="text-3xl font-bold mb-6">Products</h1>
<div className="flex gap-8">
<aside className="w-64">
<div className="mb-6">
<h3 className="font-semibold mb-2">Category</h3>
<div className="space-y-2">
{['all', 'electronics', 'clothing', 'books'].map(cat => (
<label key={cat} className="flex items-center gap-2">
<input
type="radio"
checked={category === cat}
onChange={() => handleCategoryChange(cat)}
/>
<span className="capitalize">{cat}</span>
</label>
))}
</div>
</div>
<div className="mb-6">
<h3 className="font-semibold mb-2">Price Range</h3>
<div className="space-y-2">
<input
type="number"
placeholder="Min"
value={minPrice}
onChange={(e) => setMinPrice(e.target.value)}
className="w-full p-2 border rounded"
/>
<input
type="number"
placeholder="Max"
value={maxPrice}
onChange={(e) => setMaxPrice(e.target.value)}
className="w-full p-2 border rounded"
/>
<button
onClick={handlePriceFilter}
className="w-full px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600"
>
Apply
</button>
</div>
</div>
<button
onClick={clearFilters}
className="w-full px-4 py-2 border rounded hover:bg-gray-100"
>
Clear Filters
</button>
</aside>
<main className="flex-1">
{/* 产品列表 */}
</main>
</div>
</div>
)
}3.3 查询参数验证
typescript
// lib/searchParams.ts
import { z } from 'zod'
export const searchParamsSchema = z.object({
q: z.string().optional(),
category: z.enum(['all', 'electronics', 'clothing', 'books']).optional(),
minPrice: z.coerce.number().min(0).optional(),
maxPrice: z.coerce.number().min(0).optional(),
sort: z.enum(['relevance', 'price-asc', 'price-desc', 'rating']).optional(),
page: z.coerce.number().min(1).optional()
})
export type SearchParams = z.infer<typeof searchParamsSchema>
export function parseSearchParams(params: any): SearchParams {
const result = searchParamsSchema.safeParse(params)
if (!result.success) {
console.error('Invalid search params:', result.error)
return {}
}
return result.data
}
// app/search/page.tsx
import { parseSearchParams } from '@/lib/searchParams'
interface PageProps {
searchParams: Promise<any>
}
export default async function SearchPage({ searchParams }: PageProps) {
const params = parseSearchParams(await searchParams)
// params 现在是类型安全的
const {
q = '',
category = 'all',
minPrice,
maxPrice,
sort = 'relevance',
page = 1
} = params
return (
<div>
{/* 使用验证后的参数 */}
</div>
)
}3.4 URL 状态管理
typescript
// hooks/useUrlState.ts
'use client'
import { useSearchParams, useRouter, usePathname } from 'next/navigation'
import { useCallback } from 'react'
export function useUrlState<T extends Record<string, any>>(
defaultValues: T
): [T, (updates: Partial<T>) => void] {
const router = useRouter()
const pathname = usePathname()
const searchParams = useSearchParams()
const state = Object.keys(defaultValues).reduce((acc, key) => {
const value = searchParams.get(key)
acc[key] = value !== null ? value : defaultValues[key]
return acc
}, {} as T)
const setState = useCallback(
(updates: Partial<T>) => {
const params = new URLSearchParams(searchParams.toString())
Object.entries(updates).forEach(([key, value]) => {
if (value === undefined || value === null || value === '') {
params.delete(key)
} else {
params.set(key, String(value))
}
})
router.push(pathname + '?' + params.toString())
},
[router, pathname, searchParams]
)
return [state, setState]
}
// 使用示例
// app/products/ProductFilters.tsx
'use client'
import { useUrlState } from '@/hooks/useUrlState'
export function ProductFilters() {
const [filters, setFilters] = useUrlState({
category: 'all',
minPrice: '',
maxPrice: '',
sort: 'relevance'
})
return (
<div className="space-y-4">
<div>
<label className="block font-semibold mb-2">Category</label>
<select
value={filters.category}
onChange={(e) => setFilters({ category: e.target.value })}
className="w-full p-2 border rounded"
>
<option value="all">All</option>
<option value="electronics">Electronics</option>
<option value="clothing">Clothing</option>
</select>
</div>
<div>
<label className="block font-semibold mb-2">Price Range</label>
<div className="flex gap-2">
<input
type="number"
placeholder="Min"
value={filters.minPrice}
onChange={(e) => setFilters({ minPrice: e.target.value })}
className="flex-1 p-2 border rounded"
/>
<input
type="number"
placeholder="Max"
value={filters.maxPrice}
onChange={(e) => setFilters({ maxPrice: e.target.value })}
className="flex-1 p-2 border rounded"
/>
</div>
</div>
<div>
<label className="block font-semibold mb-2">Sort By</label>
<select
value={filters.sort}
onChange={(e) => setFilters({ sort: e.target.value })}
className="w-full p-2 border rounded"
>
<option value="relevance">Relevance</option>
<option value="price-asc">Price: Low to High</option>
<option value="price-desc">Price: High to Low</option>
</select>
</div>
<button
onClick={() => setFilters({
category: 'all',
minPrice: '',
maxPrice: '',
sort: 'relevance'
})}
className="w-full px-4 py-2 border rounded hover:bg-gray-100"
>
Clear Filters
</button>
</div>
)
}四、路由组与并行路由
4.1 路由组
使用 (folder) 创建路由组,不影响 URL 结构:
typescript
// 文件结构:
// app/
// (marketing)/
// about/page.tsx
// contact/page.tsx
// layout.tsx
// (shop)/
// products/page.tsx
// cart/page.tsx
// layout.tsx
// layout.tsx
// app/(marketing)/layout.tsx
export default function MarketingLayout({
children
}: {
children: React.ReactNode
}) {
return (
<div>
<nav className="bg-white border-b">
<div className="container mx-auto px-4 py-4">
<div className="flex items-center justify-between">
<a href="/" className="text-2xl font-bold">Brand</a>
<div className="flex gap-6">
<a href="/about">About</a>
<a href="/contact">Contact</a>
</div>
</div>
</div>
</nav>
<main>{children}</main>
<footer className="bg-gray-100 mt-12">
<div className="container mx-auto px-4 py-8">
<p className="text-center text-gray-600">
© 2024 Brand. All rights reserved.
</p>
</div>
</footer>
</div>
)
}
// app/(shop)/layout.tsx
export default function ShopLayout({
children
}: {
children: React.ReactNode
}) {
return (
<div>
<nav className="bg-blue-600 text-white">
<div className="container mx-auto px-4 py-4">
<div className="flex items-center justify-between">
<a href="/" className="text-2xl font-bold">Shop</a>
<div className="flex gap-6">
<a href="/products">Products</a>
<a href="/cart">Cart (0)</a>
</div>
</div>
</div>
</nav>
<main>{children}</main>
</div>
)
}URL 映射:
| 文件路径 | URL |
|---|---|
app/(marketing)/about/page.tsx | /about |
app/(marketing)/contact/page.tsx | /contact |
app/(shop)/products/page.tsx | /products |
app/(shop)/cart/page.tsx | /cart |
4.2 并行路由
使用 @folder 创建并行路由槽:
typescript
// 文件结构:
// app/
// dashboard/
// @analytics/page.tsx
// @team/page.tsx
// @activity/page.tsx
// layout.tsx
// page.tsx
// app/dashboard/layout.tsx
export default function DashboardLayout({
children,
analytics,
team,
activity
}: {
children: React.ReactNode
analytics: React.ReactNode
team: React.ReactNode
activity: React.ReactNode
}) {
return (
<div className="container mx-auto p-4">
<h1 className="text-4xl font-bold mb-6">Dashboard</h1>
<div className="grid grid-cols-2 gap-6 mb-6">
<div className="border rounded-lg p-4">
<h2 className="text-2xl font-semibold mb-4">Analytics</h2>
{analytics}
</div>
<div className="border rounded-lg p-4">
<h2 className="text-2xl font-semibold mb-4">Team</h2>
{team}
</div>
</div>
<div className="border rounded-lg p-4">
<h2 className="text-2xl font-semibold mb-4">Recent Activity</h2>
{activity}
</div>
<div className="mt-6">
{children}
</div>
</div>
)
}
// app/dashboard/@analytics/page.tsx
async function getAnalytics() {
const res = await fetch('https://api.example.com/analytics')
return res.json()
}
export default async function AnalyticsSlot() {
const data = await getAnalytics()
return (
<div className="space-y-4">
<div className="flex justify-between items-center">
<div>
<div className="text-sm text-gray-600">Total Users</div>
<div className="text-3xl font-bold">{data.totalUsers}</div>
</div>
<div className="text-green-600">+12%</div>
</div>
<div className="flex justify-between items-center">
<div>
<div className="text-sm text-gray-600">Revenue</div>
<div className="text-3xl font-bold">${data.revenue}</div>
</div>
<div className="text-green-600">+8%</div>
</div>
</div>
)
}
// app/dashboard/@team/page.tsx
async function getTeam() {
const res = await fetch('https://api.example.com/team')
return res.json()
}
export default async function TeamSlot() {
const team = await getTeam()
return (
<div className="space-y-2">
{team.members.map((member: any) => (
<div key={member.id} className="flex items-center gap-3">
<img
src={member.avatar}
alt={member.name}
className="w-10 h-10 rounded-full"
/>
<div>
<div className="font-semibold">{member.name}</div>
<div className="text-sm text-gray-600">{member.role}</div>
</div>
</div>
))}
</div>
)
}
// app/dashboard/@activity/page.tsx
async function getActivity() {
const res = await fetch('https://api.example.com/activity')
return res.json()
}
export default async function ActivitySlot() {
const activities = await getActivity()
return (
<div className="space-y-3">
{activities.map((activity: any) => (
<div key={activity.id} className="flex items-start gap-3">
<div className="w-2 h-2 mt-2 rounded-full bg-blue-500"></div>
<div className="flex-1">
<div className="font-semibold">{activity.title}</div>
<div className="text-sm text-gray-600">{activity.description}</div>
<div className="text-xs text-gray-500 mt-1">
{new Date(activity.timestamp).toLocaleString()}
</div>
</div>
</div>
))}
</div>
)
}
// app/dashboard/page.tsx
export default function DashboardPage() {
return (
<div className="text-center text-gray-600">
Select a section to view details
</div>
)
}4.3 条件路由
typescript
// app/dashboard/layout.tsx
import { getUser } from '@/lib/auth'
export default async function DashboardLayout({
children,
admin,
user
}: {
children: React.ReactNode
admin: React.ReactNode
user: React.ReactNode
}) {
const currentUser = await getUser()
return (
<div className="container mx-auto p-4">
<h1 className="text-4xl font-bold mb-6">Dashboard</h1>
{currentUser.role === 'admin' ? admin : user}
{children}
</div>
)
}
// app/dashboard/@admin/page.tsx
export default function AdminDashboard() {
return (
<div className="grid grid-cols-3 gap-4 mb-6">
<div className="border rounded p-4">
<h3 className="font-semibold mb-2">Total Users</h3>
<p className="text-3xl font-bold">1,234</p>
</div>
<div className="border rounded p-4">
<h3 className="font-semibold mb-2">Active Sessions</h3>
<p className="text-3xl font-bold">567</p>
</div>
<div className="border rounded p-4">
<h3 className="font-semibold mb-2">System Health</h3>
<p className="text-3xl font-bold text-green-600">Good</p>
</div>
</div>
)
}
// app/dashboard/@user/page.tsx
export default function UserDashboard() {
return (
<div className="border rounded p-6 mb-6">
<h2 className="text-2xl font-semibold mb-4">Welcome Back!</h2>
<p className="text-gray-600">
Here's what's new since your last visit.
</p>
</div>
)
}4.4 默认槽
typescript
// app/dashboard/@analytics/default.tsx
export default function DefaultAnalytics() {
return (
<div className="text-center text-gray-500 py-8">
Loading analytics...
</div>
)
}
// app/dashboard/@team/default.tsx
export default function DefaultTeam() {
return (
<div className="text-center text-gray-500 py-8">
Loading team...
</div>
)
}五、路由拦截
5.1 基础路由拦截
使用 (.) 拦截同级路由:
typescript
// 文件结构:
// app/
// photos/
// [id]/
// page.tsx
// (.)[id]/
// page.tsx
// page.tsx
// app/photos/page.tsx
import Link from 'next/link'
async function getPhotos() {
const res = await fetch('https://api.example.com/photos')
return res.json()
}
export default async function PhotosPage() {
const photos = await getPhotos()
return (
<div className="container mx-auto p-4">
<h1 className="text-4xl font-bold mb-6">Photos</h1>
<div className="grid grid-cols-4 gap-4">
{photos.map((photo: any) => (
<Link
key={photo.id}
href={`/photos/${photo.id}`}
className="aspect-square overflow-hidden rounded-lg hover:opacity-80 transition"
>
<img
src={photo.url}
alt={photo.title}
className="w-full h-full object-cover"
/>
</Link>
))}
</div>
</div>
)
}
// app/photos/(.)[id]/page.tsx - 模态框视图
'use client'
import { useRouter } from 'next/navigation'
import { useEffect, useState } from 'react'
interface PageProps {
params: Promise<{ id: string }>
}
export default function PhotoModal({ params }: PageProps) {
const router = useRouter()
const [photo, setPhoto] = useState<any>(null)
useEffect(() => {
params.then(({ id }) => {
fetch(`https://api.example.com/photos/${id}`)
.then(r => r.json())
.then(setPhoto)
})
}, [params])
if (!photo) return null
return (
<div
className="fixed inset-0 bg-black bg-opacity-75 flex items-center justify-center z-50"
onClick={() => router.back()}
>
<div
className="max-w-4xl max-h-[90vh] bg-white rounded-lg overflow-hidden"
onClick={(e) => e.stopPropagation()}
>
<div className="p-4 border-b flex justify-between items-center">
<h2 className="text-xl font-semibold">{photo.title}</h2>
<button
onClick={() => router.back()}
className="text-gray-500 hover:text-gray-700"
>
✕
</button>
</div>
<img
src={photo.url}
alt={photo.title}
className="w-full"
/>
<div className="p-4">
<p className="text-gray-600">{photo.description}</p>
</div>
</div>
</div>
)
}
// app/photos/[id]/page.tsx - 完整页面视图
async function getPhoto(id: string) {
const res = await fetch(`https://api.example.com/photos/${id}`)
return res.json()
}
interface PageProps {
params: Promise<{ id: string }>
}
export default async function PhotoPage({ params }: PageProps) {
const { id } = await params
const photo = await getPhoto(id)
return (
<div className="container mx-auto p-4 max-w-4xl">
<a href="/photos" className="text-blue-600 hover:underline mb-4 block">
← Back to Photos
</a>
<h1 className="text-4xl font-bold mb-6">{photo.title}</h1>
<img
src={photo.url}
alt={photo.title}
className="w-full rounded-lg mb-6"
/>
<p className="text-gray-600 text-lg">{photo.description}</p>
</div>
)
}5.2 拦截级别
typescript
// (.) - 同级
// app/feed/(.)[id]/page.tsx 拦截 app/feed/[id]/page.tsx
// (..) - 上一级
// app/(..)photos/[id]/page.tsx 拦截 app/photos/[id]/page.tsx
// (...) - 根级
// app/feed/(...)photos/[id]/page.tsx 拦截 app/photos/[id]/page.tsx
// 示例: 产品快速查看
// 文件结构:
// app/
// products/
// [id]/
// page.tsx
// page.tsx
// category/
// [slug]/
// (..)(..)/products/[id]/
// page.tsx
// page.tsx
// app/category/[slug]/(..)(..)/products/[id]/page.tsx
'use client'
import { useRouter } from 'next/navigation'
import { useEffect, useState } from 'react'
interface PageProps {
params: Promise<{ id: string }>
}
export default function QuickView({ params }: PageProps) {
const router = useRouter()
const [product, setProduct] = useState<any>(null)
useEffect(() => {
params.then(({ id }) => {
fetch(`https://api.example.com/products/${id}`)
.then(r => r.json())
.then(setProduct)
})
}, [params])
if (!product) return null
return (
<div
className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50"
onClick={() => router.back()}
>
<div
className="bg-white rounded-lg p-6 max-w-2xl w-full mx-4"
onClick={(e) => e.stopPropagation()}
>
<div className="flex justify-between items-start mb-4">
<h2 className="text-2xl font-bold">{product.name}</h2>
<button
onClick={() => router.back()}
className="text-gray-500 hover:text-gray-700"
>
✕
</button>
</div>
<div className="grid md:grid-cols-2 gap-6">
<img
src={product.image}
alt={product.name}
className="w-full rounded-lg"
/>
<div>
<p className="text-3xl font-bold text-blue-600 mb-4">
${product.price}
</p>
<p className="text-gray-600 mb-6">{product.description}</p>
<button className="w-full px-6 py-3 bg-blue-500 text-white rounded-lg hover:bg-blue-600">
Add to Cart
</button>
<a
href={`/products/${product.id}`}
className="block text-center mt-4 text-blue-600 hover:underline"
>
View Full Details
</a>
</div>
</div>
</div>
</div>
)
}5.3 登录模态框
typescript
// 文件结构:
// app/
// login/
// page.tsx
// (.)login/
// page.tsx
// app/(.)login/page.tsx
'use client'
import { useRouter } from 'next/navigation'
import { useState } from 'react'
export default function LoginModal() {
const router = useRouter()
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
// 处理登录逻辑
console.log('Login:', { email, password })
router.back()
}
return (
<div
className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50"
onClick={() => router.back()}
>
<div
className="bg-white rounded-lg p-8 max-w-md w-full mx-4"
onClick={(e) => e.stopPropagation()}
>
<div className="flex justify-between items-center mb-6">
<h2 className="text-2xl font-bold">Login</h2>
<button
onClick={() => router.back()}
className="text-gray-500 hover:text-gray-700"
>
✕
</button>
</div>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block font-semibold mb-2">Email</label>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
className="w-full p-3 border rounded-lg"
/>
</div>
<div>
<label className="block font-semibold mb-2">Password</label>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
className="w-full p-3 border rounded-lg"
/>
</div>
<button
type="submit"
className="w-full px-6 py-3 bg-blue-500 text-white rounded-lg hover:bg-blue-600"
>
Login
</button>
</form>
<p className="text-center mt-4 text-sm text-gray-600">
Don't have an account?{' '}
<a href="/signup" className="text-blue-600 hover:underline">
Sign up
</a>
</p>
</div>
</div>
)
}
// app/login/page.tsx
export default function LoginPage() {
return (
<div className="min-h-screen flex items-center justify-center bg-gray-100">
<div className="bg-white rounded-lg p-8 max-w-md w-full">
<h1 className="text-3xl font-bold mb-6">Login</h1>
<form className="space-y-4">
<div>
<label className="block font-semibold mb-2">Email</label>
<input
type="email"
required
className="w-full p-3 border rounded-lg"
/>
</div>
<div>
<label className="block font-semibold mb-2">Password</label>
<input
type="password"
required
className="w-full p-3 border rounded-lg"
/>
</div>
<button
type="submit"
className="w-full px-6 py-3 bg-blue-500 text-white rounded-lg hover:bg-blue-600"
>
Login
</button>
</form>
</div>
</div>
)
}六、路由优先级与匹配
6.1 路由优先级规则
Next.js 按以下优先级匹配路由:
1. 静态路由 (最高优先级)
/about/page.tsx
2. 动态路由
/posts/[id]/page.tsx
3. 捕获所有段
/docs/[...slug]/page.tsx
4. 可选捕获所有段 (最低优先级)
/shop/[[...slug]]/page.tsx示例:
typescript
// 文件结构:
// app/
// blog/
// page.tsx // 匹配 /blog
// new/page.tsx // 匹配 /blog/new (优先于 [slug])
// [slug]/page.tsx // 匹配 /blog/anything
// [slug]/edit/page.tsx // 匹配 /blog/anything/edit
// [...slug]/page.tsx // 不会被匹配 (被上面的规则覆盖)
// URL 匹配示例:
// /blog → app/blog/page.tsx
// /blog/new → app/blog/new/page.tsx
// /blog/hello → app/blog/[slug]/page.tsx
// /blog/hello/edit → app/blog/[slug]/edit/page.tsx6.2 路由冲突处理
typescript
// 错误示例 - 路由冲突
// app/
// products/
// [id]/page.tsx
// [slug]/page.tsx // 错误! 与 [id] 冲突
// 正确示例 - 使用不同的路径结构
// app/
// products/
// id/
// [id]/page.tsx
// slug/
// [slug]/page.tsx
// 或者使用路由组
// app/
// products/
// (by-id)/
// [id]/page.tsx
// (by-slug)/
// [slug]/page.tsx6.3 复杂路由示例
typescript
// 文件结构:
// app/
// (shop)/
// products/
// page.tsx // /products
// new/page.tsx // /products/new
// [id]/page.tsx // /products/123
// [id]/edit/page.tsx // /products/123/edit
// [id]/reviews/page.tsx // /products/123/reviews
// [id]/reviews/[reviewId]/page.tsx // /products/123/reviews/456
// categories/
// [[...slug]]/page.tsx // /categories, /categories/electronics, /categories/electronics/phones
// app/(shop)/products/[id]/page.tsx
interface PageProps {
params: Promise<{ id: string }>
}
export default async function ProductPage({ params }: PageProps) {
const { id } = await params
return (
<div className="container mx-auto p-4">
<nav className="mb-4 text-sm text-gray-600">
<a href="/products">Products</a> / Product {id}
</nav>
<h1 className="text-4xl font-bold">Product {id}</h1>
</div>
)
}
// app/(shop)/products/[id]/reviews/[reviewId]/page.tsx
interface PageProps {
params: Promise<{
id: string
reviewId: string
}>
}
export default async function ReviewPage({ params }: PageProps) {
const { id, reviewId } = await params
return (
<div className="container mx-auto p-4">
<nav className="mb-4 text-sm text-gray-600">
<a href="/products">Products</a> /
<a href={`/products/${id}`}>Product {id}</a> /
<a href={`/products/${id}/reviews`}>Reviews</a> /
Review {reviewId}
</nav>
<h1 className="text-4xl font-bold">
Review {reviewId} for Product {id}
</h1>
</div>
)
}七、静态参数生成
7.1 基础 generateStaticParams
typescript
// app/posts/[id]/page.tsx
interface Post {
id: string
title: string
content: string
}
async function getPost(id: string): Promise<Post> {
const res = await fetch(`https://api.example.com/posts/${id}`)
return res.json()
}
interface PageProps {
params: Promise<{ id: string }>
}
export default async function PostPage({ params }: PageProps) {
const { id } = await params
const post = await getPost(id)
return (
<article className="container mx-auto p-4 max-w-3xl">
<h1 className="text-4xl font-bold mb-6">{post.title}</h1>
<div className="prose max-w-none">{post.content}</div>
</article>
)
}
// 生成静态参数
export async function generateStaticParams() {
const res = await fetch('https://api.example.com/posts')
const posts: Post[] = await res.json()
return posts.map(post => ({
id: post.id
}))
}
// 配置动态参数行为
export const dynamicParams = true // 允许动态参数 (默认)
// export const dynamicParams = false // 只允许预生成的参数7.2 多级动态路由
typescript
// app/blog/[category]/[slug]/page.tsx
interface Post {
category: string
slug: string
title: string
content: string
}
async function getPost(category: string, slug: string): Promise<Post> {
const res = await fetch(
`https://api.example.com/posts/${category}/${slug}`
)
return res.json()
}
interface PageProps {
params: Promise<{
category: string
slug: string
}>
}
export default async function BlogPostPage({ params }: PageProps) {
const { category, slug } = await params
const post = await getPost(category, slug)
return (
<article className="container mx-auto p-4 max-w-3xl">
<nav className="mb-4 text-sm text-gray-600">
<a href="/blog">Blog</a> /
<a href={`/blog/${category}`}>{category}</a> /
{slug}
</nav>
<h1 className="text-4xl font-bold mb-6">{post.title}</h1>
<div className="prose max-w-none">{post.content}</div>
</article>
)
}
export async function generateStaticParams() {
const res = await fetch('https://api.example.com/posts')
const posts: Post[] = await res.json()
return posts.map(post => ({
category: post.category,
slug: post.slug
}))
}7.3 父子路由参数生成
typescript
// app/categories/[category]/products/[id]/page.tsx
interface PageProps {
params: Promise<{
category: string
id: string
}>
}
export default async function ProductPage({ params }: PageProps) {
const { category, id } = await params
return (
<div>
Product {id} in {category}
</div>
)
}
// 父路由生成参数
// app/categories/[category]/page.tsx
export async function generateStaticParams() {
return [
{ category: 'electronics' },
{ category: 'clothing' },
{ category: 'books' }
]
}
// 子路由生成参数 (接收父路由参数)
// app/categories/[category]/products/[id]/page.tsx
export async function generateStaticParams({
params
}: {
params: { category: string }
}) {
const res = await fetch(
`https://api.example.com/categories/${params.category}/products`
)
const products = await res.json()
return products.map((product: any) => ({
id: product.id
}))
}7.4 增量静态生成
typescript
// app/posts/[id]/page.tsx
export const revalidate = 60 // 每60秒重新验证
export async function generateStaticParams() {
// 只预生成最热门的文章
const res = await fetch('https://api.example.com/posts/popular?limit=10')
const posts = await res.json()
return posts.map((post: any) => ({
id: post.id
}))
}
// 允许动态参数
export const dynamicParams = true
interface PageProps {
params: Promise<{ id: string }>
}
export default async function PostPage({ params }: PageProps) {
const { id } = await params
// 如果 ID 不在预生成列表中,会在首次访问时生成
const res = await fetch(`https://api.example.com/posts/${id}`)
const post = await res.json()
return (
<article>
<h1>{post.title}</h1>
<div>{post.content}</div>
</article>
)
}八、实战案例
8.1 多语言博客系统
typescript
// 文件结构:
// app/
// [lang]/
// blog/
// [[...slug]]/
// page.tsx
// layout.tsx
// app/[lang]/layout.tsx
import { notFound } from 'next/navigation'
const languages = ['en', 'zh', 'ja', 'ko']
interface LayoutProps {
children: React.ReactNode
params: Promise<{ lang: string }>
}
export default async function LangLayout({ children, params }: LayoutProps) {
const { lang } = await params
if (!languages.includes(lang)) {
notFound()
}
return (
<html lang={lang}>
<body>
<nav className="bg-white border-b">
<div className="container mx-auto px-4 py-4">
<div className="flex items-center justify-between">
<a href={`/${lang}`} className="text-2xl font-bold">
Blog
</a>
<div className="flex gap-4">
{languages.map(l => (
<a
key={l}
href={`/${l}/blog`}
className={`px-3 py-1 rounded ${
l === lang
? 'bg-blue-500 text-white'
: 'hover:bg-gray-100'
}`}
>
{l.toUpperCase()}
</a>
))}
</div>
</div>
</div>
</nav>
{children}
</body>
</html>
)
}
export async function generateStaticParams() {
return languages.map(lang => ({ lang }))
}
// app/[lang]/blog/[[...slug]]/page.tsx
import { notFound } from 'next/navigation'
interface PageProps {
params: Promise<{
lang: string
slug?: string[]
}>
}
async function getPost(lang: string, slug: string[]) {
const path = slug.join('/')
const res = await fetch(
`https://api.example.com/${lang}/posts/${path}`
)
if (!res.ok) return null
return res.json()
}
async function getPosts(lang: string) {
const res = await fetch(`https://api.example.com/${lang}/posts`)
return res.json()
}
export default async function BlogPage({ params }: PageProps) {
const { lang, slug } = await params
if (!slug || slug.length === 0) {
// 博客列表
const posts = await getPosts(lang)
return (
<div className="container mx-auto p-4">
<h1 className="text-4xl font-bold mb-6">
{lang === 'en' && 'Blog Posts'}
{lang === 'zh' && '博客文章'}
{lang === 'ja' && 'ブログ記事'}
{lang === 'ko' && '블로그 게시물'}
</h1>
<div className="grid md:grid-cols-3 gap-6">
{posts.map((post: any) => (
<a
key={post.slug}
href={`/${lang}/blog/${post.slug}`}
className="border rounded-lg p-4 hover:shadow-lg transition"
>
<h2 className="text-xl font-semibold mb-2">{post.title}</h2>
<p className="text-gray-600">{post.excerpt}</p>
</a>
))}
</div>
</div>
)
}
// 单篇文章
const post = await getPost(lang, slug)
if (!post) {
notFound()
}
return (
<article className="container mx-auto p-4 max-w-3xl">
<h1 className="text-4xl font-bold mb-6">{post.title}</h1>
<div className="prose max-w-none">{post.content}</div>
</article>
)
}
export async function generateStaticParams() {
const languages = ['en', 'zh', 'ja', 'ko']
const params = []
for (const lang of languages) {
const res = await fetch(`https://api.example.com/${lang}/posts`)
const posts = await res.json()
// 博客列表页
params.push({ lang, slug: undefined })
// 各个文章页
for (const post of posts) {
params.push({
lang,
slug: post.slug.split('/')
})
}
}
return params
}8.2 电商产品路由
typescript
// 文件结构:
// app/
// shop/
// [[...path]]/
// page.tsx
// app/shop/[[...path]]/page.tsx
import { notFound } from 'next/navigation'
interface PageProps {
params: Promise<{
path?: string[]
}>
searchParams: Promise<{
sort?: string
minPrice?: string
maxPrice?: string
page?: string
}>
}
async function getCategories() {
const res = await fetch('https://api.example.com/categories')
return res.json()
}
async function getProducts(filters: any) {
const params = new URLSearchParams(filters)
const res = await fetch(`https://api.example.com/products?${params}`)
return res.json()
}
async function getProduct(id: string) {
const res = await fetch(`https://api.example.com/products/${id}`)
if (!res.ok) return null
return res.json()
}
export default async function ShopPage({ params, searchParams }: PageProps) {
const { path = [] } = await params
const filters = await searchParams
// 路由: /shop
if (path.length === 0) {
const categories = await getCategories()
return (
<div className="container mx-auto p-4">
<h1 className="text-4xl font-bold mb-6">Shop</h1>
<div className="grid md:grid-cols-4 gap-6">
{categories.map((category: any) => (
<a
key={category.slug}
href={`/shop/${category.slug}`}
className="border rounded-lg p-6 hover:shadow-lg transition"
>
<img
src={category.image}
alt={category.name}
className="w-full h-48 object-cover rounded mb-4"
/>
<h2 className="text-xl font-semibold">{category.name}</h2>
<p className="text-gray-600">{category.count} products</p>
</a>
))}
</div>
</div>
)
}
// 路由: /shop/[category]
if (path.length === 1) {
const [category] = path
const products = await getProducts({ category, ...filters })
return (
<div className="container mx-auto p-4">
<nav className="mb-4 text-sm text-gray-600">
<a href="/shop">Shop</a> / {category}
</nav>
<h1 className="text-4xl font-bold mb-6 capitalize">{category}</h1>
<div className="flex gap-8">
<aside className="w-64">
<div className="mb-6">
<h3 className="font-semibold mb-2">Sort By</h3>
<select className="w-full p-2 border rounded">
<option value="relevance">Relevance</option>
<option value="price-asc">Price: Low to High</option>
<option value="price-desc">Price: High to Low</option>
</select>
</div>
</aside>
<main className="flex-1">
<div className="grid md:grid-cols-3 gap-6">
{products.items.map((product: any) => (
<a
key={product.id}
href={`/shop/${category}/${product.slug}`}
className="border rounded-lg overflow-hidden hover:shadow-lg transition"
>
<img
src={product.image}
alt={product.name}
className="w-full h-48 object-cover"
/>
<div className="p-4">
<h3 className="font-semibold mb-2">{product.name}</h3>
<p className="text-blue-600 font-bold">${product.price}</p>
</div>
</a>
))}
</div>
</main>
</div>
</div>
)
}
// 路由: /shop/[category]/[slug]
if (path.length === 2) {
const [category, slug] = path
const product = await getProduct(slug)
if (!product) {
notFound()
}
return (
<div className="container mx-auto p-4">
<nav className="mb-4 text-sm text-gray-600">
<a href="/shop">Shop</a> /
<a href={`/shop/${category}`}>{category}</a> /
{product.name}
</nav>
<div className="grid md:grid-cols-2 gap-8">
<div>
<img
src={product.image}
alt={product.name}
className="w-full rounded-lg"
/>
</div>
<div>
<h1 className="text-4xl font-bold mb-4">{product.name}</h1>
<p className="text-3xl text-blue-600 mb-6">${product.price}</p>
<p className="text-gray-600 mb-6">{product.description}</p>
<button className="w-full px-6 py-3 bg-blue-500 text-white rounded-lg hover:bg-blue-600">
Add to Cart
</button>
</div>
</div>
</div>
)
}
notFound()
}
export async function generateStaticParams() {
const categories = await getCategories()
const params = []
// 生成分类页
for (const category of categories) {
params.push({ path: [category.slug] })
// 生成产品页
const products = await getProducts({ category: category.slug })
for (const product of products.items) {
params.push({ path: [category.slug, product.slug] })
}
}
return params
}九、总结与最佳实践
9.1 路由设计原则
typescript
// 1. 保持 URL 结构清晰
// 好: /blog/2024/nextjs-15
// 坏: /b/24/n15
// 2. 使用语义化路径
// 好: /products/electronics/phones
// 坏: /p/1/2
// 3. 避免过深的嵌套
// 好: /docs/api/components
// 坏: /docs/v1/api/reference/components/ui/buttons
// 4. 使用一致的命名
// 好: /products/[id] 和 /users/[id]
// 坏: /products/[productId] 和 /users/[userId]
// 5. 合理使用捕获所有段
// 好: /docs/[[...slug]] (灵活的文档系统)
// 坏: /[[...path]] (过于宽泛)9.2 性能优化
typescript
// 1. 使用 generateStaticParams 预生成热门页面
export async function generateStaticParams() {
const popular = await getPopularPosts()
return popular.map(post => ({ id: post.id }))
}
// 2. 启用增量静态生成
export const revalidate = 3600 // 1小时
// 3. 合理使用 dynamicParams
export const dynamicParams = true // 允许动态生成
// 4. 并行数据获取
const [post, comments, related] = await Promise.all([
getPost(id),
getComments(id),
getRelatedPosts(id)
])
// 5. 使用 Suspense 流式渲染
<Suspense fallback={<Loading />}>
<SlowComponent />
</Suspense>9.3 常见问题
| 问题 | 原因 | 解决方案 |
|---|---|---|
| 404 错误 | 路由不匹配 | 检查文件结构和参数名称 |
| 参数为 undefined | 未正确 await params | 使用 const { id } = await params |
| 路由冲突 | 多个动态段冲突 | 使用路由组或调整结构 |
| 静态生成失败 | generateStaticParams 错误 | 检查返回格式和数据 |
| 性能问题 | 过度动态渲染 | 使用静态生成和缓存 |
9.4 学习资源
官方文档
实践项目
- 多语言博客
- 电商平台
- 文档系统
- 文件浏览器
课后练习
- 创建一个多级分类的电商路由系统
- 实现一个带模态框的图片画廊
- 构建一个多语言文档系统
- 开发一个文件浏览器应用
- 实现一个带筛选的产品搜索页面
通过本课程的学习,你应该能够熟练掌握 Next.js 中的动态路由和参数处理,并能根据实际需求设计合理的路由结构。记住:良好的路由设计是用户体验的基础!