#Next.js#React

Next.js App Router 深度:路由、布局与数据流

App Router 是 Next.js 的核心,但它的嵌套布局/并行路由/拦截路由/动态段很多人没用透。本文讲清 App Router 的文件约定(layout/page/template/loading/error)、并行路由与拦截路由的高级用法、Server/Client 组件数据流,以及从 Pages Router 迁移的关键差异。

本文要点

  • 主题聚焦:Next.js、React
  • 适合人群:独立开发者、全栈工程师、AI 应用开发者
  • 核心价值:快速理解背景、实践路径、常见误区和工程建议
作者 @admin·发布于 2026年3月3日·更新于 2026年7月23日

很多人把 App Router 当成 Pages Router 的"换皮"——把 pages/ 改名 app/,加个 layout.tsx,就觉得自己会了。这种理解会在第一次写模态、第一次跨段共享状态、第一次想拿到 searchParams 时彻底崩盘。

App Router 不是文件结构的调整,是渲染模型的重写:默认 Server Components、布局真正嵌套且持久化、数据获取从"页面级 getServerSideProps"下沉到"组件级 async"。这篇把路由、布局、数据流和迁移一次性讲透,所有 API 以 Next.js 16 为准。

一、范式转变:从 Pages 到 App,不只是换文件夹

Pages Router 的核心隐喻是"一个文件 = 一个路由 = 一次取数"。pages/blog/[slug].tsx 既负责 URL,又负责 getStaticProps/getServerSideProps,布局靠 _app.tsx 这种全局壳子硬塞,想嵌套就得手写组件层级。

App Router 换了三个底层假设:

  1. 文件即路由,但路由是"段(segment)"的组合。URL 由目录拼出来,每个目录可以独立携带 UI 约定文件。
  2. 布局是一等公民,且天然嵌套、天然持久。导航时布局不重渲染,这是 Pages Router 用 _app 永远做不到的。
  3. 组件默认跑在服务端。page.tsx 默认是 Server Component,可以直接 await fetch,不需要任何专门的数据获取函数。

一个典型结构长这样:

text
app/
├── layout.tsx            # 根布局,必须含 <html><body>
├── page.tsx              # 首页 /
├── blog/
│   ├── layout.tsx        # 博客专属布局,嵌套在根布局内
│   ├── page.tsx          # /blog
│   └── [slug]/
│       ├── page.tsx      # /blog/:slug
│       └── loading.tsx   # 该段的 Suspense 兜底
└── (marketing)/          # 路由组,不影响 URL
    └── about/page.tsx    # /about

记住一句话:在 Pages 里你组织的是"页面",在 App 里你组织的是"布局树 + 叶子页"。这个心智模型的切换比任何 API 都重要。

二、核心文件约定:layout / page / template / loading / error

每个 segment 目录可以放五类特殊文件,它们在组件树里的相对位置是固定的,理解这个层级比背 API 重要:

text
<Layout>              ← layout.tsx:最外层,跨导航持久
  <Template>          ← template.tsx:每次导航重挂载
    <Error>           ← error.tsx:错误边界(必须是 Client)
      <Loading>       ← loading.tsx:Suspense 兜底
        <Page />      ← page.tsx:叶子,路由真正的内容

逐个说实战要点:

layout.tsx —— 持久布局,不要乱塞数据。布局在客户端导航时会被复用、不会重渲染,这正是它适合放导航栏、侧边栏的原因。但这也意味着它拿不到 searchParams、拿不到 pathname(拿到也是陈旧的)。需要这些值的组件必须拆成 Client Component(usePathname/useSearchParams)再嵌进去。根布局是唯一必须包含 <html> 和 <body> 的地方,且 <head> 别手写,交给 Metadata API。

page.tsx —— 路由的叶子,唯一让 segment 可访问的文件。没有 page.tsx 的 segment 不算公开路由。它默认是 Server Component。

template.tsx —— 当你需要"每次进入都重置"时用。它和 layout 长得像,但内部被赋了一个基于 segment 的唯一 key,所以导航时会重挂载,内部 Client 状态会重置、useEffect 会重跑。典型场景:表单页(每次进入要清空)、需要每次进入都重新触发动画的页面。一个易错点:template 的 key 跟着当前段的动态参数走,深层段变化不会重挂载上层 template。

loading.tsx —— 自动 Suspense 边界。它会把同段的 page.tsx 及其子布局包进一个 <Suspense fallback={<Loading/>}>。导航是可中断的、共享布局在加载期间保持可交互。但有个大坑:loading.tsx 在层级上位于 layout.tsx 之下,所以布局自己里面的未缓存取数(比如裸 cookies()、未缓存的 fetch)它接不住——要么把这些数据移到 page.tsx,要么自己在 layout 里再套一层 <Suspense>。

