React 基础体系 · 第 65/70 篇。示例以 React 19、现代 TypeScript 和主流框架能力为基础;客户端与服务端边界会明确说明。

Next.js 路由与布局:Segment、并行路由、拦截和错误边界

Next.js App Router 的路由不是由一个集中式路由表决定的,而是由 app 目录中的文件系统树决定的。目录层级会形成 URL 层级,特殊目录名会改变路由行为,layout.tsxpage.tsxerror.tsx 等约定文件则分别参与页面组合、渲染和故障处理。

要准确理解 Next.js 路由,需要同时区分四件事:

  1. Segment:URL 路径树中的一个层级。
  2. Layout:包裹某个 Segment 及其后代的持久化 UI。
  3. Parallel Routes:在同一个布局中,同时挂载多个独立路由插槽。
  4. Intercepting Routes:在客户端软导航时,用当前页面上下文中的另一个 UI 覆盖目标路由。
  5. Error Boundary:将渲染错误限制在某个路由子树内,并提供恢复入口。

这些能力还依赖几个前置概念:动态 Segment、路由组、loading.tsxnot-found.tsx、Server Components 与 Client Components 的边界。


一、从文件系统理解 Segment

1. Segment 是什么

Segment 是 URL 路径中由一个目录层级表示的部分。

例如:

app/
├── page.tsx
├── dashboard/
│   ├── page.tsx
│   └── settings/
│       └── page.tsx
└── posts/
    └── [id]/
        └── page.tsx

对应关系如下:

文件 URL Segment
app/page.tsx / 根路由
app/dashboard/page.tsx /dashboard dashboard
app/dashboard/settings/page.tsx /dashboard/settings dashboardsettings
app/posts/[id]/page.tsx /posts/abc posts[id]

page.tsx 表示“这个路由节点可以作为页面直接访问”。只有目录而没有 page.tsx,并不意味着该目录本身一定可以访问,它可能只是布局、动态参数或路由组织结构。

可以把一个 URL 表示为 Segment 数组:

/dashboard/settings

可抽象为:

["dashboard", "settings"]

/posts/abc 而言,文件系统中的 [id] 是动态 Segment,但实际 URL 中的值是:

["posts", "abc"]

[id] 是匹配规则,abc 才是本次请求的参数值。


2. 静态、动态和捕获多个 Segment

Next.js 支持几种常用的 Segment 形式。

静态 Segment

app/about/page.tsx

匹配:

/about

动态 Segment

app/posts/[id]/page.tsx

匹配:

/posts/1
/posts/hello

页面可以读取动态参数:

// app/posts/[id]/page.tsx
type PageProps = {
  params: Promise<{ id: string }>
}

export default async function PostPage({ params }: PageProps) {
  const { id } = await params

  return <h1>文章:{id}</h1>
}

在当前主流 Next.js App Router 版本中,params 按异步值处理,因此服务端页面和布局通常需要 await params。如果项目使用的是较早版本,类型可能仍然是同步对象,实际代码应以项目版本的类型检查结果为准。

捕获多个 Segment

app/docs/[...slug]/page.tsx

可以匹配:

/docs/getting-started
/docs/api/client/request

参数结构类似:

{
  slug: ["api", "client", "request"]
}

可选捕获多个 Segment

app/docs/[[...slug]]/page.tsx

除了匹配:

/docs/a
/docs/a/b

还可以匹配:

/docs

此时 slug 可能不存在。


3. 路由组、私有目录和插槽不一定进入 URL

以下目录名称有特殊含义:

路由组:(group)

app/
├── (marketing)/
│   ├── about/
│   │   └── page.tsx
│   └── layout.tsx
└── (app)/
    ├── dashboard/
    │   └── page.tsx
    └── layout.tsx

app/(marketing)/about/page.tsx 的 URL 是:

/about

括号中的 marketing 不会出现在 URL 中。路由组通常用于:

  • 给不同路由子树设置不同布局;
  • 按业务边界组织文件;
  • 将页面拆分为多个独立入口。

需要注意,同一个 URL 不能因为路由组不同而拥有两个实际页面。例如:

app/(marketing)/about/page.tsx
app/(app)/about/page.tsx

二者最终都映射到 /about,会产生冲突。

私有目录:_folder

以下目录不会被当作路由 Segment:

app/_components/Button.tsx

它可以用于存放路由附近的组件、工具函数或测试辅助代码。

