App Router 路由机制
面试题:App Router 相对 Pages Router 有什么优势?嵌套 Layout、Streaming、Parallel Routes 怎么用?
一、App Router vs Pages Router
| 维度 | Pages Router(pages/) | App Router(app/) |
|---|---|---|
| 文件类型 | .js / .jsx / .tsx | page.tsx / layout.tsx 等 |
| 布局 | 手动嵌套 _app.tsx | 文件系统嵌套 layout |
| 数据获取 | getServerSideProps 等 | 组件内 await |
| 默认渲染 | 客户端 hydrate | Server Component |
| Loading | 手动写 | loading.tsx + Suspense |
| Error | _error.tsx | error.tsx 嵌套 |
| API 路由 | pages/api/* | app/api/*/route.ts |
| 流式渲染 | 不支持 | 原生 Streaming |
| 状态 | 稳定 | 15+ 已稳定(13.4 稳定) |
面试立场:新项目一律 App Router。Pages Router 已进入维护期,只在迁移老项目时使用。
二、文件系统路由
2.1 基本规则
app/
├─ page.tsx → /
├─ about/page.tsx → /about
├─ blog/
│ ├─ page.tsx → /blog
│ └─ [slug]/page.tsx → /blog/:slug
├─ shop/
│ └─ [...catchAll]/page.tsx → /shop/*(通配)
└─ (marketing)/ → 路由分组,不影响 URL
├─ pricing/page.tsx → /pricing
└─ about/page.tsx → /about
特殊文件名(约定大于配置):
| 文件 | 作用 |
|---|---|
page.tsx | 路由页面 |
layout.tsx | 嵌套布局 |
loading.tsx | Loading UI(自动包 Suspense) |
error.tsx | Error Boundary |
not-found.tsx | 404 页面 |
template.tsx | 类似 layout 但每次导航重新挂载 |
default.tsx | Parallel Routes 回退 |
route.ts | API 路由(GET/POST 等) |
middleware.ts | 全局中间件 |
2.2 动态路由
// app/blog/[slug]/page.tsx
export default async function Page({ params }) {
const { slug } = await params; // 15+ 异步 params
const post = await db.post.findUnique({ where: { slug } });
return <article>{post.content}</article>;
}
// app/shop/[...catchAll]/page.tsx → /shop/a/b/c
export default async function Page({ params }) {
const { catchAll } = await params; // ["a", "b", "c"]
}
// app/shop/[[...optional]]/page.tsx → /shop 或 /shop/a 都匹配
2.3 路由分组 (group)
app/
├─ (marketing)/ ← 分组,不影响 URL
│ ├─ about/page.tsx → /about
│ └─ pricing/page.tsx → /pricing
├─ (dashboard)/
│ └─ dashboard/
│ ├─ layout.tsx ← 只有 dashboard 路径用这个 layout
│ └─ page.tsx → /dashboard
用途:多套布局(营销页 vs 后台 vs 登录)。
三、嵌套 Layout
3.1 树形结构
// app/layout.tsx —— 根 layout(必须有)
export default function RootLayout({ children }) {
return (
<html lang="zh">
<body>
<Nav />
{children}
<Footer />
</body>
</html>
);
}
// app/(dashboard)/dashboard/layout.tsx —— 嵌套 layout
export default function DashboardLayout({ children }) {
return (
<div className="dashboard">
<Sidebar />
<main>{children}</main>
</div>
);
}
渲染结果(访问 /dashboard/users):
<html>
<body>
<nav />
<div class="dashboard">
← DashboardLayout
<Sidebar />
<main>
<h1>用户列表</h1>
← page.tsx
</main>
</div>
<footer />
</body>
</html>
3.2 关键规则
- 根 layout 唯一:只能有
app/layout.tsx,必须有<html>和<body> - 嵌套 layout 自动包裹:所有子路由都会被父 layout 包裹
- layout 不会重新渲染:跨路由切换时保持状态(vs template 重新挂载)
面试题:layout vs template 区别? layout 在导航间保持状态(不重新挂载),template 每次都重新挂载。需要强制重置状态(如表单)时用 template。
四、Loading UI 与 Streaming
4.1 自动 Suspense
// app/dashboard/page.tsx
import { Suspense } from "react";
export default function Page() {
return (
<div>
<h1>Dashboard</h1>
<Suspense fallback={<Skeleton />}>
<SlowData /> {/* 异步组件 */}
</Suspense>
</div>
);
}
或用约定式 loading.tsx:
app/
└─ dashboard/
├─ page.tsx
└─ loading.tsx ← 自动包裹 page.tsx 的 Suspense fallback
// app/dashboard/loading.tsx
export default function Loading() {
return <div>Loading...</div>;
}
4.2 文件级 loading 的优势
无需手动写 <Suspense>,约定式文件自动包裹上一层 page.tsx。
五、Error Boundary
app/
└─ dashboard/
├─ page.tsx
├─ loading.tsx
└─ error.tsx ← 错误兜底
// app/dashboard/error.tsx
"use client"; // error.tsx 必须是 Client Component
export default function Error({ error, reset }) {
return (
<div>
<h1>出错了</h1>
<p>{error.message}</p>
<button onClick={reset}>重试</button> {/* reset() 重新渲染 */}
</div>
);
}
错误捕获范围:
app/dashboard/error.tsx
↓ 捕获
app/dashboard/page.tsx 及其子组件
(不会捕获 app/layout.tsx 错误)
面试题:root error.tsx? 如果是
app/global-error.tsx,必须包含<html>和<body>,是兜底的兜底。
六、Parallel Routes(并行路由)
面试题:Parallel Routes 用来解决什么问题?
6.1 场景
主页面要同时展示多个独立面板(如后台的「团队列表 + 通知」),每个面板有自己的 loading/error 状态。
6.2 实现
app/
└─ dashboard/
├─ layout.tsx
├─ page.tsx ← 主内容
├─ @team/ ← 命名 slot
│ ├─ page.tsx
│ ├─ loading.tsx
│ └─ error.tsx
└─ @notifications/
└─ page.tsx
// app/dashboard/layout.tsx
export default function Layout({ children, team, notifications }) {
return (
<div>
<main>{children}</main>
<aside>
{team} ← @team slot
{notifications} ← @notifications slot
</aside>
</div>
);
}
优势:
- 多个 slot 独立流式渲染(team 慢不影响 notifications)
- 每个 slot 独立 error handling
- 适合复杂后台 UI
七、Intercepting Routes(拦截路由)
面试题:拦截路由是什么?和 Modal 有什么关系?
7.1 场景
点击「用户头像」希望在当前页面弹出 Modal,而不是直接跳转。
7.2 规则
app/
├─ feed/
│ └─ page.tsx ← 主 feed 列表
├─ @modal/ ← Parallel slot
│ └─ (..)photo/
│ └─ [id]/
│ └─ page.tsx ← 拦截 /photo/[id]
└─ photo/
└─ [id]/
└─ page.tsx ← 真实路由(直接访问时全屏)
拦截语法:
| 写法 | 含义 |
|---|---|
(.) | 同层级 |
(..) | 上 1 级 |
(..)(..) | 上 2 级 |
(...) | 根 app/ 目录 |
效果:
- 从
/feed点击链接/photo/123→ 显示 Modal(走intercepting路由) - 直接访问
/photo/123或刷新 → 全屏显示(走真实路由)
八、Middleware
// middleware.ts(项目根或 src/ 下)
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
export function middleware(req: NextRequest) {
const token = req.cookies.get("token");
// 鉴权拦截
if (!token && req.nextUrl.pathname.startsWith("/dashboard")) {
return NextResponse.redirect(new URL("/login", req.url));
}
// 注入请求头
const requestHeaders = new Headers(req.headers);
requestHeaders.set("x-user-id", "123");
return NextResponse.next({
request: { headers: requestHeaders },
});
}
export const config = {
matcher: ["/dashboard/:path*", "/api/:path*"], // 匹配哪些路径
};
特点:
- Edge Runtime 执行(V8 isolate,比 Node 启动快 10x)
- 适合鉴权、A/B 测试、请求头注入
- 不能用 Node API(fs、db)
面试题:Middleware 性能为什么好? 跑在 V8 isolate(轻量沙箱),冷启动 < 5ms,比传统 Node 函数快 10 倍。代价是只能用 Web 标准 API。
九、面试高频追问
Q1:layout 和 page 什么关系?
A:layout 是父级包裹,page 是当前路由的内容。每个路由会先找最近的 layout,再找最近的 page。layout 不会重新渲染,page 每次导航重新挂载。
Q2:怎么强制页面重新渲染(清状态)?
A:用 template.tsx 替代 layout.tsx,或用 router.refresh()。
Q3:Parallel Routes 的 @ 命名空间有什么限制?
A:必须挂在 layout 下才能接收;每个 slot 必须有 default.tsx(兜底渲染,否则硬刷新会 404)。
Q4:怎么实现 Modal 弹窗 + 刷新全屏?
A:使用 Intercepting Routes + Parallel Routes 的组合方案(上面 §7)。
Q5:Middleware 能访问 DB 吗?
A:不能。Middleware 跑在 Edge Runtime,没有 Node API 和 DB 连接。需要 DB 的鉴权逻辑应该放在 Server Component 或 Route Handler 里。