error.tsx —— 错误边界,必须是 Client Component。Next.js 16 起,error.tsx 拿到的 props 里推荐用 unstable_retry() 来重新拉取并重渲染出错的子树(老的 reset() 只清状态不重新取数,大多数场景应该用前者)。注意 error.tsx 不包裹同段的 layout,所以布局挂了它救不了——救根布局要用根目录的 global-error.tsx,它得自己写 <html><body>。

三、动态路由:[slug]、catch-all 与 generateStaticParams

动态段三种写法,语义分明:

写法匹配params 形态
[slug]单段,如 /blog/a{ slug: 'a' }
[...slug]catch-all,至少一段,如 /shop/a/b{ slug: ['a','b'] }
[[...slug]]optional catch-all,连父路径本身也匹配,如 /shop{ slug: undefined }

注意 Next.js 15 起,params 是 Promise,16 仍然如此。Server Component 里直接 await,Client Component 里用 React 的 use():

tsx
// app/blog/[slug]/page.tsx
export default async function Page(props: PageProps<'/blog/[slug]'>) {
  const { slug } = await props.params
  const post = await getPost(slug)
  return <article>{post.title}</article>
}

这里用的是 Next.js 16 的全局类型助手 PageProps<'/blog/[slug]'>——路由字面量直接给你严格的 params/searchParams 类型,不用手写。LayoutProps<'/dashboard'>、RouteContext<'/api/...'> 同理,next dev / next build / next typegen 时生成。

要预生成静态页,用 generateStaticParams:

tsx
export async function generateStaticParams() {
  const posts = await fetch('https://api.example.com/posts').then(r => r.json())
  return posts.map((p: { slug: string }) => ({ slug: p.slug }))
}

它返回的段会在构建时静态预渲染;没被列出的段则按需渲染。它同样适用于 Route Handler,能让 /api/posts/[id] 的 GET 在构建时预生成。

四、高级路由:并行路由、拦截路由、路由组

这三件套是 App Router 真正的杀手锏,也是文档看得懂、自己写不出来的部分。

路由组 (group)——纯组织,不影响 URL。用括号包目录,比如 (marketing)/about/page.tsx 仍然是 /about。用途:给一组路由共享一个 layout,又不想污染 URL;或者给同一 URL 段拆出多套根布局。

并行路由 @slot——一个页面,多个独立区域。目录名以 @ 开头(@analytics、@team),它们不是路由段、不出现在 URL 里,而是作为具名 prop 注入到所在 layout:

tsx
// app/dashboard/layout.tsx
export default function Layout({
  children, analytics, team,
}: {
  children: React.ReactNode
  analytics: React.ReactNode
  team: React.ReactNode
}) {
  return (
    <>
      {children}
      {team}
      {analytics}
    </>
  )
}

它最大的价值是软导航时的局部渲染:点 @team 里的链接只重渲染 @team,@analytics 保持当前激活子页不动。硬导航(刷新)时找不到匹配的 slot 会回落到 default.js,没有就 404。仪表盘、社交 feed 这种"多面板各自独立"的页面,没有并行路由几乎做不优雅。

拦截路由 (.)——把别人的路由"借"进当前布局。用相对段约定:同层 (.)、上一层 (..)、上两层 (..)(..)、从根 (...)。注意它基于路由段,不算文件系统层级——@slot 不计入。

拦截路由最常见的用法是和并行路由配合做可深链接的模态。比如点 feed 里的图片,URL 变成 /photo/123 但弹出模态而非跳页;而别人直接打开 /photo/123 这个分享链接时,走完整 photo 页面:

text
app/
├── feed/page.tsx
├── photo/[id]/page.tsx          # 完整页(分享链接、刷新时走这里)
└── @modal/
    ├── default.tsx              # 返回 null
    └── (..)photo/[id]/page.tsx  # 拦截 /photo/[id],在模态里渲染

这一套解决了模态的四大难题:URL 可分享、刷新保留上下文、后退关模态、前进重开模态。用 router.back() 关闭,或导航到 slot 内返回 null 的页面来"擦除"模态。

五、布局与状态:什么该持久,什么该重置

App Router 最容易踩的坑是状态边界,因为它和你的直觉相反。

layout.tsx 在导航间持久——这意味着放在 layout 里的 Client Component 的 state(input 值、滚动位置、展开状态)会跨页面保留。这是把双刃剑:做全局购物车、侧边栏折叠态时是福利,做表单时是 bug 源。