并行路由插槽:@slot

app/@analytics/page.tsx

@analytics 是一个并行路由插槽名称,不会出现在 URL 中。它的作用不是增加路径,而是向父布局注入一个名为 analytics 的属性。

因此,以下目录:

app/@analytics/page.tsx

并不对应:

/@analytics

而是对应父布局中的:

<Layout analytics={...} />

这正是并行路由的基础。


二、Layout:Segment 树中的持久化组件

1. Layout 的结构关系

一个布局文件会包裹它所在 Segment 下的页面和更深层的子 Segment。

app/
├── layout.tsx
├── dashboard/
│   ├── layout.tsx
│   ├── page.tsx
│   └── settings/
│       └── page.tsx

组件关系大致是:

RootLayout
└── DashboardLayout
    ├── DashboardPage
    └── SettingsPage

对应代码:

// app/layout.tsx
import type { ReactNode } from 'react'

export default function RootLayout({
  children,
}: {
  children: ReactNode
}) {
  return (
    <html lang="zh-CN">
      <body>
        <header>全站头部</header>
        {children}
      </body>
    </html>
  )
}
// app/dashboard/layout.tsx
import type { ReactNode } from 'react'

export default function DashboardLayout({
  children,
}: {
  children: ReactNode
}) {
  return (
    <section className="dashboard-shell">
      <aside>仪表盘导航</aside>
      <main>{children}</main>
    </section>
  )
}

访问 /dashboard 时,childrenapp/dashboard/page.tsx 的结果;访问 /dashboard/settings 时,childrenapp/dashboard/settings/page.tsx 的结果。


2. Layout 为什么可以“保持状态”

在客户端软导航中,如果新旧页面共享同一个布局,Next.js 会尽可能复用这个布局,而不是把整棵 React 子树全部卸载再挂载。

例如:

/dashboard
/dashboard/settings

两者都共享:

app/layout.tsx
app/dashboard/layout.tsx

导航过程可以抽象为:

旧树:
RootLayout
└── DashboardLayout
    └── DashboardPage

新树:
RootLayout
└── DashboardLayout
    └── SettingsPage

共享的 RootLayoutDashboardLayout 可以继续存在,变化集中在页面 Segment。

因此,放在 DashboardLayout 中的客户端状态,例如侧边栏展开状态,通常不会因为从 /dashboard 导航到 /dashboard/settings 而重置。

但这不是“所有情况下都永远不重新挂载”的保证。以下情况可能导致不同结果:

  • 页面发生完整刷新;
  • 路由树不再共享同一布局;
  • 布局自身的 key 发生变化;
  • 代码或运行时环境触发了重新加载;
  • 使用了会改变组件身份的结构。

布局的持久化是路由树复用带来的结果,不应被理解为全局状态管理机制。


3. Layout 与 Page 的服务端边界

App Router 中,页面和布局默认是 Server Components。它们可以直接执行异步服务端逻辑,例如读取数据库或调用内部服务,但不能直接使用浏览器 API、React 客户端状态或事件处理器。

// 默认是 Server Component
export default async function DashboardPage() {
  const user = await getCurrentUser()

  return <h1>欢迎,{user.name}</h1>
}

需要浏览器交互时,必须在组件文件顶部声明:

'use client'

import { useState } from 'react'

export default function Counter() {
  const [count, setCount] = useState(0)

  return (
    <button onClick={() => setCount((value) => value + 1)}>
      {count}
    </button>
  )
}

'use client' 不是“让整个应用变成客户端渲染”,它标记的是一个 Client Component 入口。该入口导入的客户端组件会进入客户端模块图;Server Component 仍然可以继续存在于更高层的路由和布局中。

典型结构是:

Server Layout
└── Server Page
    └── Client Toolbar

服务端组件可以向客户端组件传递可序列化数据,但不能把任意服务器对象、数据库连接或函数直接作为普通属性传给客户端。


三、导航时到底发生了什么

Next.js 的 <Link> 和客户端路由 API 通常执行软导航。软导航不是简单地修改地址栏,而是让客户端请求目标路由所需的 React Server Component 结果,并根据新的路由树更新页面。

import Link from 'next/link'

export function Navigation() {
  return (
    <nav>
      <Link href="/dashboard">仪表盘</Link>
      <Link href="/dashboard/settings">设置</Link>
    </nav>
  )
}

一个简化的导航过程如下:

