一、引言
App Router 是 Next.js 13 引入的新一代路由与渲染体系,它将 React 18 的 Server Components、Streaming 与 Suspense 首次真正带入生产框架。与 Pages Router 相比,App Router 不是一个渐进增强,而是一次范式切换:数据获取从 getServerSideProps / getStaticProps 的「页面级函数」变为「组件级并发请求」;渲染从「整页 HTML 一次性返回」变为「按 Suspense 边界分块流式输出」;缓存从「ISR 单一静态再生」变为「fetch / Router Cache / 客户端缓存三层协同」。
许多团队迁移到 App Router 后遇到的最大困惑,不是语法,而是缓存语义:为什么 fetch 默认会被缓存?为什么重新部署后客户端还显示旧数据?revalidateTag 与 revalidatePath 有什么区别?本文从底层机制出发,拆解 Server Components 数据获取、Streaming/Suspense 流式渲染,以及三层缓存语义,最后给出与 Pages Router 的完整对照与迁移清单。
本文属于工具与平台实战专题,建议与 Next.js + Jamstack + SSR + SPA 混合实战指南对照阅读;渲染模式的基础概念可参考前端架构模式对比。
二、App Router 与 Pages Router 的总体对照
2.1 目录约定与路由模型
App Router 使用 app/ 目录,Pages Router 使用 pages/ 目录。两者可并存(渐进迁移),但同一 URL 不能同时被两套目录命中。
| 能力 | Pages Router (pages/) | App Router (app/) |
|---|---|---|
| 路由文件 | pages/about.tsx | app/about/page.tsx |
| 布局 | 无原生布局,需 _app.tsx + 自定义 | layout.tsx 原生嵌套布局 |
| 模板 | 无 | template.tsx(路由切换时重建) |
| 加载态 | 无 | loading.tsx(自动 Suspense 边界) |
| 错误处理 | _error.tsx / ErrorBoundary 手动 | error.tsx / global-error.tsx |
| 动态路由 | [id].tsx / [...slug].tsx | [id]/page.tsx / [...slug]/page.tsx |
| 服务端数据获取 | getServerSideProps / getStaticProps | Server Components 直接 async |
| 组件默认运行端 | 客户端(CSR) | 服务端(RSC) |
| 流式渲染 | 不支持(整页等待) | 支持(按 Suspense 边界流式) |
| 缓存 | ISR(按页 revalidate) | fetch / Router Cache / 按路由段 |
2.2 渲染模型:从「页面级」到「组件级」
Pages Router 的 SSR 是页面级瀑布:浏览器请求页面 → getServerSideProps 执行 → 全部数据就绪 → 一次性返回完整 HTML。任何一个慢接口都会拖垮整页 TTFB。
App Router 将数据获取下沉到组件级:page.tsx 只负责壳与布局,内部每个依赖数据的子组件都可以独立 await fetch,并包裹在各自的 <Suspense> 边界中。慢的部分先输出 <template> 占位,快的部分先到达浏览器。
Pages Router: App Router:
请求 → getServerSideProps 请求 → layout.tsx(立即输出)
→ 慢数据A → page.tsx 壳 + <Suspense>
→ 慢数据B → 快数据组件(先流出)
→ 全部完成 → 整页 HTML → 慢数据组件(后流出,替换占位)
(TTFB = 最慢请求) (TTFB = 首块壳)
这条差异是理解 App Router 一切设计的钥匙:它把「服务端渲染」从一次性的整页事务,重构为可分块、可流式、可增量缓存的组件流水线。
三、Server Components 数据获取深度
3.1 RSC 与 fetch 的服务端执行模型
Server Components 是默认组件类型,它们在服务端执行一次(每请求或按缓存),返回描述 UI 的 RSC Payload(一种序列化的 React 元素树),客户端用此 Payload 调和出界面。组件内的 fetch 直接执行真实网络请求,无需 getServerSideProps 的间接层:
// app/posts/page.tsx — 服务端组件,直接 async
import { PostCard } from '@/components/post-card'
async function getPosts() {
// fetch 默认会进入 Data Cache(见第四章)
const res = await fetch('https://api.example.com/posts', {
next: { revalidate: 60 },
})
if (!res.ok) throw new Error('Failed to fetch posts')
return res.json()
}
export default async function PostsPage() {
const posts = await getPosts()
return (
<ul>
{posts.map((p) => (
<PostCard key={p.id} {...p} />
))}
</ul>
)
}
要点:
- 服务端组件可以直接
async/await,浏览器永远不会收到这段代码。 fetch的next.revalidate等价于旧 ISR 的按数据块revalidate,但粒度从「整个页面」细化为「单次请求结果」。- 未走
fetch的数据源(ORM、直接查数据库)默认每次请求执行,需要配合 React 的cache()做请求级记忆化:
import { cache } from 'react'
import { db } from '@/lib/db'
// React cache():同一请求内去重,避免并发组件重复查库
export const getArticle = cache(async (slug: string) => {
return db.article.findUnique({ where: { slug } })
})
3.2 并行数据获取与序列瀑布
App Router 中两个组件各自 await 不会自动并行——它们按树渲染顺序执行。要并行,需要在同一组件内使用 Promise.all:
// 坏:两个组件各自 await,形成序列瀑布
// <Header data={await getProfile()} />
// <Feed data={await getFeed()} />
// 好:父组件用 Promise.all 并行发起,再向下传
export default async function Dashboard() {
const [profile, feed] = await Promise.all([
getProfile(),
getFeed(),
])
return (
<>
<Header profile={profile} />
<Feed items={feed} />
</>
)
}
对比 Pages Router,getServerSideProps 天然支持并行(同一函数内 Promise.all);App Router 把这一责任下放给组件,写错就会悄悄退回序列瀑布——建议用 Promise.all 而非多个 await。
3.3 访问请求上下文
需要读取 Cookie、Header、查询参数时,使用 next/headers 与 next/navigation:
import { cookies, headers } from 'next/headers'
export default async function Page() {
// 注意:这些 API 是动态的,会使所在路由段退出静态优化
const token = (await cookies()).get('session')?.value
const ua = (await headers()).get('user-agent')
return <ClientComp token={token} ua={ua} />
}
读取查询参数只能从 page 组件的 params / searchParams prop 获取:
// app/search/page.tsx
export default async function SearchPage({
searchParams,
}: {
searchParams: Promise<{ q?: string }>
}) {
const { q } = await searchParams // Next 15 后 searchParams 为 Promise
const results = await search(q)
return <Results results={results} />
}
⚠️ 在服务端组件里不要把
cookies()/headers()的结果传给客户端组件做 props——它们含非序列化属性;只传纯值。
四、Streaming 与 Suspense:流式渲染
4.1 流式渲染原理
App Router 返回的是分块的 HTML 流:layout.tsx 与未包裹 Suspense 的部分立即输出,被 <Suspense fallback={...}> 包裹的子树延迟输出,并在数据就绪后通过流内联的脚本替换占位节点。这要求服务端渲染是增量可中断的,Node/Edge 运行时都能支持。
HTTP Response(流式):
--------------------------------------------------------------------------------
<layout 头部> <div class="shell"> ... <!--#--> <骨架UI> <!--/#--> </div>
↓ 数据就绪后追加
<script>document.replaceChildren(...)</script> <真实内容流>
4.2 Suspense 边界与 loading.tsx
loading.tsx 是每个路由段自动注入的 Suspense 边界:
// app/analytics/loading.tsx
export default function Loading() {
return (
<div className="animate-pulse">
<div className="h-4 w-48 rounded bg-gray-200" />
<div className="mt-4 h-64 rounded-lg bg-gray-200" />
</div>
)
}
当页面同时存在「首屏必须的数据」与「可延后的数据」时,正确做法是把关键数据放在页面壳层、慢数据拆进独立组件并包 Suspense:
// app/analytics/page.tsx
import { Suspense } from 'react'
import { SlowChart } from './slow-chart'
export default function AnalyticsPage() {
return (
<main>
<h1>分析面板</h1>
{/* 关键内容立即渲染 */}
<Summary />
{/* 慢图表独立流式加载 */}
<Suspense fallback={<ChartSkeleton />}>
<SlowChart />
</Suspense>
</main>
)
}
4.3 流式与动态渲染的取舍
流式渲染天然要求动态渲染——服务器必须持续保持连接直到所有边界输出完毕。因此:
- 全静态页面(纯 SSG)不会有流式收益,直接整页缓存。
- 混合页面(部分动态)是流式最大价值场景:TTFB 从「最慢子请求」降到「壳层完成」,改善 LCP 与 INP。
- 流式与 CDN 缓存冲突时,通常让「壳 + 静态部分」在 CDN 缓存,动态边界在源站流式输出(见边缘缓存策略)。
// 显式控制动态渲染
export const dynamic = 'force-dynamic' // 强制每请求渲染
export const dynamic = 'force-static' // 强制静态化
export const revalidate = 300 // 按秒 ISR 化
export const dynamicParams = false // 未声明的动态路由返回 404
五、缓存语义:fetch 缓存 / 路由缓存 / 客户端缓存
这是 App Router 被误解最多的地方。App Router 存在三层独立缓存,理解它们的边界是正确使用的前提。
5.1 第一层:Data Cache(fetch 缓存)
fetch 默认开启缓存,结果按 URL + options 作为键,存于服务端数据缓存:
// 强缓存 60 秒
const data = await fetch(url, { next: { revalidate: 60 } })
// 永不缓存(等价 Pages 的每次 SSR)
const data = await fetch(url, { cache: 'no-store' })
// 强制写入缓存(无过期,需手动失效)
const data = await fetch(url, { cache: 'force-cache' })
非 fetch 数据源(Prisma、直接 SQL)不进入 Data Cache,除非手动接入:
// app/api/lib/cache.ts — 用 unstable_cache 接入 Data Cache
import { unstable_cache } from 'next/cache'
export const getStats = unstable_cache(
async () => db.dashboard.stats(),
['dashboard-stats'], // 缓存键的一部分(依赖列表)
{ revalidate: 300, tags: ['stats'] }
)
5.2 第二层:Router Cache(RSC Payload 客户端缓存)
客户端对已访问路由的 RSC Payload 做 30 秒软缓存(<Link> 预取也会写入)。这意味着:
- 用户点击回退按钮时,页面秒开(无需重新请求)。
- 用户重新部署后 30 秒内,已访问过的路由可能仍显示旧内容——因为客户端缓存未过期。
router.refresh()会强制刷新当前路由的 RSC Payload(但仍复用未过期的 fetch 缓存)。
'use client'
import { useRouter } from 'next/navigation'
export function RefreshButton() {
const router = useRouter()
return (
<button onClick={() => router.refresh()}>
刷新服务端数据
</button>
)
}
5.3 第三层:Full Route Cache(静态 HTML / 静态 RSC)
构建时全静态化的页面,HTML 与 RSC Payload 会进入 CDN 级的 Full Route Cache。对 generateStaticParams 产出的动态路由同样适用,行为类似旧 ISR:
// app/articles/[slug]/page.tsx
export async function generateStaticParams() {
const articles = await getAllSlugs() // 构建期执行
return articles.map(({ slug }) => ({ slug }))
}
// 构建时生成,并按 60s 周期重新验证(ISR 语义)
export const revalidate = 60
5.4 精确失效:revalidateTag 与 revalidatePath
当数据变更时,用这两种 API 主动失效,而非等 TTL 自然过期:
// app/api/articles/route.ts — 写操作后失效
import { revalidateTag, revalidatePath } from 'next/cache'
export async function POST(req: Request) {
const body = await req.json()
await db.article.create(body)
// 按标签失效:只清掉带 'articles' 标签的 fetch/unstable_cache
revalidateTag('articles')
// 按路径失效:使该路径的 Router Cache / Full Route Cache 过期
revalidatePath('/articles')
return Response.json({ ok: true })
}
配合打标签的写法:
// 打标签,便于精准失效
const data = await fetch(url, {
next: { tags: ['articles'], revalidate: 3600 },
})
选择建议:revalidateTag 适合「一类数据整体变更」(如 CMS 发布新文章);revalidatePath 适合「特定 URL 内容变更」(如编辑单篇文章)。两者都会在下一次请求时触发重新渲染,并将新结果写回缓存。
5.5 三层缓存交互速查表
| 场景 | 生效缓存 | 如何绕过 |
|---|---|---|
| 新部署后旧内容 | Router Cache(客户端 30s)+ Data Cache | router.refresh();部署时刷新路由或调低 staleTimes |
| 更新数据库后旧列表 | Data Cache / Full Route Cache | 写操作后 revalidateTag / revalidatePath |
| 需要实时数据 | 不缓存 | cache: 'no-store' + dynamic = 'force-dynamic' |
| 仅首屏前 60s 一致即可 | Data Cache | next: { revalidate: 60 } |
// next.config.ts — 调整客户端 Router Cache 时长(Next 15+)
const nextConfig = {
experimental: {
staleTimes: { dynamic: 30, static: 300 }, // 单位:秒
},
}
六、与 Pages Router 的对照与迁移路径
6.1 API 映射表
| Pages Router | App Router 等价 |
|---|---|
getServerSideProps | 服务端组件内直接 await fetch(+ cache: 'no-store') |
getStaticProps | 静态组件 + next.revalidate / generateStaticParams |
getStaticPaths | generateStaticParams |
getInitialProps | 不推荐,用 Server Components + 客户端状态 |
_app.tsx | 根 layout.tsx |
_document.tsx | app 下的 head.tsx / 根布局 |
next/router | next/navigation(useRouter / usePathname) |
next/link | 基本不变(但会触发 RSC 预取) |
middleware.ts | 兼容(App Router 中更推荐在 Server Components 里用 headers) |
6.2 渐进迁移策略
不要一次性重写,按四步渐进:
- 同目录共存:
app/与pages/可同时存在,公共代码抽到src/components与src/lib。 - 从布局迁移:把
_app.tsx的全局外壳搬进app/layout.tsx,验证嵌套布局。 - 逐路由替换:挑选内容型页面(文章详情、文档页)先迁,用
generateStaticParams复刻getStaticPaths行为。 - 替换动态页:用 Server Components + 组件级数据获取重写,用
Suspense拆分慢区域,最后处理写操作(Server Actions / Route Handlers)。
6.3 常见坑清单
- 客户端组件里
await会编译报错——改用useEffect或use()(React 19)。 - 把含
Date的对象直接传 props 到客户端组件,会触发「非序列化值」警告。 - 忘记
revalidate导致上线后数据不更新——排查顺序:Router Cache → Data Cache → Full Route Cache。 - 全站
cache: 'no-store'会把 App Router 变成纯 SSR,丢失 ISR 与预取收益——只在真正动态的路径上用。
七、最佳实践总结
| 实践 | 理由 |
|---|---|
| 数据获取放在服务端组件,客户端只渲染 | 减少客户端请求、支持缓存与流式 |
同一组件内用 Promise.all 并行请求 | 避免序列瀑布拖慢 TTFB |
慢数据拆独立组件 + <Suspense> 边界 | 流式输出,改善 LCP |
写操作后立即 revalidateTag / revalidatePath | 数据一致性,避免等 TTL |
只对真正动态的路径用 no-store | 保留 ISR / 预取收益 |
用 loading.tsx / error.tsx 做边界 | 优雅降级,无需手动 ErrorBoundary |
非 fetch 数据源用 cache() + unstable_cache | 请求级去重 + Data Cache 接入 |
App Router 的复杂度来自它把「缓存、流式、并发」三大底层能力直接暴露给了开发者。掌握 Server Components 数据获取与三层缓存语义,就能从「能用」走向「用得对」。下一篇建议阅读前端性能与 Core Web Vitals 优化,把流式渲染与缓存的收益落到用户可感知的指标上。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。