Skip to main content

App Router 路由机制

面试题:App Router 相对 Pages Router 有什么优势?嵌套 Layout、Streaming、Parallel Routes 怎么用?

一、App Router vs Pages Router

维度Pages Router(pages/)App Router(app/)
文件类型.js / .jsx / .tsxpage.tsx / layout.tsx
布局手动嵌套 _app.tsx文件系统嵌套 layout
数据获取getServerSideProps组件内 await
默认渲染客户端 hydrateServer Component
Loading手动写loading.tsx + Suspense
Error_error.tsxerror.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.tsxLoading UI(自动包 Suspense)
error.tsxError Boundary
not-found.tsx404 页面
template.tsx类似 layout 但每次导航重新挂载
default.tsxParallel Routes 回退
route.tsAPI 路由(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 里。

十、相关文档