sequenceDiagram
    participant U as 用户
    participant B as 浏览器
    participant N as Next.js 服务端
    participant R as React 客户端运行时

    U->>B: 点击 Link
    B->>N: 请求目标路由的 RSC 数据
    N->>N: 匹配 Segment、布局和页面
    N-->>B: 返回 RSC Payload / 必要资源
    B->>R: 合并新的路由树
    R->>R: 复用共享布局,替换变化的 Segment
    R-->>U: 显示新页面

这里有两个容易混淆的点:

  1. 并行路由不等于 HTTP 请求一定并行。
    它首先是 UI 路由树的并行插槽模型。服务端是否并行获取数据,还取决于组件中的异步代码、缓存策略和数据源。

  2. 布局复用不等于数据永远不重新读取。
    一个共享布局可能被复用,但其服务端数据是否重新请求,受导航类型、缓存、重新验证和框架版本行为影响。不要仅依赖“布局不会卸载”来推断所有数据都不会刷新。


四、并行路由:在一个布局中挂载多个独立插槽

1. 并行路由的定义

并行路由使用 @slot 目录定义命名插槽。

app/
├── layout.tsx
├── page.tsx
├── @team/
│   └── page.tsx
└── @activity/
    └── page.tsx

父布局可以接收:

// app/layout.tsx
import type { ReactNode } from 'react'

export default function RootLayout({
  children,
  team,
  activity,
}: {
  children: ReactNode
  team: ReactNode
  activity: ReactNode
}) {
  return (
    <html lang="zh-CN">
      <body>
        {children}

        <div className="grid">
          <section>{team}</section>
          <section>{activity}</section>
        </div>
      </body>
    </html>
  )
}

这里:

  • children 是默认插槽;
  • team 来自 app/@team/...
  • activity 来自 app/@activity/...
  • teamactivity 不会出现在 URL 中。

可以把这个布局抽象成:

RootLayout(
  children = 主页面,
  team = 团队面板,
  activity = 活动面板
)

这与普通嵌套路由不同。普通嵌套路由通常在同一个 children 区域中替换页面;并行路由允许多个命名区域各自参与路由匹配和渲染。


2. 并行路由的匹配模型

考虑如下目录:

app/
├── layout.tsx
├── page.tsx
├── @team/
│   ├── page.tsx
│   └── settings/
│       └── page.tsx
└── @activity/
    ├── page.tsx
    └── history/
        └── page.tsx

访问:

/settings

并不意味着 URL 中存在 team。Next.js 会在同一层级下,为不同插槽寻找对应的分支:

RootLayout
├── children:根页面分支
├── team:team 插槽在当前路由状态下的内容
└── activity:activity 插槽在当前路由状态下的内容

插槽本身不是 URL Segment,但它们各自可以拥有页面、布局、加载状态和错误边界。

例如:

app/@team/loading.tsx
app/@team/error.tsx
app/@activity/loading.tsx
app/@activity/error.tsx

这样,团队面板和活动面板可以拥有独立的加载和错误 UI,而不是让一个面板的故障覆盖所有区域。


3. default.tsx 与硬刷新

并行路由必须考虑一个特殊情况:浏览器完整刷新时,客户端此前保存的插槽状态不存在。

例如用户在某个客户端导航过程中使 @team 插槽显示了一个特定分支,但用户直接刷新页面时,服务端只根据当前 URL 重新建立路由树。某个插槽如果找不到与当前 URL 对应的页面,就可能无法恢复原来的内容。

可以提供:

// app/@team/default.tsx
export default function DefaultTeamSlot() {
  return null
}

default.tsx 是并行插槽在无法恢复匹配状态时的后备 UI。它不是普通的 URL 页面,也不替代 page.tsx

一个合理的插槽结构可能是:

app/
├── layout.tsx
├── page.tsx
├── @team/
│   ├── default.tsx
│   ├── page.tsx
│   └── settings/
│       └── page.tsx
└── @activity/
    ├── default.tsx
    ├── page.tsx
    └── history/
        └── page.tsx

如果没有 default.tsx,生产环境中的表现通常可能是刷新后出现 404 或插槽无法恢复。具体错误形式取决于路由树和当前版本,但根因相同:软导航期间存在的插槽状态不能仅靠浏览器 URL 完整表达。


4. 并行路由的独立故障路径

假设 @activity/page.tsx 查询活动日志失败,而 @team/page.tsx 正常:

