大家好,我是程序员柏风,AI 最全的 Vibecoding 学习地图 http://coderoadmap.cn 作者。
App Router 深入
这一课你将能做到
- 用嵌套文件夹、动态段、Catch-all 段构造任意复杂的 URL 结构
- 用路由组
(folder)在不影响 URL 的前提下组织代码和布局 - 用并行路由
@slot在同一页面渲染多个独立区域 - 用拦截路由
(..)实现「列表里点开是模态、直接访问是详情页」的效果
为什么要学
第一课的文件路由只覆盖了「一个文件夹一个页面」的最简场景。真实项目的 URL 结构远比这复杂:博客详情 /blog/2024/hello-world、用户设置 /settings/profile 和 /settings/security、管理后台用一套布局、营销页用另一套布局、点开图片是模态但刷新后是详情页……这些都靠 App Router 的高级路由能力实现。
人话:基础文件路由教你盖平房,这一课教你盖写字楼——同一栋楼可以分多个单元(路由组)、同一层可以有多个房间同时开(并行路由)、走楼梯能看到的风景和坐电梯不一样(拦截路由)。
面试中,能讲清楚「拦截路由 + 并行路由实现图片预览模态」是 App Router 熟练度的标志性问题。
先建立图像
一个使用了所有路由技巧的 app/ 目录:
app/
├── (marketing)/ ← 路由组:不出现在 URL 里
│ ├── layout.tsx ← 营销站布局(带顶部导航)
│ ├── page.tsx ← /
│ └── about/page.tsx ← /about
├── (shop)/ ← 另一个路由组,独立布局
│ ├── layout.tsx ← 商城布局(带侧边栏)
│ └── products/
│ ├── page.tsx ← /products
│ └── [id]/page.tsx ← /products/123
├── blog/
│ ├── page.tsx ← /blog
│ ├── [slug]/page.tsx ← /blog/hello-world
│ └── [...path]/page.tsx ← /blog/2024/05/anything
├── feed/
│ ├── page.tsx ← /feed
│ └── @modal/ ← 并行路由 slot
│ ├── default.tsx
│ └── (..)photo/[id]/page.tsx ← 拦截 /photo/[id]
├── photo/[id]/page.tsx ← /photo/123(直接访问的完整页)
└── api/
└── users/route.ts ← /api/users(Route Handler)
跟着做
步骤 1:动态路由 [id]
在 app/blog/[slug]/page.tsx 创建动态页面:
interface Props {
params: Promise<{ slug: string }>;
}
export default async function BlogPost({ params }: Props) {
const { slug } = await params;
return (
<main style={{ padding: 40 }}>
<h1>文章详情</h1>
<p>当前 slug:<strong>{slug}</strong></p>
</main>
);
}
访问 http://localhost:3000/blog/hello-world、/blog/anything 都会渲染这个页面,并显示对应 slug。
Next.js 15 注意:
params和searchParams现在是异步的,必须await。
步骤 2:Catch-all 路由 [...slug]
新建 app/docs/[...slug]/page.tsx:
interface Props {
params: Promise<{ slug: string[] }>;
}
export default async function DocsPage({ params }: Props) {
const { slug } = await params;
return (
<main style={{ padding: 40 }}>
<h1>文档路径</h1>
<p>段数组:{JSON.stringify(slug)}</p>
</main>
);
}
访问 /docs/a、/docs/a/b/c 都匹配,slug 分别是 ['a'] 和 ['a','b','c']。
区别:
[...slug]不匹配/docs本身(至少要一段)[[...slug]]双层中括号是可选 catch-all,/docs也匹配,slug为undefined
步骤 3:路由组 (folder)
场景:网站分两个区——营销站 (marketing) 用一套布局(顶部横幅 + 居中),管理台 (admin) 用另一套布局(左侧边栏)。但 URL 都不希望出现 marketing / admin 字样。
app/
├── (marketing)/
│ ├── layout.tsx
│ ├── page.tsx ← /
│ └── pricing/page.tsx ← /pricing
└── (admin)/
├── layout.tsx
└── dashboard/page.tsx ← /dashboard
app/(marketing)/layout.tsx:
export default function MarketingLayout({ children }: { children: React.ReactNode }) {
return (
<div>
<header style={{ background: '#f0f0f0', padding: 16, textAlign: 'center' }}>
营销站头部横幅
</header>
<main style={{ maxWidth: 960, margin: '0 auto', padding: 24 }}>{children}</main>
</div>
);
}
app/(admin)/layout.tsx:
export default function AdminLayout({ children }: { children: React.ReactNode }) {
return (
<div style={{ display: 'flex', minHeight: '100vh' }}>
<aside style={{ width: 220, background: '#1a202c', color: '#fff', padding: 20 }}>
管理台侧边栏
</aside>
<main style={{ flex: 1, padding: 24 }}>{children}</main>
</div>
);
}
访问 /pricing 走营销布局,/dashboard 走管理布局,URL 干净。
步骤 4:并行路由 @slot
需求:dashboard 页面要同时展示「用户列表」和「统计图表」,两个区域独立加载、独立错误边界,互不影响。
app/dashboard/
├── layout.tsx
├── page.tsx ← 主内容
├── @users/
│ ├── page.tsx ← 用户列表
│ ├── loading.tsx
│ └── error.tsx
└── @stats/
├── page.tsx ← 图表
└── loading.tsx
app/dashboard/layout.tsx:
export default function DashboardLayout({
children,
users,
stats,
}: {
children: React.ReactNode;
users: React.ReactNode;
stats: React.ReactNode;
}) {
return (
<div>
<div>{children}</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 24, padding: 24 }}>
<section style={{ border: '1px solid #ddd', padding: 16 }}>{users}</section>
<section style={{ border: '1px solid #ddd', padding: 16 }}>{stats}</section>
</div>
</div>
);
}
app/dashboard/@users/page.tsx(模拟慢加载):
async function getUsers() {
await new Promise(r => setTimeout(r, 2000));
return [{ id: 1, name: '张三' }, { id: 2, name: '李四' }];
}
export default async function UsersSlot() {
const users = await getUsers();
return (
<div>
<h3>用户列表</h3>
<ul>{users.map(u => <li key={u.id}>{u.name}</li>)}</ul>
</div>
);
}
app/dashboard/@users/loading.tsx:
export default function UsersLoading() {
return <p>用户加载中…</p>;
}
@stats/page.tsx 类似,模拟 3 秒加载。访问 /dashboard,主内容立刻出现,两个 slot 各自显示 loading、各自完成渲染——这就是流式渲染和并行数据获取的实际效果。
步骤 5:拦截路由 (..) 实现图片模态
需求:在 feed 列表点开一张图,URL 变成 /photo/123 但内容是模态层(保留 feed 背景);用户直接访问或刷新 /photo/123,看到的是完整详情页。
app/
├── feed/
│ ├── layout.tsx ← 接收 @modal slot
│ ├── page.tsx ← feed 列表
│ └── @modal/
│ ├── default.tsx ← 默认返回 null
│ └── (..)photo/
│ └── [id]/page.tsx ← 拦截的模态内容
└── photo/[id]/page.tsx ← 完整详情页
app/feed/layout.tsx:
export default function FeedLayout({
children,
modal,
}: {
children: React.ReactNode;
modal: React.ReactNode;
}) {
return (
<>
{children}
{modal}
</>
);
}
app/feed/@modal/default.tsx:
export default function Default() {
return null;
}
app/feed/page.tsx(点击链接到 /photo/123):
import Link from 'next/link';
export default function FeedPage() {
const photos = [1, 2, 3];
return (
<main style={{ padding: 40 }}>
<h1>Feed 流</h1>
<div style={{ display: 'flex', gap: 12 }}>
{photos.map(id => (
<Link key={id} href={`/photo/${id}`} style={{ display: 'block', width: 100, height: 100, background: '#ccc', textAlign: 'center', lineHeight: '100px' }}>
图{id}
</Link>
))}
</div>
</main>
);
}
app/feed/@modal/(..)photo/[id]/page.tsx(模态内容):
interface Props {
params: Promise<{ id: string }>;
}
export default async function PhotoModal({ params }: Props) {
const { id } = await params;
return (
<div style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.6)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 50 }}>
<div style={{ background: '#fff', padding: 40, borderRadius: 8 }}>
<h2>模态:图片 {id}</h2>
<p>从 feed 点击进入,看到模态。</p>
</div>
</div>
);
}
app/photo/[id]/page.tsx(直接访问的完整页):
interface Props {
params: Promise<{ id: string }>;
}
export default async function PhotoPage({ params }: Props) {
const { id } = await params;
return (
<main style={{ padding: 40 }}>
<h1>完整页:图片 {id}</h1>
<p>直接访问 /photo/{id} 时渲染。</p>
</main>
);
}
验证效果:
- 从
/feed点击「图1」→ URL 变成/photo/1,显示模态,feed 仍在背景 - 直接刷新或新开
/photo/1→ 渲染完整详情页
步骤 6:链接导航和编程式导航
'use client';
import { useRouter } from 'next/navigation';
import Link from 'next/link';
export default function NavDemo() {
const router = useRouter();
return (
<div>
{/* 声明式:自动预取 */}
<Link href="/about" prefetch={true}>关于</Link>
{/* 编程式:登录后跳转等场景 */}
<button onClick={() => router.push('/dashboard')}>进入控制台</button>
<button onClick={() => router.replace('/login')}>替换历史</button>
<button onClick={() => router.back()}>返回</button>
<button onClick={() => router.refresh()}>刷新当前路由(重新执行 Server Component)</button>
</div>
);
}
讲清楚要点
- 保留文件名约定:
page.tsx(页面)、layout.tsx(布局)、loading.tsx(加载中)、error.tsx(错误边界)、not-found.tsx(404)、template.tsx(每次都重新挂载的 layout)、default.tsx(slot 默认值)、route.ts(API)。文件名是契约。 - 动态段三种:
[id]单段、[...slug]多段(至少一段)、[[...slug]]可选多段(0 或多段)。 - 路由组
(name):纯组织作用,不影响 URL,常用于「多个根布局」「按域分组」「把页面塞进不同 layout」。 - 私有文件夹
_folder:以下划线开头的文件夹被路由系统忽略,可以放组件、工具、样式等不暴露成 URL 的文件。 - 并行路由
@slot:同一 layout 内并行渲染多个独立区域,每个 slot 有自己的 loading/error/数据获取,互不影响。slot 不是 URL 的一部分,刷新后由default.tsx兜底。 - 拦截路由
(.)(..)(...):在同一会话中「劫持」对某 URL 的导航,渲染另一个组件。(.)同级、(..)上一级、(...)从根开始。必须配合并行路由的 slot 使用。 params是 Promise(Next.js 15+):动态路由的参数和searchParams都是异步的,必须await才能拿到。
你可能会懵的地方
-
template.tsx和layout.tsx区别? layout 切换路由时保持挂载,状态保留;template 每次切换都重新挂载,状态清空。template 适合做「每次进入页面都重新执行的动画、统计 PV 的 useEffect」。 -
路由组里两个
page.tsx会不会冲突? 看路径。(marketing)/page.tsx和(shop)/page.tsx都映射到/,会冲突,构建时报错。但(marketing)/about/page.tsx和(shop)/contact/page.tsx不冲突,因为 URL 不同。 -
default.tsx是干嘛的? 并行路由的 slot 在某些导航场景下没有匹配内容(比如直接刷新页面),就用default.tsx兜底。不提供的 slot 在不匹配时会 404。 -
router.push和<Link>都能跳转,选哪个? 声明式优先用<Link>(自动预取、SSR 友好、可访问性好)。编程式只在事件回调里用:表单提交后跳转、登录后跳转、定时跳转。 -
(.)(..)(...)怎么记? 类比文件路径:.当前段、..上一段、...根目录。(..)photo表示「拦截上一层级下的 photo 段」。 -
能在 Server Component 里用
useRouter吗? 不能。useRouter来自next/navigation,是客户端 hook,Server Component 里跳转用redirect(),刷新数据用revalidatePath()/revalidateTag()。
错法对照
| 错法 | 问题 | 正解 |
|---|---|---|
app/blog/[id]/page.tsx 直接 params.id | Next.js 15 中 params 是 Promise | const { id } = await params; |
用 (marketing) 包裹内容后期望 URL 变成 /marketing/... | 路由组不出现在 URL | 想要 URL 出现 marketing,去掉括号 |
并行路由没写 default.tsx | 刷新页面 404 | 每个 slot 必须有 default.tsx |
在 @modal/(..)photo 写成 (..)/photo | 拦截段语法不带斜杠 | (..)photo 直接接 |
Server Component 里写 useRouter().push | hook 不存在 | 用 import { redirect } from 'next/navigation' 调 redirect('/x') |
路由组里和外面同时存在 page.tsx 同路径 | 路由冲突,构建失败 | 保证 URL 唯一性,每组路径错开 |
卡点急救
- 拦截路由不生效:检查 slot 命名(
@modal)、default.tsx 是否存在、拦截路径层级是否算对((.)当前目录、(..)上一层)。 useRouter返回undefined:从next/router引入了。App Router 必须用next/navigation。- 路由组导致 404:多半是同 URL 在两个组里都有
page.tsx,构建时其实应该报错,删掉其一即可。 params解构后是 undefined:Next.js 15 升级遗留问题。params改成Promise,函数签名是{ params: Promise<{...}> },调用处要await params。- slot 的内容覆盖了主 children:layout 里渲染顺序问题。
{children}和{slot}的先后、CSS 布局都自己控制,Next.js 不管叠放。
面试常问
-
App Router 的路由约定有哪些保留文件?各自作用?
page页面、layout共享布局(不重置状态)、template共享布局(每次重置)、loadingSuspense 兜底、errorError Boundary、not-found404 兜底、default并行路由兜底、routeAPI 端点。 -
怎么做「点列表项弹模态,刷新后是详情页」? 并行路由 + 拦截路由:列表页 layout 接收
@modalslot,@modal/(..)detail/[id]/page.tsx拦截对/detail/[id]的导航渲染模态;/detail/[id]/page.tsx是直接访问的完整页。关键点:@modal/default.tsx返回 null。 -
路由组和并行路由的区别? 路由组
(name)是逻辑分组,不影响 URL,一个 URL 一个页面;并行路由@slot是同一 layout 内同时渲染多棵组件树,每个 slot 独立加载、独立错误处理。前者是组织代码,后者是组织 UI 区域。 -
Next.js 15 中 params 为什么是 Promise? 为了支持流式渲染下参数可以延迟解析、为未来的 partial prerendering 做铺垫,统一异步接口避免边界 case。
-
redirect和router.push区别?redirect在 Server Component / Server Action / Route Handler 里调用,抛出NEXT_REDIRECT异常中断渲染;router.push是客户端 hook 方法,在事件回调里执行客户端跳转。前者可配合 307 状态码、可中断数据获取;后者无刷新。
自测三问
app/shop/[[...slug]]/page.tsx能匹配哪些 URL?和[...slug]区别在哪?- 用
(..)photo拦截路由,它拦截的是哪一层级的photo段?如果想拦截根目录下的photo,应该写成什么? - 并行路由
@users和@stats同时抛错时,error.tsx写在哪个层级最合理?为什么?
小结一句话
App Router 用 [动态]、(组)、@槽、(..)拦截 四套语法糖,把复杂的 URL 结构、布局复用、并行渲染、模态导航统一在「文件系统即路由」一套心智模型里。
延伸
- Next.js 官方 - Routing 章节
- Intercepting Routes 官方示例
- 下一课:App 与 Layout——
layout.tsx、template.tsx、loading.tsx、error.tsx、not-found.tsx的组合用法,掌握嵌套布局的真正威力。