要点
URL 决定页面身份,嵌套布局决定共享范围。路由参数、查询参数与组件内部状态用途不同;需要分享或刷新保留的筛选往往属于 URL。
路由系统掌握
- 深入理解 Next.js 16 App Router 的文件系统路由
- 掌握嵌套路由和布局的创建方法
- 学会动态路由和路由参数处理
- 理解路由组和路由的高级特性
布局架构设计
- 掌握共享布局和嵌套布局的设计模式
- 学会创建复杂的页面布局结构
- 理解布局状态保持和生命周期
- 掌握响应式布局和主题切换
概述
Next.js 16的 App Router 基于文件系统提供了强大的路由和布局功能。通过文件夹结构自动生成路由,支持嵌套布局、动态路由、并行路由等高级特性,让复杂应用的架构变得简单直观。
App Router 基础架构
文件系统路由约定
src/app/
├── layout.tsx # 根布局 (应用于整个应用)
├── page.tsx # 首页 (/)
├── loading.tsx # 根加载组件
├── error.tsx # 根错误边界
├── not-found.tsx # 根404页面
├── about/
│ ├── layout.tsx # 关于页面布局
│ ├── page.tsx # 关于页面 (/about)
│ └── team/
│ └── page.tsx # 团队页面 (/about/team)
├── blog/
│ ├── layout.tsx # 博客布局
│ ├── page.tsx # 博客列表 (/blog)
│ ├── [slug]/
│ │ ├── page.tsx # 文章详情 (/blog/[slug])
│ │ └── loading.tsx # 文章加载状态
│ └── (auth)/
│ ├── layout.tsx # 路由组布局 (不影响URL)
│ └── login/
│ └── page.tsx # 登录页面 (/blog/login)
└── dashboard/
├── layout.tsx # 仪表板布局
├── page.tsx # 仪表板首页 (/dashboard)
├── @analytics/
│ └── page.tsx # 分析页面 (并行路由)
└── @settings/
└── page.tsx # 设置页面 (并行路由)
根布局配置
// src/app/layout.tsx
import type { Metadata } from 'next'
import { Inter } from 'next/font/google'
import { Navigation } from '@/components/Navigation'
import { Footer } from '@/components/Footer'
import './globals.css'
const inter = Inter({ subsets: ['latin'] })
export const metadata: Metadata = {
title: {
default: '我的Next.js应用',
template: '%s | 我的Next.js应用'
},
description: '使用Next.js 16构建的现代化Web应用',
keywords: ['Next.js', 'React', 'TypeScript', 'Web开发'],
authors: [{ name: 'Hello Team' }],
creator: 'Hello Team',
openGraph: {
type: 'website',
locale: 'zh_CN',
url: 'https://myapp.com',
title: '我的Next.js应用',
description: '使用Next.js 16构建的现代化Web应用',
},
twitter: {
card: 'summary_large_image',
title: '我的Next.js应用',
description: '使用Next.js 16构建的现代化Web应用',
},
robots: {
index: true,
follow: true,
},
}
export default function RootLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<html lang="zh-CN" className={inter.className}>
<body className="min-h-screen bg-gray-50 antialiased">
<div className="flex flex-col min-h-screen">
<Navigation />
<main className="flex-1">
{children}
</main>
<Footer />
</div>
</body>
</html>
)
}
基础路由模式
静态路由
// src/app/about/page.tsx
import { Metadata } from 'next'
export const metadata: Metadata = {
title: '关于我们',
description: '了解我们的团队和使命',
}
export default function AboutPage() {
return (
<div className="max-w-4xl mx-auto py-12 px-4">
<h1 className="text-4xl font-bold text-gray-900 mb-8">
关于我们
</h1>
<div className="prose prose-lg max-w-none">
<p className="text-xl text-gray-600 mb-6">
我们是一个充满激情的技术团队,致力于构建优秀的Web应用。
</p>
<div className="grid grid-cols-1 md:grid-cols-2 gap-8 my-12">
<div className="bg-white p-6 rounded-lg shadow-md">
<h3 className="text-xl font-semibold mb-4">🎯 我们的使命</h3>
<p className="text-gray-600">
通过技术创新,为用户提供卓越的数字体验。
</p>
</div>
<div className="bg-white p-6 rounded-lg shadow-md">
<h3 className="text-xl font-semibold mb-4">🚀 我们的愿景</h3>
<p className="text-gray-600">
成为行业领先的技术解决方案提供商。
</p>
</div>
</div>
</div>
</div>
)
}
嵌套路由和布局
// src/app/blog/layout.tsx
import { Metadata } from 'next'
import { BlogSidebar } from '@/components/BlogSidebar'
export const metadata: Metadata = {
title: '博客',
description: '分享技术见解和开发经验',
}
export default function BlogLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<div className="max-w-6xl mx-auto py-8 px-4">
<div className="grid grid-cols-1 lg:grid-cols-4 gap-8">
<aside className="lg:col-span-1">
<BlogSidebar />
</aside>
<main className="lg:col-span-3">
{children}
</main>
</div>
</div>
)
}
// src/app/blog/page.tsx
import Link from 'next/link'
import { blogPosts } from '@/lib/blog-data'
export default function BlogListPage() {
return (
<div>
<h1 className="text-3xl font-bold text-gray-900 mb-8">
技术博客
</h1>
<div className="space-y-8">
{blogPosts.map((post) => (
<article key={post.id} className="bg-white p-6 rounded-lg shadow-md">
<div className="flex justify-between items-start mb-4">
<div className="flex-1">
<h2 className="text-xl font-semibold text-gray-900 mb-2">
<Link
href={`/blog/${post.slug}`}
className="hover:text-blue-600 transition"
>
{post.title}
</Link>
</h2>
<p className="text-gray-600 mb-2">{post.excerpt}</p>
<div className="flex items-center text-sm text-gray-500">
<span>{post.author}</span>
<span className="mx-2">•</span>
<time>{post.publishedAt}</time>
</div>
</div>
</div>
</article>
))}
</div>
</div>
)
}
动态路由
基础动态路由
// src/app/blog/[slug]/page.tsx
import { Metadata } from 'next'
import { notFound } from 'next/navigation'
import { getPostBySlug, getAllPostSlugs } from '@/lib/blog-data'
import { BlogContent } from '@/components/BlogContent'
interface PostPageProps {
// Next.js 16:params 为 Promise
params: Promise<{
slug: string
}>
}
// 生成静态参数
export async function generateStaticParams() {
const posts = getAllPostSlugs()
return posts.map((slug) => ({
slug,
}))
}
// 生成元数据
export async function generateMetadata({ params }: PostPageProps): Promise<Metadata> {
const { slug } = await params
const post = await getPostBySlug(slug)
if (!post) {
return {
title: '文章不存在',
}
}
return {
title: post.title,
description: post.excerpt,
openGraph: {
title: post.title,
description: post.excerpt,
type: 'article',
publishedTime: post.publishedAt,
authors: [post.author],
},
}
}
export default async function PostPage({ params }: PostPageProps) {
const { slug } = await params
const post = await getPostBySlug(slug)
if (!post) {
notFound()
}
return (
<article className="max-w-4xl mx-auto">
<BlogContent post={post} />
</article>
)
}
多参数动态路由
// src/app/users/[userId]/posts/[postId]/page.tsx
import { Metadata } from 'next'
import { notFound } from 'next/navigation'
import { getUserPost } from '@/lib/api'
interface UserPostPageProps {
// Next.js 16:params 为 Promise
params: Promise<{
userId: string
postId: string
}>
}
export async function generateMetadata({ params }: UserPostPageProps): Promise<Metadata> {
const { userId, postId } = await params
const post = await getUserPost(userId, postId)
return {
title: post?.title || '文章不存在',
description: post?.content?.substring(0, 160) || '',
}
}
export default async function UserPostPage({ params }: UserPostPageProps) {
const { userId, postId } = await params
const post = await getUserPost(userId, postId)
if (!post) {
notFound()
}
return (
<div className="max-w-4xl mx-auto py-8">
<div className="mb-6">
<nav className="text-sm text-gray-500">
<Link href="/users" className="hover:text-gray-700">用户</Link>
<span className="mx-2">/</span>
<Link href={`/users/${userId}`} className="hover:text-gray-700">
用户 {userId}
</Link>
<span className="mx-2">/</span>
<span className="text-gray-900">文章 {postId}</span>
</nav>
</div>
<article>
<h1 className="text-3xl font-bold text-gray-900 mb-4">
{post.title}
</h1>
<div className="text-gray-600 mb-8">
作者:{post.author} | 发布时间:{post.publishedAt}
</div>
<div
className="prose prose-lg max-w-none"
dangerouslySetInnerHTML={{ __html: post.content }}
/>
</article>
</div>
)
}
高级布局模式
条件布局
// src/app/dashboard/layout.tsx
import { redirect } from 'next/navigation'
import { getServerSession } from 'next-auth'
import { DashboardSidebar } from '@/components/DashboardSidebar'
import { DashboardHeader } from '@/components/DashboardHeader'
export default async function DashboardLayout({
children,
}: {
children: React.ReactNode
}) {
// 检查用户认证状态
const session = await getServerSession()
if (!session) {
redirect('/login')
}
return (
<div className="min-h-screen bg-gray-100">
<DashboardHeader user={session.user} />
<div className="flex">
<DashboardSidebar />
<main className="flex-1 p-6">
{children}
</main>
</div>
</div>
)
}
响应式布局
// src/components/ResponsiveLayout.tsx
'use client'
import { useState, useEffect } from 'react'
import { Sidebar } from './Sidebar'
import { Header } from './Header'
interface ResponsiveLayoutProps {
children: React.ReactNode
}
export function ResponsiveLayout({ children }: ResponsiveLayoutProps) {
const [sidebarOpen, setSidebarOpen] = useState(false)
const [isMobile, setIsMobile] = useState(false)
useEffect(() => {
const checkMobile = () => {
setIsMobile(window.innerWidth < 768)
if (window.innerWidth >= 768) {
setSidebarOpen(false)
}
}
checkMobile()
window.addEventListener('resize', checkMobile)
return () => window.removeEventListener('resize', checkMobile)
}, [])
return (
<div className="min-h-screen bg-gray-50">
<Header
onMenuClick={() => setSidebarOpen(!sidebarOpen)}
showMenuButton={isMobile}
/>
<div className="flex">
{/* 移动端侧边栏覆盖层 */}
{isMobile && sidebarOpen && (
<div
className="fixed inset-0 bg-black bg-opacity-50 z-40"
onClick={() => setSidebarOpen(false)}
/>
)}
{/* 侧边栏 */}
<aside className={`
${isMobile ? 'fixed' : 'relative'}
${sidebarOpen ? 'translate-x-0' : '-translate-x-full'}
md:translate-x-0
transition-transform duration-300 ease-in-out
w-64 h-full bg-white shadow-lg z-50
`}>
<Sidebar />
</aside>
{/* 主内容区 */}
<main className="flex-1 min-h-0">
{children}
</main>
</div>
</div>
)
}
路由组
组织路由结构
src/app/
├── (marketing)/ # 路由组:营销页面
│ ├── layout.tsx # 营销页面布局
│ ├── page.tsx # 首页 (/)
│ ├── about/
│ │ └── page.tsx # 关于页面 (/about)
│ └── pricing/
│ └── page.tsx # 价格页面 (/pricing)
├── (auth)/ # 路由组:认证页面
│ ├── layout.tsx # 认证页面布局
│ ├── login/
│ │ └── page.tsx # 登录页面 (/login)
│ └── register/
│ └── page.tsx # 注册页面 (/register)
└── (dashboard)/ # 路由组:仪表板页面
├── layout.tsx # 仪表板布局
├── page.tsx # 仪表板首页 (/dashboard)
└── settings/
└── page.tsx # 设置页面 (/dashboard/settings)
路由组布局实现
// src/app/(marketing)/layout.tsx
import { MarketingHeader } from '@/components/MarketingHeader'
import { MarketingFooter } from '@/components/MarketingFooter'
export default function MarketingLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<div className="min-h-screen bg-white">
<MarketingHeader />
<main className="flex-1">
{children}
</main>
<MarketingFooter />
</div>
)
}
// src/app/(auth)/layout.tsx
import { AuthLayout } from '@/components/AuthLayout'
export default function AuthLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<AuthLayout>
{children}
</AuthLayout>
)
}
并行路由
仪表板并行布局
// src/app/dashboard/@analytics/page.tsx
export default function AnalyticsPage() {
return (
<div className="space-y-6">
<h2 className="text-2xl font-bold text-gray-900">数据分析</h2>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
<div className="bg-white p-6 rounded-lg shadow">
<h3 className="text-sm font-medium text-gray-500">总用户数</h3>
<p className="text-2xl font-bold text-gray-900 mt-2">1,234</p>
</div>
<div className="bg-white p-6 rounded-lg shadow">
<h3 className="text-sm font-medium text-gray-500">活跃用户</h3>
<p className="text-2xl font-bold text-green-600 mt-2">856</p>
</div>
<div className="bg-white p-6 rounded-lg shadow">
<h3 className="text-sm font-medium text-gray-500">新增用户</h3>
<p className="text-2xl font-bold text-blue-600 mt-2">+124</p>
</div>
<div className="bg-white p-6 rounded-lg shadow">
<h3 className="text-sm font-medium text-gray-500">转化率</h3>
<p className="text-2xl font-bold text-purple-600 mt-2">3.2%</p>
</div>
</div>
{/* 更多分析内容 */}
</div>
)
}
// src/app/dashboard/@settings/page.tsx
export default function SettingsPage() {
return (
<div className="space-y-6">
<h2 className="text-2xl font-bold text-gray-900">系统设置</h2>
<div className="bg-white rounded-lg shadow">
<div className="p-6 border-b">
<h3 className="text-lg font-medium text-gray-900">基本设置</h3>
</div>
<div className="p-6 space-y-4">
{/* 设置表单 */}
</div>
</div>
</div>
)
}
// src/app/dashboard/layout.tsx
import { DashboardNav } from '@/components/DashboardNav'
export default function DashboardLayout({
children,
analytics,
settings,
}: {
children: React.ReactNode
analytics?: React.ReactNode
settings?: React.ReactNode
}) {
return (
<div className="min-h-screen bg-gray-50">
<DashboardNav />
<div className="flex">
<aside className="w-64 bg-white shadow-sm h-screen">
{/* 侧边栏内容 */}
</aside>
<main className="flex-1 p-6">
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
<div className="lg:col-span-2">
{children}
</div>
<div className="space-y-6">
{analytics}
{settings}
</div>
</div>
</main>
</div>
</div>
)
}
拦截路由
模态框拦截路由
// src/app/photos/[id]/page.tsx
import { notFound } from 'next/navigation'
import { getPhotoById } from '@/lib/photos'
interface PhotoPageProps {
// Next.js 16:params 为 Promise
params: Promise<{ id: string }>
}
export default async function PhotoPage({ params }: PhotoPageProps) {
const { id } = await params
const photo = await getPhotoById(id)
if (!photo) {
notFound()
}
return (
<div className="container mx-auto py-8">
<div className="max-w-4xl mx-auto">
<img
src={photo.url}
alt={photo.title}
className="w-full h-auto rounded-lg shadow-lg"
/>
<h1 className="text-3xl font-bold mt-6">{photo.title}</h1>
<p className="text-gray-600 mt-2">{photo.description}</p>
</div>
</div>
)
}
// src/app/@modal/(.)photos/[id]/page.tsx
import { getPhotoById } from '@/lib/photos'
import { PhotoModal } from '@/components/PhotoModal'
interface PhotoModalProps {
params: Promise<{ id: string }>
}
export default async function PhotoModal({ params }: PhotoModalProps) {
const { id } = await params
const photo = await getPhotoById(id)
if (!photo) {
return null
}
return <PhotoModal photo={photo} />
}
// src/components/PhotoModal.tsx
'use client'
import { useRouter, usePathname } from 'next/navigation'
import { createPortal } from 'react-dom'
import { useEffect } from 'react'
interface PhotoModalProps {
photo: {
id: string
title: string
url: string
description: string
}
}
export function PhotoModal({ photo }: PhotoModalProps) {
const router = useRouter()
const pathname = usePathname()
useEffect(() => {
const handleEscape = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
router.back()
}
}
document.addEventListener('keydown', handleEscape)
return () => document.removeEventListener('keydown', handleEscape)
}, [router])
const handleBackdropClick = (e: React.MouseEvent) => {
if (e.target === e.currentTarget) {
router.back()
}
}
if (typeof document === 'undefined') {
return null
}
return createPortal(
<div
className="fixed inset-0 bg-black bg-opacity-75 flex items-center justify-center z-50 p-4"
onClick={handleBackdropClick}
>
<div className="bg-white rounded-lg max-w-4xl w-full max-h-[90vh] overflow-auto">
<div className="relative">
<img
src={photo.url}
alt={photo.title}
className="w-full h-auto"
/>
<button
onClick={() => router.back()}
className="absolute top-4 right-4 bg-white rounded-full p-2 shadow-lg hover:bg-gray-100"
>
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<div className="p-6">
<h2 className="text-2xl font-bold">{photo.title}</h2>
<p className="text-gray-600 mt-2">{photo.description}</p>
</div>
</div>
</div>,
document.body
)
}
导航和链接
智能导航组件
// src/components/Navigation.tsx
'use client'
import Link from 'next/link'
import { usePathname } from 'next/navigation'
import { useState } from 'react'
import { cn } from '@/lib/utils'
interface NavItem {
href: string
label: string
icon?: React.ReactNode
children?: NavItem[]
}
const navItems: NavItem[] = [
{ href: '/', label: '首页' },
{ href: '/about', label: '关于' },
{
href: '/blog',
label: '博客',
children: [
{ href: '/blog', label: '所有文章' },
{ href: '/blog/categories', label: '分类' },
{ href: '/blog/tags', label: '标签' }
]
},
{ href: '/contact', label: '联系' },
]
export function Navigation() {
const pathname = usePathname()
const [isMenuOpen, setIsMenuOpen] = useState(false)
const isActive = (href: string) => {
if (href === '/') {
return pathname === '/'
}
return pathname.startsWith(href)
}
return (
<nav className="bg-white shadow-sm border-b">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex justify-between items-center h-16">
{/* Logo */}
<Link href="/" className="text-xl font-bold text-blue-600">
MyApp
</Link>
{/* Desktop Navigation */}
<div className="hidden md:flex space-x-8">
{navItems.map((item) => (
<div key={item.href} className="relative group">
<Link
href={item.href}
className={cn(
'flex items-center px-3 py-2 text-sm font-medium transition-colors',
isActive(item.href)
? 'text-blue-600 border-b-2 border-blue-600'
: 'text-gray-700 hover:text-blue-600'
)}
>
{item.label}
{item.children && (
<svg className="ml-1 h-4 w-4" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z" clipRule="evenodd" />
</svg>
)}
</Link>
{/* Dropdown Menu */}
{item.children && (
<div className="absolute left-0 mt-0 w-48 bg-white border border-gray-200 rounded-md shadow-lg opacity-0 invisible group-hover:opacity-100 group-hover:visible transition-all duration-200 z-50">
{item.children.map((child) => (
<Link
key={child.href}
href={child.href}
className={cn(
'block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 hover:text-blue-600',
isActive(child.href) && 'text-blue-600 bg-blue-50'
)}
>
{child.label}
</Link>
))}
</div>
)}
</div>
))}
</div>
{/* Mobile menu button */}
<div className="md:hidden">
<button
onClick={() => setIsMenuOpen(!isMenuOpen)}
className="text-gray-700 hover:text-blue-600 p-2"
>
<svg className="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
{isMenuOpen ? (
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
) : (
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h16" />
)}
</svg>
</button>
</div>
</div>
{/* Mobile Navigation */}
{isMenuOpen && (
<div className="md:hidden py-4 border-t">
{navItems.map((item) => (
<div key={item.href}>
<Link
href={item.href}
className={cn(
'block px-3 py-2 text-base font-medium',
isActive(item.href)
? 'text-blue-600 bg-blue-50'
: 'text-gray-700 hover:text-blue-600'
)}
onClick={() => setIsMenuOpen(false)}
>
{item.label}
</Link>
{item.children && (
<div className="ml-4 space-y-1">
{item.children.map((child) => (
<Link
key={child.href}
href={child.href}
className={cn(
'block px-3 py-2 text-sm',
isActive(child.href)
? 'text-blue-600 bg-blue-50'
: 'text-gray-600 hover:text-blue-600'
)}
onClick={() => setIsMenuOpen(false)}
>
{child.label}
</Link>
))}
</div>
)}
</div>
))}
</div>
)}
</div>
</nav>
)
}
面包屑导航
// src/components/Breadcrumbs.tsx
import Link from 'next/link'
import { usePathname } from 'next/navigation'
interface BreadcrumbItem {
label: string
href: string
}
export function Breadcrumbs() {
const pathname = usePathname()
const getBreadcrumbs = (): BreadcrumbItem[] => {
const segments = pathname.split('/').filter(Boolean)
const breadcrumbs: BreadcrumbItem[] = [
{ label: '首页', href: '/' }
]
let currentPath = ''
segments.forEach((segment) => {
currentPath += `/${segment}`
// 转换URL段为显示名称
const label = segment
.replace(/-/g, ' ')
.replace(/bw/g, l => l.toUpperCase())
breadcrumbs.push({
label,
href: currentPath
})
})
return breadcrumbs
}
const breadcrumbs = getBreadcrumbs()
if (breadcrumbs.length <= 1) {
return null
}
return (
<nav className="flex items-center space-x-2 text-sm text-gray-500 py-4">
{breadcrumbs.map((item, index) => (
<div key={item.href} className="flex items-center space-x-2">
{index > 0 && (
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" clipRule="evenodd" />
</svg>
)}
{index === breadcrumbs.length - 1 ? (
<span className="text-gray-900 font-medium">{item.label}</span>
) : (
<Link
href={item.href}
className="hover:text-gray-700 transition-colors"
>
{item.label}
</Link>
)}
</div>
))}
</nav>
)
}
总结
通过本教程,你已经掌握了:
- App Router 基础: 文件系统路由和布局的创建方法
- 动态路由: 参数处理和静态生成
- 嵌套布局: 复杂页面结构的构建
- 路由组: 路由的组织和管理
- 并行路由: 复杂页面的并行渲染
- 拦截路由: 模态框和覆盖层的实现
- 导航组件: 智能导航和面包屑的构建
下一步学习
- 深入学习服务器组件和客户端组件
- 掌握数据获取和缓存策略
- 学习状态管理和表单处理
- 探索性能优化和 SEO 策略
- 了解部署和运维最佳实践
路由约定解决“请求落到哪里”,并不自动解决参数可信、资源授权或导航体验;这些边界要在每条关键路径中单独验证。
文档交叉引用
相关文档
参考章节
- 📖 [本模块其他章节]: TypeScript 集成 | 样式设计
- 📖 [Knowledge Points 快速参考]: App Router 模式 | Next.js API 参考
总结
路由设计的验收边界
- 目录约定对应用户状态:为页面准备成功、加载、错误和找不到状态;共享 layout 只放跨子路由持续存在的结构,避免在根布局保存会随页面切换失效的局部状态。
- 动态段来自 URL 文本:解析前检查缺失、重复、格式和范围,资源读取后再检查当前身份是否有权访问。类型声明不能阻止手工构造 URL。
- 路由组只整理源码:组名不会出现在 URL;在引入前先确认它降低了导航或布局复杂度,不能把业务授权或数据隔离寄托在目录命名上。
- 并行和拦截路由应有退路:为直接访问、刷新、关闭浮层和错误分支分别设计可达页面。若一个模态只能从特定前序页面打开,刷新后应明确回到详情还是独立页面。
- 导航可由键盘完成:当前页有可感知标记,焦点在页面切换后落到有意义位置,面包屑使用实际路由层级而不是从显示标题反向猜测路径。
学习成果验收
- [ ] 访问一个嵌套路由,分别触发加载、错误、404 和成功状态,确认 layout 的共享部分与页面局部内容行为符合预期。
- [ ] 手工编辑动态段和查询参数,验证无效、重复、越界、资源不存在和无权限访问各有明确结果。
- [ ] 使用路由组重排源码后,确认公开 URL 没有改变,并说明组只承担的组织职责。
- [ ] 从列表打开一个拦截路由页面后刷新、复制链接、关闭浮层,确认每条路径都回到可解释的页面状态。
- [ ] 只用键盘完成导航,检查当前项语义、焦点移动和面包屑目标正确。
贡献与反馈
内容改进
如果你发现本文档有改进空间,欢迎: – 🐛 报告问题: 在 Issues 中提出具体问题 – 💡 建议改进: 提出修改建议和补充内容 – 📝 参与贡献: 提交 PR 完善文档内容
学习反馈
分享你的学习体验: – ✅ 有用内容: 哪些部分对你最有帮助 – ❓ 疑问点: 哪些内容需要进一步澄清 – 🎯 建议: 希望增加什么内容
文档状态: ✅ 已完成最后更新: 2026年9月版本: v1.0.0
可复跑:URL 参数不是组件状态
Next 取得的 params 和 searchParams 最终都来自 URL 文本。尤其是查询参数可能重复、缺失或带有非数字值,因此解析时先保留原始字符串,再按页面契约收窄;不要把 Number(value) || 1 当作校验,因为它会把合法的 和非法输入混在一起。
const url = new URL('https://example.test/notes/42?mode=preview&page=2&page=3')
const segments = url.pathname.split('/').filter(Boolean)
const pageText = url.searchParams.get('page')
const page = pageText !== null && /^d+$/.test(pageText) ? Number(pageText) : 1
if (segments.at(-1) !== '42') throw new Error('动态路径参数解析错误')
if (url.searchParams.get('mode') !== 'preview') throw new Error('查询参数解析错误')
if (url.searchParams.getAll('page').join(',') !== '2,3') throw new Error('重复查询参数不应静默丢失')
if (page !== 2) throw new Error('页码收窄错误')
console.log('Next route URL contracts passed')
该程序只运行 Web 标准 URL/URLSearchParams 的路由输入契约,并不启动 Next 服务器、验证文件约定或替代浏览器导航测试。实际页面还应决定重复 page 是拒绝、取首个还是取最后一个值。