判断标准很简单:

  • 跨页要留 → 放 layout.tsx(或更上层的 Context Provider)
  • 进入就清 → 放 page.tsx,或用 template.tsx 强制重挂载
  • 多个 slot 各自独立 → 用并行路由,每个 slot 状态互不干扰

Provider 是另一个高频问题。把全局 Provider(主题、鉴权、Toast)放在根 layout 里没问题,但不要在 layout 里塞太多 Client Provider 包裹 children——每多一层都会让下层 Server Component 的边界变得模糊。原则:Provider 尽量薄、尽量靠上,业务取数留在 page 里。

六、Server/Client 数据流:params 是 Promise

App Router 的数据流核心就一句:Server Component 取数,Client Component 交互。Server 可以直接 await,然后把可序列化的数据当 props 往下传;一旦遇到 useState、onClick、useEffect,就必须 'use client' 切到客户端,切下去之后就不能再 await 了。

page.tsx 和 layout.tsx 都能拿到两个关键 props,在 Next.js 16 里都是 Promise:

  • params:动态路由段(见第三节)
  • searchParams:URL 查询串,Promise<{ [key]: string | string[] | undefined }>。注意它不是 URLSearchParams,是普通对象;用它会让页面进入动态渲染(因为是请求时才能确定的值)
tsx
// app/shop/page.tsx
export default async function Page({
  searchParams,
}: PageProps<'/shop'>) {
  const { page = '1', sort = 'asc' } = await searchParams
  const products = await getProducts({ page, sort })
  return <ProductGrid items={products} />
}

Client 页面里要用 use(searchParams) 而不是 await。还有几个易错点:

  1. fetch 默认去重,同一请求多次调用只发一次,可以放心在 layout 和 page 各取一次。
  2. headers() / cookies() 是请求时 API,会让组件变动态,且会破坏 loading.tsx 的兜底(见第二节)——需要时自己套 <Suspense>。
  3. Server 不能直接传函数/类实例给 Client,只能传可序列化数据。需要事件回调就把交互组件标 'use client',在它内部用 Server Action 或 fetch 回服务端。

七、从 Pages Router 迁移:不是平移,是重构

最大的误区是想"逐文件平移"。pages/blog/[slug].tsx 里那套 getStaticProps + getStaticPaths + 顶层布局的组合,在 App Router 里被拆成了 page.tsx(取数)+ generateStaticParams(预生成)+ layout.tsx(布局)三个文件,职责重新切分了。把它当一个重构项目来做,心理预期会健康很多。

迁移的关键差异清单:

  • getServerSideProps / getStaticProps → 直接在 Server Component 里 await 取数
  • getStaticPaths → generateStaticParams
  • _app.tsx 全局壳 → 根 layout.tsx
  • _document.tsx → 不需要了,根 layout 写 <html><body>,head 用 Metadata API
  • next/router 的 useRouter → next/navigation 的 useRouter、usePathname、useSearchParams、useParams(注意 API 完全不同)
  • getInitialProps → 别用了,彻底告别

好消息是两者可以共存:app/ 和 pages/ 同时存在,Next.js 给 pages/ 下的路由加一层传统外壳,允许你渐进迁移。建议从叶子路由开始、自底向上迁,每迁一个 segment 就删掉对应的 pages/ 文件,最后再处理根布局。迁移期间 pages/ 的路由优先级更高,别两边都留着同名路由。

小结

  • App Router 是渲染模型重写,不是文件换皮。文件即段、布局嵌套持久、组件默认服务端,这三条心智模型先建立,API 才有意义。
  • 五类特殊文件的层级是固定的:layout(持久)> template(重挂载)> error(客户端错误边界)> loading(Suspense)> page(叶子)。层级决定了谁能兜住谁——loading 兜不住 layout 自己的未取数。
  • params/searchParams 在 Next.js 16 是 Promise,Server 里 await、Client 里 use(),配合 PageProps<'/route'> 拿严格类型。
  • 并行路由 + 拦截路由 是高级 UI(仪表盘、可深链模态)的正规解法,值得专门花一天练手,别等用到再翻文档。
  • 迁移是重构不是平移,职责被重新切分,但 app/ 与 pages/ 可共存,自底向上渐进迁移最稳。

一句话金句:在 App Router 里,你不是在写页面,你是在组织一棵会持久化的布局树,页面只是它最末端的叶子。 想清楚"这份数据、这段状态、这个 UI 该挂在树的哪一层",比记住任何 API 都重要。

Related

相关推荐