跳到主要内容

路由系统

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

路由类型对照表

路由模式示例 URLparams 类型
[slug]/blog/helloPromise<{ slug: string }>
[...slug]/docs/a/bPromise<{ slug: string[] }>
[[...slug]]/docs/docs/aPromise<{ slug?: string[] }>
[category]/[item]/shop/clothes/shirtPromise<{ 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 组件进行客户端导航:

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 实现:

  1. 页面开始加载时显示 loading 组件
  2. 页面内容加载完成后替换为实际内容
  3. 导航时自动显示加载状态

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>;
}

小结

本章我们学习了:

  1. 文件系统路由的基本概念:文件夹定义路径,文件定义 UI
  2. 静态路由、动态路由、捕获所有路由的使用方式
  3. Next.js 15 中 paramssearchParams 都是 Promise 类型
  4. 布局嵌套和路由组的组织方式
  5. Link 组件和编程式导航的使用
  6. 查询参数的获取和修改
  7. 加载状态和错误处理机制
  8. 自定义 404 页面的实现

练习

  1. 创建一个包含首页、关于、联系三个静态路由的网站
  2. 实现一个动态路由 /users/[id],显示用户 ID
  3. 创建一个嵌套布局,包含侧边栏导航
  4. 实现一个搜索页面,支持查询参数
  5. 创建自定义的加载状态和错误页面