路由系统
Next.js 使用基于文件系统的路由,通过文件夹和文件来定义路由结构。本章将详细介绍 App Router 的路由机制。
路由基础
文件系统路由
在 App Router 中,路由由文件夹结构决定:
- 文件夹:定义路由路径
- 文件:定义路由 UI
app/
├── page.tsx → /
├── about/
│ └── page.tsx → /about
├── blog/
│ ├── page.tsx → /blog
│ └── [slug]/
│ └── page.tsx → /blog/:slug
页面文件
page.tsx 文件定义路由页面,必须默认导出一个 React 组件:
// app/about/page.tsx
export default function AboutPage() {
return <div>关于我们</div>;
}
访问 /about 路由将渲染此组件。
布局文件
layout.tsx 文件定义共享布局,必须接受 children 属性:
// 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/
├── page.tsx → /
├── about/
│ └── page.tsx → /about
└── contact/
└── page.tsx → /contact
动态路由
使用方括号 [参数名] 创建动态路由。在 Next.js 15 中,params 是 Promise 类型,必须使用 async/await 来获取值:
// app/blog/[slug]/page.tsx
export default async function BlogPost({
params,
}: {
params: Promise<{ slug: string }>;
}) {
const { slug } = await params;
return <div>文章: {slug}</div>;
}
访问 /blog/hello-world 时,slug 为 "hello-world"。
Next.js 15 中,params 从同步属性变为 Promise。这是为了支持部分预渲染(Partial Prerendering)等新特性。在 Next.js 14 及更早版本中,params 是同步的,但在 Next.js 15 中虽然仍可以同步访问以保持向后兼容,但未来将被弃用。
动态路由生成
使用 generateStaticParams 在构建时生成静态页面:
// app/blog/[slug]/page.tsx
// 在构建时生成所有博客文章的静态路由
export async function generateStaticParams() {
const posts = await getPosts();
return posts.map((post) => ({
slug: post.slug,
}));
}
export default async function BlogPost({
params,
}: {
params: Promise<{ slug: string }>;
}) {
const { slug } = await params;
const post = await getPost(slug);
return <article>{post.title}</article>;
}
generateStaticParams 函数的特点:
- 在构建时执行,生成静态路由参数
- 函数内部的
fetch请求会自动去重 - 返回一个对象数组,每个对象包含动态路由段的值
捕获所有路由
使用 [...slug] 捕获多级路径:
// app/docs/[...slug]/page.tsx
export default async function DocsPage({
params,
}: {
params: Promise<{ slug: string[] }>;
}) {
const { slug } = await params;
return <div>文档路径: {slug.join('/')}</div>;
}
访问 /docs/a/b/c 时,slug 为 ['a', 'b', 'c']。
可选捕获所有路由
使用 [[...slug]] 创建可选的捕获所有路由:
// app/docs/[[...slug]]/page.tsx
export default async function DocsPage({
params,
}: {
params: Promise<{ slug?: string[] }>;
}) {
const { slug } = await params;
if (!slug) {
return <div>文档首页</div>;
}
return <div>文档路径: {slug.join('/')}</div>;
}
可以匹配 /docs、/docs/a、/docs/a/b 等路径。注意 slug 可能是 undefined。
路由类型对照表
| 路由模式 | 示例 URL | params 类型 |
|---|---|---|
[slug] | /blog/hello | Promise<{ slug: string }> |
[...slug] | /docs/a/b | Promise<{ slug: string[] }> |
[[...slug]] | /docs 或 /docs/a | Promise<{ slug?: string[] }> |
[category]/[item] | /shop/clothes/shirt | Promise<{ category: string, item: string }> |
布局嵌套
嵌套布局
布局可以嵌套,子布局会包裹在父布局内:
app/
├── layout.tsx → 根布局
├── page.tsx → 首页
└── dashboard/
├── layout.tsx → dashboard 布局
└── page.tsx → /dashboard 页面
// app/dashboard/layout.tsx
export default function DashboardLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<div className="flex">
<nav>侧边栏</nav>
<main>{children}</main>
</div>
);
}
布局层级
最终的 HTML 结构:
<html>
<body>
<!-- 根布局内容 -->
<div class="flex">
<nav>侧边栏</nav>
<main>
<!-- dashboard 页面内容 -->
</main>
</div>
</body>
</html>
路由组
使用 (组名) 创建路由组,不影响 URL 路径:
app/
├── (marketing)/
│ ├── about/
│ │ └── page.tsx → /about
│ └── contact/
│ └── page.tsx → /contact
└── (shop)/
├── products/
│ └── page.tsx → /products
└── cart/
└── page.tsx → /cart
路由组的作用:
- 组织代码结构,按功能或模块分组
- 共享布局,同一组内的页面可以共享同一个布局
- 不影响 URL 路径,括号内的名称不会出现在 URL 中
页面导航
Link 组件
使用 Link 组件进行客户端导航:
import Link from "next/link";
export default function Navigation() {
return (
<nav>
<Link href="/">首页</Link>
<Link href="/about">关于</Link>
<Link href="/blog/hello-world">文章</Link>
</nav>
);
}
Link 组件的优势:
- 自动预取:视口内的链接会在后台预取
- 客户端导航:不会刷新整个页面
- 保持滚动位置:返回时恢复滚动位置
动态链接
import Link from "next/link";
export default function PostList({ posts }: { posts: Post[] }) {
return (
<ul>
{posts.map((post) => (
<li key={post.id}>
<Link href={`/blog/${post.slug}`}>
{post.title}
</Link>
</li>
))}
</ul>
);
}
编程式导航
使用 useRouter Hook 进行编程式导航:
"use client";
import { useRouter } from "next/navigation";
export default function LoginForm() {
const router = useRouter();
const handleSubmit = async (formData: FormData) => {
const success = await login(formData);
if (success) {
router.push("/dashboard");
}
};
return (
<form action={handleSubmit}>
<button type="submit">登录</button>
</form>
);
}
useRouter 方法
const router = useRouter();
// 导航到新页面(添加到历史记录)
router.push("/path");
// 替换当前历史记录(不添加新记录)
router.replace("/path");
// 返回上一页
router.back();
// 前进
router.forward();
// 刷新当前路由(重新获取数据)
router.refresh();
useRouter 只能在客户端组件中使用。在服务端组件中,应该使用 redirect 函数进行重定向。
查询参数
获取查询参数
在 Server Component 中使用 searchParams。在 Next.js 15 中,searchParams 也是 Promise 类型:
// app/search/page.tsx
export default async function SearchPage({
searchParams,
}: {
searchParams: Promise<{ q?: string; page?: string }>;
}) {
const { q = "", page = "1" } = await searchParams;
const pageNum = Number(page);
return (
<div>
搜索: {q},第 {pageNum} 页
</div>
);
}
使用 searchParams 会使页面变为动态渲染,因为查询参数在请求时才能确定。
客户端获取查询参数
在 Client Component 中使用 useSearchParams:
"use client";
import { useSearchParams } from "next/navigation";
export default function SearchFilters() {
const searchParams = useSearchParams();
const category = searchParams.get("category");
const sort = searchParams.get("sort");
return (
<div>
分类: {category},排序: {sort}
</div>
);
}
修改查询参数
"use client";
import { useRouter, useSearchParams, usePathname } from "next/navigation";
export default function Pagination() {
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
const setPage = (page: number) => {
const params = new URLSearchParams(searchParams);
params.set("page", String(page));
router.push(`${pathname}?${params.toString()}`);
};
return (
<div>
<button onClick={() => setPage(1)}>第1页</button>
<button onClick={() => setPage(2)}>第2页</button>
</div>
);
}
在客户端组件中使用 params 和 searchParams
如果需要在客户端组件中读取 Promise 类型的 props,可以使用 React 的 use 函数:
"use client";
import { use } from "react";
export default function ClientPage({
params,
searchParams,
}: {
params: Promise<{ slug: string }>;
searchParams: Promise<{ [key: string]: string | undefined }>;
}) {
const { slug } = use(params);
const { query } = use(searchParams);
return (
<div>
文章: {slug},搜索: {query}
</div>
);
}
加载状态
loading.tsx
创建 loading.tsx 文件定义加载状态:
// app/dashboard/loading.tsx
export default function Loading() {
return (
<div className="animate-pulse">
<div className="h-8 bg-gray-200 rounded w-1/2 mb-4"></div>
<div className="h-4 bg-gray-200 rounded w-3/4 mb-2"></div>
<div className="h-4 bg-gray-200 rounded w-1/2"></div>
</div>
);
}
工作原理
loading.tsx 基于 React Suspense 实现:
- 页面开始加载时显示 loading 组件
- 页面内容加载完成后替换为实际内容
- 导航时自动显示加载状态
Next.js 会自动将 loading.tsx 嵌套在 layout.tsx 内部,并用 <Suspense> 包裹 page.tsx:
<Layout>
<Suspense fallback={<Loading />}>
<Page />
</Suspense>
</Layout>
错误处理
error.tsx
创建 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>
);
}
注意 error.tsx 必须是客户端组件(需要 "use client" 指令),因为它需要处理用户交互。
global-error.tsx
处理根布局错误的特殊文件:
"use client";
export default function GlobalError({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
return (
<html>
<body>
<h2>全局错误</h2>
<button onClick={reset}>重试</button>
</body>
</html>
);
}
global-error.tsx 需要包含 <html> 和 <body> 标签,因为根布局出错时,布局本身可能无法渲染。
404 页面
not-found.tsx
创建自定义 404 页面:
// app/not-found.tsx
import Link from "next/link";
export default function NotFound() {
return (
<div>
<h2>页面未找到</h2>
<p>您访问的页面不存在</p>
<Link href="/">返回首页</Link>
</div>
);
}
触发 404
使用 notFound() 函数主动触发 404:
import { notFound } from "next/navigation";
export default async function PostPage({
params,
}: {
params: Promise<{ slug: string }>;
}) {
const { slug } = await params;
const post = await getPost(slug);
if (!post) {
notFound(); // 触发 not-found.tsx
}
return <article>{post.content}</article>;
}
小结
本章我们学习了:
- 文件系统路由的基本概念:文件夹定义路径,文件定义 UI
- 静态路由、动态路由、捕获所有路由的使用方式
- Next.js 15 中
params和searchParams都是 Promise 类型 - 布局嵌套和路由组的组织方式
- Link 组件和编程式导航的使用
- 查询参数的获取和修改
- 加载状态和错误处理机制
- 自定义 404 页面的实现
练习
- 创建一个包含首页、关于、联系三个静态路由的网站
- 实现一个动态路由
/users/[id],显示用户 ID - 创建一个嵌套布局,包含侧边栏导航
- 实现一个搜索页面,支持查询参数
- 创建自定义的加载状态和错误页面