Next.js 页面与路由学习笔记
Next.js 13+ 的 App Router 基于文件系统路由,通过文件夹和文件的命名约定自动生成路由,无需手动配置路由表。
1. 基本路由规则
1.1 核心约定
| 文件 | 作用 | 是否必须 |
|---|---|---|
page.tsx | 定义路由的 UI(页面内容) | 是(没有则该路径不可访问) |
layout.tsx | 定义路由的布局(包裹子页面) | 否(根布局必须) |
loading.tsx | 定义加载状态 | 否 |
error.tsx | 定义错误边界 | 否 |
not-found.tsx | 定义 404 页面 | 否 |
template.tsx | 类似 layout,但导航时重新挂载 | 否 |
1.2 文件夹 = 路由段
app/ ├── page.tsx → / ├── about/ │ └── page.tsx → /about ├── blog/ │ └── page.tsx → /blog │ └── [slug]/ │ └── page.tsx → /blog/hello-world ├── dashboard/ │ ├── page.tsx → /dashboard │ └── settings/ │ └── page.tsx → /dashboard/settings关键规则:
- 只有存在
page.tsx的文件夹才是可访问的路由。 - 文件夹名即为 URL 路径段。
page.tsx默认导出的组件就是该路由的页面内容。
2. 动态路由
2.1 基本动态路由[slug]
用方括号包裹的文件夹名表示动态参数。
app/ └── blog/ └── [slug]/ └── page.tsx → /blog/任意字符串// app/blog/[slug]/page.tsx export default function BlogPost({ params }: { params: { slug: string } }) { return <h1>文章: {params.slug}</h1>; } // 访问 /blog/hello-world → 显示 "文章: hello-world" // 访问 /blog/nextjs-13 → 显示 "文章: nextjs-13"2.2 多段动态路由
app/ └── shop/ └── [category]/ └── [id]/ └── page.tsx → /shop/shoes/nike-001// app/shop/[category]/[id]/page.tsx export default function Product({ params, }: { params: { category: string; id: string }; }) { return ( <div> <p>分类: {params.category}</p> <p>商品: {params.id}</p> </div> ); }2.3 捕获所有路由[...slug]
用[...slug]匹配剩余所有路径段(至少匹配一段)。
app/ └── docs/ └── [...slug]/ └── page.tsx| URL | params.slug |
|---|---|
/docs/getting-started | ['getting-started'] |
/docs/api/reference | ['api', 'reference'] |
/docs/guide/advanced/config | ['guide', 'advanced', 'config'] |
/docs | 404(至少需要一段) |
2.4 可选捕获所有路由[[...slug]]
用双括号表示可选(0 段或多段都匹配)。
app/ └── docs/ └── [[...slug]]/ └── page.tsx| URL | params.slug |
|---|---|
/docs | undefined或[] |
/docs/getting-started | ['getting-started'] |
/docs/api/reference | ['api', 'reference'] |
3. 布局系统 (Layout)
3.1 根布局 (Root Layout)
必须存在,定义在app/layout.tsx,包含<html>和<body>标签。
// app/layout.tsx export default function RootLayout({ children, }: { children: React.ReactNode; }) { return ( <html lang="zh"> <body> <header>全局导航栏</header> <main>{children}</main> <footer>全局页脚</footer> </body> </html> ); }3.2 嵌套布局 (Nested Layout)
子目录中的layout.tsx会嵌套在父布局内,不会替换父布局。
app/ ├── layout.tsx ← 根布局(全局导航 + 页脚) ├── page.tsx ← 首页 └── dashboard/ ├── layout.tsx ← 仪表盘布局(侧边栏) ├── page.tsx ← /dashboard └── settings/ └── page.tsx ← /dashboard/settings// app/dashboard/layout.tsx export default function DashboardLayout({ children, }: { children: React.ReactNode; }) { return ( <div style={{ display: 'flex' }}> <nav>侧边栏:概览 | 设置 | 消息</nav> <section>{children}</section> </div> ); }渲染结果(访问/dashboard/settings):
<html> <body> <header>全局导航栏</header> ← 根布局 <main> <div style="display:flex"> <nav>侧边栏</nav> ← dashboard 布局 <section> <h1>设置页面</h1> ← settings/page.tsx </section> </div> </main> </body> </html>3.3 layout vs template
| 特性 | layout.tsx | template.tsx |
|---|---|---|
| 实例 | 共享(导航时不重新创建) | 独立(每次导航重新挂载) |
| 状态保持 | 保持(跨页面共享状态) | 不保持(每次重置) |
| 性能 | 更好(不重新挂载) | 稍差 |
| 适用场景 | 通用布局 | 进入/离开动画、useEffect每次执行 |
4. 特殊文件详解
4.1loading.tsx— 加载状态
利用 React Suspense,在页面数据加载时自动显示。
// app/dashboard/loading.tsx export default function Loading() { return ( <div className="flex items-center justify-center h-screen"> <div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-500" /> </div> ); }效果:访问/dashboard时,如果页面组件正在获取数据,先显示 loading 动画,数据就绪后自动替换为真实内容。
4.2error.tsx— 错误边界
捕获当前路由段的运行时错误,显示友好的错误提示。
// app/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> ); }4.3not-found.tsx— 404 页面
当调用notFound()函数或路径不存在时显示。
// app/not-found.tsx export default function NotFound() { return ( <div> <h1>404</h1> <p>页面不存在</p> <a href="/">返回首页</a> </div> ); } // 在页面中主动触发 import { notFound } from 'next/navigation'; export default async function Post({ params }) { const post = await getPost(params.slug); if (!post) { notFound(); // 触发 not-found.tsx } return <article>{post.title}</article>; }4.4route.ts— API 路由
处理 HTTP 请求,替代传统的 Express 路由。
// app/api/users/route.ts import { NextResponse } from 'next/server'; // GET /api/users export async function GET() { const users = await db.user.findMany(); return NextResponse.json(users); } // POST /api/users export async function POST(request: Request) { const body = await request.json(); const user = await db.user.create({ data: body }); return NextResponse.json(user, { status: 201 }); }5. 路由组 (Route Groups)
用圆括号(groupName)创建的文件夹,不影响 URL,仅用于组织代码。
5.1 按布局分组
app/ ├── (auth)/ │ ├── layout.tsx ← 登录/注册专用布局(无导航栏) │ ├── login/ │ │ └── page.tsx → /login │ └── register/ │ └── page.tsx → /register ├── (marketing)/ │ ├── layout.tsx ← 营销页面布局(有导航栏 + 页脚) │ ├── page.tsx → / │ └── about/ │ └── page.tsx → /about/login和/register共享(auth)布局(无导航栏)。/和/about共享(marketing)布局(有导航栏)。- URL 中不包含
(auth)或(marketing)。
5.2 按团队/功能分组
app/ ├── (admin)/ │ ├── dashboard/ │ │ └── page.tsx → /dashboard │ └── users/ │ └── page.tsx → /users └── (store)/ ├── products/ │ └── page.tsx → /products └── cart/ └── page.tsx → /cart6. 平行路由 (Parallel Routes)
用@slotName命名的文件夹,允许在同一布局中同时渲染多个页面。
6.1 结构
app/ ├── layout.tsx ← 引用 @analytics 和 @team ├── @analytics/ │ └── page.tsx ← 分析面板 ├── @team/ │ └── page.tsx ← 团队面板 └── page.tsx ← 主内容6.2 布局接收 slots
// app/layout.tsx export default function Layout({ children, analytics, team, }: { children: React.ReactNode; analytics: React.ReactNode; team: React.ReactNode; }) { return ( <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr' }}> <div>{analytics}</div> <div>{children}</div> <div>{team}</div> </div> ); }6.3 典型场景:模态框
app/ ├── layout.tsx ├── @modal/ │ ├── (.)login/ ← 拦截 /login,在模态框中显示 │ │ └── page.tsx │ └── default.tsx ← 模态框未激活时显示 null ├── login/ │ └── page.tsx ← 直接访问 /login 的完整页面 └── page.tsx// app/@modal/default.tsx export default function Default() { return null; // 模态框未激活时不显示 } // app/layout.tsx export default function Layout({ children, modal }) { return ( <> {children} {modal} </> ); }7. 拦截路由 (Intercepting Routes)
在不改变 URL 的情况下,用另一个路由的内容渲染当前页面。
7.1 命名约定
| 约定 | 含义 |
|---|---|
(.)folder | 拦截同级的 folder 路由 |
(..)folder | 拦截上一级的 folder 路由 |
(..)(..)folder | 拦截上两级的 folder 路由 |
(...)folder | 拦截根目录的 folder 路由 |
7.2 典型场景:照片弹窗
app/ ├── layout.tsx ├── @modal/ │ └── (.)photo/ │ └── [id]/ │ └── page.tsx ← 弹窗版本(从照片列表点击进入) ├── photo/ │ └── [id]/ │ └── page.tsx ← 完整页面版本(直接访问 URL) └── page.tsx ← 照片列表- 从列表点击:URL 变为
/photo/123,但显示弹窗(拦截路由生效)。 - 直接访问
/photo/123:显示完整页面(拦截路由不生效)。 - 刷新
/photo/123:显示完整页面(刷新不走客户端导航)。
8. 页面导航
8.1<Link>组件
import Link from 'next/link'; export default function Nav() { return ( <nav> <Link href="/">首页</Link> <Link href="/about">关于</Link> <Link href="/blog/hello-world">文章</Link> {/* 动态链接 */} <Link href={`/blog/${post.slug}`}>{post.title}</Link> {/* 激活状态 */} <Link href="/about" scroll={false}>关于(不滚动到顶部)</Link> </nav> ); }特性:
- 视口内的
<Link>自动预取(prefetch)目标页面。 - 客户端导航,不会整页刷新。
- 自动滚动到页面顶部(可关闭)。
8.2 编程式导航
'use client'; import { useRouter } from 'next/navigation'; export default function LoginForm() { const router = useRouter(); async function handleSubmit() { const success = await login(); if (success) { router.push('/dashboard'); // 导航到 /dashboard router.refresh(); // 刷新当前路由(重新获取数据) } } return <button onClick={handleSubmit}>登录</button>; }| 方法 | 作用 |
|---|---|
router.push(href) | 导航到新页面(加入历史记录) |
router.replace(href) | 替换当前页面(不加入历史记录) |
router.back() | 返回上一页 |
router.refresh() | 刷新当前路由,重新获取服务端数据 |
8.3 导航行为对比
| 行为 | <Link> | router.push | <a>标签 |
|---|---|---|---|
| 客户端导航 | 是 | 是 | 否(整页刷新) |
| 预取 | 是 | 否 | 否 |
| 历史记录 | 加入 | 加入 | 加入 |
| 适用场景 | 大多数导航 | 表单提交后跳转 | 外部链接 |
9. 路由元数据 (Metadata)
9.1 静态元数据
// app/about/page.tsx export const metadata = { title: '关于我们', description: '这是关于我们的页面', }; export default function AboutPage() { return <h1>关于我们</h1>; }9.2 动态元数据
// app/blog/[slug]/page.tsx export async function generateMetadata({ params }) { const post = await getPost(params.slug); return { title: post.title, description: post.excerpt, openGraph: { title: post.title, description: post.excerpt, images: [post.coverImage], }, }; }9.3 布局中的元数据继承
子页面的title会覆盖父布局的title,也可用模板语法:
// app/layout.tsx export const metadata = { title: { default: '我的网站', // 默认标题 template: '%s | 我的网站', // 子页面标题模板 }, }; // app/about/page.tsx export const metadata = { title: '关于我们', // 最终: "关于我们 | 我的网站" };10. 完整路由速查表
| 文件/文件夹 | URL | 说明 |
|---|---|---|
app/page.tsx | / | 首页 |
app/about/page.tsx | /about | 静态路由 |
app/blog/[slug]/page.tsx | /blog/hello | 动态路由 |
app/shop/[...slug]/page.tsx | /shop/a/b/c | 捕获所有路由 |
app/docs/[[...slug]]/page.tsx | /docs或/docs/a/b | 可选捕获所有路由 |
app/(auth)/login/page.tsx | /login | 路由组(不影响 URL) |
app/@modal/(.)photo/[id]/page.tsx | /photo/123 | 平行路由 + 拦截路由 |
app/api/users/route.ts | /api/users | API 路由 |
总结
Next.js 路由系统的核心记忆点:
- 文件即路由:
page.tsx定义可访问路径,文件夹名即 URL 段 - 动态参数:
[slug]单段、[...slug]多段、[[...slug]]可选多段 - 布局嵌套:
layout.tsx从根到叶逐层嵌套,导航时不销毁 - 路由组:
(group)组织代码不影响 URL - 平行路由:
@slot同一布局渲染多个独立视图 - 拦截路由:
(.)在客户端导航时拦截,直接访问不拦截 - 特殊文件:
loading/error/not-found处理非理想状态