flowchart TD
    L[Root Layout]
    L --> C[children 主页面]
    L --> T[@team 插槽]
    L --> A[@activity 插槽]
    A --> AE[activity error.tsx]
    T --> TO[正常团队面板]

如果 @activity/error.tsx 位于正确的插槽边界内,理想结果是:

  • 主页面正常显示;
  • 团队面板正常显示;
  • 活动面板显示错误恢复 UI。

这比在根布局中用一个全局 try/catch 包住所有内容更精细,因为错误边界的位置决定了故障影响范围。


五、拦截路由:软导航时改变目标页面的呈现方式

1. 拦截路由解决什么问题

假设应用有文章列表和文章详情:

/posts
/posts/123

直接访问 /posts/123 时,通常希望显示完整详情页。但从 /posts 点击某篇文章时,产品可能希望:

  • 背景仍然保留文章列表;
  • 详情显示为模态框;
  • 地址栏变为 /posts/123
  • 刷新 /posts/123 后显示完整详情页,而不是模态框。

这正是拦截路由的使用场景。

拦截路由允许一个路由在客户端软导航时被另一个路由树中的页面“拦截”,从而在当前布局上下文中显示不同的 UI。它不会改变目标 URL,也不会让直接访问目标 URL 时永远显示拦截后的版本。


2. 拦截目录的命名约定

Next.js 使用特殊目录名表示相对拦截关系:

目录 含义
(.)segment 拦截当前层级的 Segment
(..)segment 拦截上一级 Segment
(..)(..)segment 拦截上两级 Segment
(...)segment 从根路由开始匹配

这里的“层级”指路由 Segment 层级,而不是简单地数所有物理目录。路由组和并行插槽不会按普通 URL Segment 出现在地址栏中,因此设计拦截路径时应以最终路由树为准,并通过实际导航验证。


3. 一个完整的模态框示例

目录结构:

app/
├── layout.tsx
├── page.tsx
├── @modal/
│   ├── default.tsx
│   └── (.)photos/
│       └── [id]/
│           └── page.tsx
└── photos/
    ├── page.tsx
    └── [id]/
        └── page.tsx

这里存在两份详情页面:

app/photos/[id]/page.tsx

用于直接访问或完整导航。

app/@modal/(.)photos/[id]/page.tsx

用于从当前路由软导航到 /photos/:id 时,将详情渲染到 modal 插槽。

根布局接收插槽:

// app/layout.tsx
import type { ReactNode } from 'react'

export default function RootLayout({
  children,
  modal,
}: {
  children: ReactNode
  modal: ReactNode
}) {
  return (
    <html lang="zh-CN">
      <body>
        {children}
        {modal}
      </body>
    </html>
  )
}

模态框插槽的默认状态:

// app/@modal/default.tsx
export default function DefaultModal() {
  return null
}

文章列表:

// app/photos/page.tsx
import Link from 'next/link'

const photos = [
  { id: '1', title: '山脉' },
  { id: '2', title: '海边' },
]

export default function PhotosPage() {
  return (
    <main>
      <h1>照片</h1>

      <ul>
        {photos.map((photo) => (
          <li key={photo.id}>
            <Link href={`/photos/${photo.id}`}>{photo.title}</Link>
          </li>
        ))}
      </ul>
    </main>
  )
}

完整详情页:

// app/photos/[id]/page.tsx
type PageProps = {
  params: Promise<{ id: string }>
}

export default async function PhotoPage({ params }: PageProps) {
  const { id } = await params

  return (
    <main>
      <h1>照片详情:{id}</h1>
      <p>这是完整页面,可以直接刷新访问。</p>
    </main>
  )
}

模态框详情页:

// app/@modal/(.)photos/[id]/page.tsx
import Modal from '@/components/Modal'

type PageProps = {
  params: Promise<{ id: string }>
}

export default async function PhotoModalPage({ params }: PageProps) {
  const { id } = await params

  return (
    <Modal>
      <h2>照片:{id}</h2>
      <p>这是通过软导航显示的模态框详情。</p>
    </Modal>
  )
}

关闭模态框需要使用客户端路由控制:

// components/Modal.tsx
'use client'

import { useRouter } from 'next/navigation'
import type { ReactNode } from 'react'

export default function Modal({ children }: { children: ReactNode }) {
  const router = useRouter()

  return (
    <div role="dialog" aria-modal="true">
      <button type="button" onClick={() => router.back()}>
        关闭
      </button>

      {children}
    </div>
  )
}

