大家好,我是程序员柏风,AI 最全的 Vibecoding 学习地图 http://coderoadmap.cn 作者。
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:根布局加导航栏
修改 src/app/layout.tsx:
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:列表页 + 编程式跳转
src/app/tracks/page.tsx:
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。
延伸
- Next.js 官方文档:Routing
- 下一课:本 track 最重要的一课——Server 与 Client 组件。