App Router 与文件路由
这一课你将能做到
- 看到任意 URL 就能反推出它在
app/ 下的文件路径,反之亦然
- 使用动态段
[slug] 写出像本站课时页 /tracks/[slug]/lessons/[lessonSlug] 这样的嵌套动态路由
- 用
layout.tsx 搭建跨页面共享的导航栏,并用 Link、useRouter 实现跳转
为什么要学
路由是 Web 框架的第一公民。Next.js App Router 的路由方案是「文件即路由」——你在文件夹里建 page.tsx,URL 就出现了。这带来两个直接好处:一是零配置,二是看 URL 就能猜到代码位置,这在协作和读源码时价值巨大。本站课时页的 URL 是 /tracks/nextjs-essentials/lessons/next-intro,你读完这课,应该立刻知道它对应 src/app/tracks/[slug]/lessons/[lessonSlug]/page.tsx 这个文件。
面试中路由题是必考基础题:「动态路由怎么写、参数怎么取」「layout 和 page 的区别」「路由跳转的几种方式」。这些题不难,但答得利索能快速建立「写过 Next.js 项目」的印象。
先建立图像
URL 与文件的对照表,先花 30 秒记一遍:
| URL | 文件路径 | 说明 |
|---|
/ | app/page.tsx | 首页 |
/about | app/about/page.tsx | 静态段 |
/tracks | app/tracks/page.tsx | 列表页 |
/tracks/java-essentials | app/tracks/[slug]/page.tsx | 动态段 |
/tracks/java/lessons/intro | app/tracks/[slug]/lessons/[lessonSlug]/page.tsx | 嵌套动态段 |
/docs/a/b/c | app/docs/[...rest]/page.tsx | 全捕获段 |
/api/health | app/api/health/route.ts | 接口(第 7 课) |
三条命名规则:
[name] 动态段:匹配任意一段,如 [slug] 匹配 java-essentials
(group) 路由组:括号名不出现在 URL 里,仅用于组织文件
[...name] 全捕获:匹配剩余所有段(含多段)
嵌套布局的心智图:
app/
├── layout.tsx ← 所有页面都套这层(根布局,必须有)
├── tracks/
│ ├── layout.tsx ← /tracks 下所有页面再套这层
│ ├── page.tsx ← /tracks
│ └── [slug]/
│ └── page.tsx ← /tracks/java-essentials
跟着做
步骤 1:搭建一个迷你课程站骨架
src/app/
├── layout.tsx (已存在,加导航栏)
├── page.tsx (首页)
├── tracks/
│ ├── page.tsx (课程列表)
│ └── [slug]/
│ └── page.tsx (课程详情)
步骤 2:根布局加导航栏
import type { Metadata } from "next";
import Link from "next/link";
import "./globals.css";
export const metadata: Metadata = { title: "迷你课程站" };
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="zh-CN">
<body>
<nav className="flex gap-4 border-b p-4">
<Link href="/">首页</Link>
<Link href="/tracks">课程</Link>
</nav>
{children}
</body>
</html>
);
}
要点:站内跳转必须用 next/link 的 <Link>,它会预取目标页面并做客户端导航(不整页刷新)。写 <a href> 虽然也能跳,但会丢失 SPA 体验。
步骤 3:列表页 + 编程式跳转
import Link from "next/link";
const tracks = [
{ slug: "java-essentials", title: "Java 基础" },
{ slug: "nextjs-essentials", title: "Next.js 基础" },
];
export default function TracksPage() {
return (
<main className="p-6">
<h1 className="text-2xl font-bold">全部课程</h1>
<ul className="mt-4 space-y-2">
{tracks.map((t) => (
<li key={t.slug}>
<Link className="text-blue-600 hover:underline" href={`/tracks/${t.slug}`}>
{t.title}
</Link>
</li>
))}
</ul>
</main>
);
}
步骤 4:动态路由详情页——重点
src/app/tracks/[slug]/page.tsx。注意 Next 15 起 params 是 Promise,必须 await(Next 14 中是同步对象,本课程按 15 的新写法,向下兼容提醒会标注):
import { notFound } from "next/navigation";
const tracks = [
{ slug: "java-essentials", title: "Java 基础", desc: "面向零基础" },
{ slug: "nextjs-essentials", title: "Next.js 基础", desc: "App Router 全流程" },
];
interface PageProps {
params: Promise<{ slug: string }>;
}
export default async function TrackPage({ params }: PageProps) {
const { slug } = await params;
const track = tracks.find((t) => t.slug === slug);
if (!track) notFound(); // 渲染最近的 not-found.tsx 或默认 404
return (
<main className="p-6">
<h1 className="text-2xl font-bold">{track.title}</h1>
<p className="mt-2 text-gray-600">{track.desc}</p>
</main>
);
}
访问 /tracks/java-essentials 出详情,访问 /tracks/不存在 出 404——notFound() 就是这么用。
步骤 5:再嵌一层动态段
新建 src/app/tracks/[slug]/lessons/[lessonSlug]/page.tsx:
interface PageProps {
params: Promise<{ slug: string; lessonSlug: string }>;
}
export default async function LessonPage({ params }: PageProps) {
const { slug, lessonSlug } = await params;
return (
<main className="p-6">
<h1 className="text-xl font-bold">课程:{slug}</h1>
<h2 className="mt-1 text-lg text-gray-700">课时:{lessonSlug}</h2>
</main>
);
}
访问 /tracks/nextjs-essentials/lessons/next-intro,两段动态参数都拿到了。本站真实的课时页就是这个结构,多了一个查询登录态、渲染 MDX 的步骤而已。
步骤 6:客户端组件里用 useRouter 跳转
动态交互(点按钮跳转)在 Client 组件里做:
"use client";
import { useRouter } from "next/navigation";
export default function GoButton() {
const router = useRouter();
return (
<button
className="rounded bg-blue-600 px-4 py-2 text-white"
onClick={() => router.push("/tracks")}
>
去课程列表
</button>
);
}
router.push 前进、router.replace 替换历史记录(登录后回跳常用)、router.back 后退、router.refresh 让服务端重新渲染当前页。
讲清楚要点
- page.tsx 是路由的「最小单元」:文件夹只是命名空间,没有
page.tsx 的文件夹不产生 URL。这让「共用布局」和「路由」解耦——布局挂在文件夹上,页面才挂 URL。
- layout 层层嵌套且保留状态:从根到当前页的所有 layout 同时渲染。用户在
/tracks/a 与 /tracks/b 之间跳转时,tracks/layout.tsx 不会重新渲染,里面的状态保留——这就是「布局只在段变化时重渲染」的官方表述。
- params(路径参数)与 searchParams(查询参数)要分清:
/tracks/[slug] 的 slug 来自路径,是路由的一部分;/tracks?page=2 的 page 来自 ? 后面,叫 searchParams,同样是 Promise:const { page } = await searchParams。注意:页面里读了 searchParams 会强制该页走动态渲染(第 6 课细讲)。
- 三种跳转方式按场景选:内容可点击(列表项、卡片)用
<Link>;提交表单后跳转、登录成功后跳转用 router.push/replace;服务端逻辑里重定向(如未登录访问)用 redirect("/login")(从 next/navigation 导入,在 Server Component 或 Server Action 里调用)。
- 路由组
(group) 解决「不同板块不同布局」:比如 (shop)/cart/page.tsx 与 (blog)/post/page.tsx,URL 都是顶层 /cart、/post,但可以各自套不同 layout,互不影响。
你可能会懵的地方
-
Next 14 和 15 在 params 上的区别到底改了什么?
14 里 params 是普通对象,直接 params.slug;15 里改成 Promise(为支持流式渲染前先出壳),统一 await params。本站源码里 params: Promise<{...}> 就是 15 的写法。写 14 的项目照旧语法即可,两种写法不混用就不会错。
-
动态段的值会变吗?URL 改名了路由也变吗?
动态段匹配的是「值」,不是「路由」。/tracks/[slug] 一个文件就能服务无穷多个 /tracks/xxx。这也是为什么本站新增课程不用改任何路由代码。
-
[...slug] 和 [[...slug]] 的区别?
[...slug] 至少要有一段(/docs/a 起);[[...slug]] 可选捕获,空的时候也匹配(/docs 本身)。一般文档站根路径用可选捕获。
-
为什么推荐 Link 而不是 a 标签,哪怕都是跳转?
Link 做客户端导航:不发整页请求、预取可视区域内的链接、保留滚动与状态。a 标签触发完整页面加载,等于放弃 Next.js 的性能优化。站外链接才用 a 标签。
-
404 页面在哪定义?
在对应层级建 not-found.tsx(根级 app/not-found.tsx 兜底),里面正常写 JSX,调用 notFound() 的组件会渲染到最近的 not-found 边界。同理还有 error.tsx(必须 'use client')与 loading.tsx。
错法对照
| 错法 | 问题 | 正解 |
|---|
src/app/tracks/JavaEssentials/page.tsx 想匹配大写 URL | 文件夹名区分大小写,Windows 本地能跑、Linux 生产 404 | 段名统一 kebab-case 小写 |
页面里直接 params.slug(Next 15) | TS 报错 / 运行时 undefined | const { slug } = await params |
把接口写在 app/api/x/page.tsx | 接口必须叫 route.ts | app/api/x/route.ts(第 7 课) |
登录后用 router.push 跳走,用户按后退又回登录页 | 历史记录残留 | 用 router.replace("/me") |
在根布局里读 searchParams | 根布局拿不到 searchParams | 在 page 层读取再传下去 |
卡点急救
- 动态路由刷新后 404:多半在静态部署(如
output: "export" 纯静态导出)下没生成兜底。服务器模式(npm run start、Vercel)不存在此问题;确认你不是在用纯静态托管直接刷新动态段。
- Link 点击没反应:检查 href 是否为完整字符串(不要嵌套
<a> 在 <Link> 里);若用了自定义组件包裹 Link,把交互逻辑放进子组件而不是改 Link 本身。
await params 后 TS 还报 possibly undefined:params 的键来自文件夹名,拼错键名(如文件叫 [id] 却解构 slug)拿到的就是 undefined。对一下文件夹名。
面试常问
-
App Router 中 layout 和 page 的关系与区别?
page 定义一个路由的 UI,layout 包裹其下所有段。布局跨页面持久:段间导航时 layout 不重渲染、状态保留;page 每次导航都会重新渲染。layout 可以嵌套(根 layout 必须有,含 html/body),还能有 loading/error/not-found 各级边界。
-
动态路由参数如何获取?14 与 15 的差异?
在页面 props 上接收 params。Next 15 起 params/searchParams 均为 Promise,需 await 后解构,这使 Next 能先渲染骨架再等待参数数据(配合流式渲染)。路径参数来自 [slug] 文件夹,查询参数来自 URL ? 后的部分,两者都通过 await 获取。
-
服务端重定向和客户端跳转分别怎么做?
服务端:Server Component 或 Server Action 里 redirect("/login")(抛出特殊错误由框架处理,可用于鉴权门控)。客户端:useRouter().push/replace/back;声明式用 <Link>。表单提交后的跳转用 replace 避免后退回到表单页。
自测三问
/tracks/[slug]/lessons/[lessonSlug] 能匹配哪些 URL?参数怎么取?
- 用户从
/tracks/a 跳到 /tracks/b,tracks/layout.tsx 会重新执行吗?为什么这很重要?
<Link>、router.push、redirect 分别用在什么场景?
小结一句话
文件即路由:page.tsx 生成 URL、[slug] 接参数、layout.tsx 层层包裹且跨页持久,跳转三件套是 Link / router / redirect。
延伸