这个示例的状态变化是:

初始访问 /photos
  children = PhotosPage
  modal    = null

点击 /photos/1,执行软导航
  children = PhotosPage
  modal    = PhotoModalPage

直接刷新 /photos/1
  children = PhotoPage
  modal    = default.tsx 的 null

因此,拦截路由依赖两个条件:

  1. 用户必须通过客户端软导航到目标地址;
  2. 目标路由本身仍然必须存在,以支持直接访问和刷新。

如果用户从外部直接打开 /photos/1,浏览器没有此前的列表上下文,Next.js 会按正常详情页渲染,而不是强行构造模态框。


4. 拦截路由与浏览器历史

模态框关闭时调用:

router.back()

之所以通常有效,是因为打开模态框时地址已经从 /photos 变为 /photos/1,浏览器历史中存在这次导航。

但如果用户直接访问 /photos/1,再调用 router.back(),返回的可能是站外页面或历史中的其他地址。因此生产代码不能把 router.back() 视为无条件安全的“关闭模态框”操作,通常还需要:

  • 判断是否存在应用内的来源上下文;
  • 提供明确的返回链接;
  • 对直接进入详情页的情况采用 router.push('/photos') 或普通链接。

这属于浏览器历史语义,而不是拦截路由自动提供的保证。


5. 拦截路由的常见失败表现

失败一:始终显示完整页

可能原因:

  • 点击使用的是普通 <a>,触发了完整刷新;
  • 目标组件不在 @modal 插槽的正确拦截路径下;
  • 拦截目录中的相对层级写错;
  • 当前导航并没有从预期的来源路由开始。

诊断方式:

  1. 打开浏览器 Network 面板,确认是否发生完整文档请求;
  2. 确认链接使用 next/link
  3. 检查 app/@modal/default.tsx 是否存在;
  4. 对照实际 URL Segment 重新计算 (.)(..)
  5. 检查布局是否真的渲染了 modal 属性。

失败二:模态框出现,但刷新后 404

通常是因为:

  • 没有 app/photos/[id]/page.tsx 这个真实目标页面;
  • 只实现了 @modal 下的拦截页面,却没有实现可直接访问的目标路由。

拦截页面不是目标路由的替代品。它只是软导航时的另一种呈现。

失败三:关闭模态框后背景页面也消失

这通常意味着模态框并没有作为当前页面的并行插槽呈现,而是通过普通页面导航替换了 children。应确认:

<body>
  {children}
  {modal}
</body>

以及模态框页面确实位于:

app/@modal/...

六、错误边界:把路由故障限制在子树内

1. 错误边界的职责

React 错误边界用于捕获其子树渲染过程中的错误,并显示替代 UI。Next.js App Router 通过 error.tsx 文件约定自动建立路由级错误边界。

例如:

app/
├── layout.tsx
├── error.tsx
└── dashboard/
    ├── layout.tsx
    ├── error.tsx
    └── page.tsx

可以近似理解为:

app/layout.tsx
└── app/error.tsx
    └── dashboard/layout.tsx
        └── dashboard/error.tsx
            └── dashboard/page.tsx

更准确地说,某个 Segment 的 error.tsx 会包裹该 Segment 的页面、子布局和后代内容,但通常不能捕获同一 Segment 的父布局错误。


2. 一个可恢复的错误边界

// app/dashboard/error.tsx
'use client'

import { useEffect } from 'react'

export default function DashboardError({
  error,
  reset,
}: {
  error: Error & { digest?: string }
  reset: () => void
}) {
  useEffect(() => {
    console.error('Dashboard route error:', error)
  }, [error])

  return (
    <section role="alert">
      <h2>仪表盘暂时无法加载</h2>
      <p>请重试,或稍后返回。</p>
      <button type="button" onClick={() => reset()}>
        重试
      </button>
    </section>
  )
}

error.tsx 必须是 Client Component,因为它需要响应用户点击并调用 reset()

reset() 的作用不是重启整个应用,而是尝试重新渲染当前错误边界包裹的路由 Segment。它适合处理暂时性的故障,例如:

  • 后端短暂不可用;
  • 请求超时;
  • 数据在重试时已经恢复;
  • 服务端组件抛出了可重试错误。

如果错误来自确定性的程序缺陷,反复调用 reset() 不会真正修复问题,只会重复失败。


3. 错误边界的故障传播

假设:

app/
├── error.tsx
└── dashboard/
    ├── error.tsx
    └── reports/
        └── page.tsx

reports/page.tsx 抛出错误时,Next.js 会沿当前路由树向上寻找最近的错误边界:

reports/page.tsx
  ↓
dashboard/error.tsx
  ↓
app/error.tsx

首先使用 dashboard/error.tsx,因此不会自动让整个应用进入根级错误 UI。

如果 dashboard/error.tsx 自身也出错,或者错误发生在它无法包裹的父布局中,错误可能继续向更高层传播。


4. 根布局错误与 global-error.tsx

普通的 app/error.tsx 不能可靠捕获根 app/layout.tsx 自身的错误,因为错误边界需要位于被包裹内容的外侧,而根布局已经是路由树的最外层。

可以使用:

// app/global-error.tsx
'use client'

export default function GlobalError({
  reset,
}: {
  error: Error & { digest?: string }
  reset: () => void
}) {
  return (
    <html lang="zh-CN">
      <body>
        <h1>应用发生错误</h1>
        <button type="button" onClick={() => reset()}>
          重新加载
        </button>
      </body>
    </html>
  )
}

因为它可能替代根布局,global-error.tsx 需要提供完整的 <html><body> 结构。

根级错误通常意味着影响范围更大,例如:

  • 根布局读取全局配置失败;
  • 全局主题或认证初始化出错;
  • 根布局代码存在运行时异常。

5. 生产环境中的错误信息

在开发环境中,错误对象通常包含较多调试信息。生产环境为了避免泄露服务器内部细节,服务端抛出的错误不会原样发送给浏览器;客户端通常只能获得通用错误对象和可用于关联日志的 digest

因此,正确的记录方式是:

'use client'

import { useEffect } from 'react'

export function ErrorLogger({
  error,
}: {
  error: Error & { digest?: string }
}) {
  useEffect(() => {
    // 将 digest 与服务端日志系统中的请求记录关联
    console.error({
      message: error.message,
      digest: error.digest,
    })
  }, [error])

  return null
}

不要把数据库连接串、内部堆栈或未经处理的服务端异常直接渲染给用户。


七、notFound()not-found.tsx 与运行时错误不是一回事

“资源不存在”通常不是系统故障,而是预期的业务分支。Next.js 提供 notFound() 表达这一语义。

// app/posts/[id]/page.tsx
import { notFound } from 'next/navigation'

type PageProps = {
  params: Promise<{ id: string }>
}

export default async function PostPage({ params }: PageProps) {
  const { id } = await params
  const post = await getPost(id)

  if (!post) {
    notFound()
  }

  return <article>{post.title}</article>
}

对应 UI:

// app/posts/[id]/not-found.tsx
export default function PostNotFound() {
  return (
    <main>
      <h1>文章不存在</h1>
      <p>该文章可能已经删除,或链接已经失效。</p>
    </main>
  )
}

两者区别如下:

场景 机制
数据不存在 notFound() + not-found.tsx
数据读取失败、代码异常 error.tsx
等待异步内容 loading.tsx

notFound() 会中止当前页面继续渲染,并在对应路由边界内显示 Not Found UI。它不应被用来隐藏数据库连接失败、权限服务异常等真正的系统错误。


八、loading.tsx 与错误边界的生命周期

一个 Segment 可以同时拥有:

app/dashboard/
├── loading.tsx
├── error.tsx
├── not-found.tsx
├── layout.tsx
└── page.tsx

可以把一次请求或导航的状态简化为:

开始匹配
  │
  ├── 参数无效或资源不存在 ──> not-found.tsx
  │
  ├── 异步内容尚未完成 ──────> loading.tsx
  │                              │
  │                              ├── 成功 ──> page/layout UI
  │                              └── 异常 ──> error.tsx
  │
  └── 正常完成 ───────────────> page/layout UI

loading.tsx 通常会被 Next.js 作为该路由 Segment 的加载 UI 使用,并与 React Suspense 机制协作。它解决的是“结果还没准备好”,不是“结果失败”。

一个简单的加载组件:

// app/dashboard/loading.tsx
export default function DashboardLoading() {
  return <p aria-live="polite">正在加载仪表盘……</p>
}

如果加载 UI 粒度不够细,可以在组件内部显式使用 Suspense:

import { Suspense } from 'react'
import ActivityList from './ActivityList'

export default function DashboardPage() {
  return (
    <main>
      <h1>仪表盘</h1>

      <Suspense fallback={<p>活动记录加载中……</p>}>
        <ActivityList />
      </Suspense>
    </main>
  )
}

此时页面的标题可以先显示,活动列表稍后流式到达。错误边界仍然负责处理 ActivityList 及其子树中的渲染错误。


九、并行路由与错误边界组合起来是什么效果

考虑后台系统:

app/
├── layout.tsx
├── @main/
│   ├── error.tsx
│   └── page.tsx
├── @notifications/
│   ├── error.tsx
│   └── page.tsx
└── @help/
    ├── error.tsx
    └── page.tsx

布局:

// app/layout.tsx
import type { ReactNode } from 'react'

export default function Layout({
  main,
  notifications,
  help,
}: {
  main: ReactNode
  notifications: ReactNode
  help: ReactNode
}) {
  return (
    <div className="shell">
      <main>{main}</main>
      <aside>{notifications}</aside>
      <aside>{help}</aside>
    </div>
  )
}

三个插槽具有相对独立的状态:

main           = 成功
notifications  = loading -> 成功
help           = error

页面可以呈现为:

主内容:正常
通知栏:正常
帮助栏:错误并可重试

这体现了两个机制的配合:

  • 并行路由决定“哪些 UI 区域同时存在”;
  • 错误边界决定“某个区域出错时影响多大”。

如果把所有异步内容都放在根页面中,再用一个根错误边界包住,则任意一个区域失败都可能让整个页面进入错误态,失去局部恢复能力。


十、路由层级、布局层级和数据层级不要混为一谈

一个常见误解是:目录层级越深,数据请求就一定越晚;或者父布局加载的数据会自动传递给所有页面。

实际上:

app/
└── dashboard/
    ├── layout.tsx
    └── page.tsx

表示组件嵌套关系:

DashboardLayout
└── DashboardPage

但数据依赖仍然由组件代码决定:

// layout.tsx
const user = await getUser()

// page.tsx
const report = await getReport()

布局中的 user 不会自动成为页面的变量。需要通过 props、上下文或重复读取共享缓存来传递。

Server Components 的一个常见模式是:

// app/dashboard/layout.tsx
import DashboardShell from './DashboardShell'

export default async function DashboardLayout({
  children,
}: {
  children: React.ReactNode
}) {
  const user = await getCurrentUser()

  return (
    <DashboardShell user={user}>
      {children}
    </DashboardShell>
  )
}

如果 DashboardShell 需要交互,可以让它成为 Client Component:

// app/dashboard/DashboardShell.tsx
'use client'

type Props = {
  user: {
    name: string
  }
  children: React.ReactNode
}

export default function DashboardShell({ user, children }: Props) {
  return (
    <section>
      <button type="button">切换侧边栏</button>
      <p>{user.name}</p>
      {children}
    </section>
  )
}

这里服务端负责获取用户,客户端负责交互。不要为了使用 useState,把整个根布局标记为 'use client',否则会扩大客户端模块边界并限制服务端能力。


十一、一个完整的路由树示例

下面的结构同时包含布局、并行路由、拦截路由和错误边界:

app/
├── layout.tsx
├── global-error.tsx
├── error.tsx
├── loading.tsx
├── page.tsx
├── dashboard/
│   ├── layout.tsx
│   ├── error.tsx
│   ├── loading.tsx
│   ├── page.tsx
│   └── settings/
│       └── page.tsx
├── photos/
│   ├── page.tsx
│   └── [id]/
│       ├── page.tsx
│       ├── loading.tsx
│       ├── error.tsx
│       └── not-found.tsx
├── @modal/
│   ├── default.tsx
│   └── (.)photos/
│       └── [id]/
│           └── page.tsx
└── @notifications/
    ├── default.tsx
    ├── page.tsx
    └── error.tsx

其核心关系可以表示为:

flowchart TD
    Root[app/layout.tsx]
    Root --> Main[children]
    Root --> Modal[@modal]
    Root --> Notifications[@notifications]

    Main --> Home[app/page.tsx]
    Main --> Dashboard[dashboard/layout.tsx]
    Dashboard --> DashboardPage[dashboard/page.tsx]
    Dashboard --> Settings[dashboard/settings/page.tsx]

    Main --> Photos[photos/page.tsx]
    Main --> PhotoDetail[photos/[id]/page.tsx]

    Modal --> ModalPhoto[@modal/(.)photos/[id]/page.tsx]
    Modal --> ModalDefault[@modal/default.tsx]

    Notifications --> NotificationPage[@notifications/page.tsx]
    Notifications --> NotificationError[@notifications/error.tsx]

/photos 点击 /photos/1 时:

children:仍然是照片列表
modal:显示拦截后的照片详情
notifications:保持自己的插槽状态

直接访问 /photos/1 时:

children:显示完整照片详情
modal:使用 default.tsx
notifications:按当前路由重新匹配

如果照片详情查询抛出异常:

app/photos/[id]/error.tsx

负责处理完整详情页的错误;而模态框版本是否由同一个边界处理,取决于它在路由树中的实际位置。拦截页面位于 @modal 插槽下,因此为了实现真正独立的模态框故障恢复,通常应在插槽内部设置对应的 error.tsx


十二、如何诊断路由结构问题

遇到 Next.js 路由异常时,先区分问题属于“匹配错误”“渲染错误”还是“导航方式错误”。

1. URL 返回 404

检查:

app/目标路径/page.tsx

是否存在,并确认:

  • 动态 Segment 名称是否拼写正确;
  • 捕获 Segment 是否使用了正确的括号形式;
  • 路由组是否造成了 URL 冲突;
  • 是否在页面中调用了 notFound()
  • 是否把 @slot 误认为了 URL 路径。

2. 页面显示了错误边界

检查:

  • 服务端数据请求是否抛错;
  • error.tsx 的位置是否覆盖了预期子树;
  • 错误是否发生在当前边界的父布局中;
  • error.tsx 是否包含 'use client'
  • reset() 是否只是重复触发确定性错误。

3. 模态框不出现

检查:

  • 是否使用 next/link 或客户端路由 API;
  • 是否从正确来源页面发起软导航;
  • @modal 是否被父布局渲染;
  • (.)(..) 的层级是否与实际路由树匹配;
  • 是否存在 default.tsx
  • 是否误把拦截页面放到了普通 photos/[id] 路径下。

4. 页面刷新后结构变化

这是拦截路由的预期边界。软导航依赖已有的客户端路由上下文;完整刷新只根据 URL 重新构造路由树。因此应分别测试:

从列表点击进入
直接输入 URL
浏览器刷新
后退和前进
新标签页打开

这五种路径的行为不一定相同,尤其是模态框和并行插槽。


十三、规范保证、实现行为和工程取舍

规范保证

以下属于 Next.js App Router 的路由约定:

  • app 目录文件系统参与路由匹配;
  • layout.tsx 表示布局;
  • page.tsx 表示可访问页面;
  • @slot 表示并行路由插槽;
  • (.)(..) 等表示拦截路由关系;
  • error.tsx 表示路由错误边界;
  • not-found.tsxnotFound() 用于 Not Found 分支;
  • default.tsx 为并行插槽提供默认内容。

常见实现行为

以下行为依赖具体 Next.js 版本、导航方式和缓存设置:

  • 软导航时共享布局通常被复用;
  • Server Component 结果可能通过 RSC Payload 流式传输;
  • loading.tsx 的显示时机受 Suspense 和请求阶段影响;
  • 数据是否重新请求受缓存与重新验证配置影响;
  • 浏览器前进后退时并行插槽可能恢复先前状态。

工程取舍

并行路由和拦截路由都增加了路由树的复杂度。它们适合解决明确的 UI 结构问题:

  • 多面板后台;
  • 可独立加载的仪表盘区域;
  • 列表与详情模态框;
  • 登录、抽屉、预览等上下文相关 UI。

如果页面只是简单的嵌套内容,普通 layout.tsxchildren 更容易维护。只有当多个区域需要独立导航状态、加载状态或错误恢复时,并行路由才真正体现价值。

最终可以用一条结构关系概括这些能力:

Segment 决定路由树层级
Layout 决定共享 UI 和持久化边界
Parallel Route 决定同层的多个 UI 插槽
Intercepting Route 决定软导航时的替代呈现
Error Boundary 决定故障传播和恢复范围

理解这五者的边界后,Next.js 的复杂路由不再是特殊目录名称的记忆题,而可以被还原为一棵带有插槽、状态和故障边界的组件树。


系列导航与关联阅读

官方资料

本文依据 React 与生态项目官方文档重新梳理;正文与示例由 WR BLOG 编写。