React 基础体系 · 第 65/70 篇。示例以 React 19、现代 TypeScript 和主流框架能力为基础;客户端与服务端边界会明确说明。
Next.js 路由与布局:Segment、并行路由、拦截和错误边界
Next.js App Router 的路由不是由一个集中式路由表决定的,而是由 app 目录中的文件系统树决定的。目录层级会形成 URL 层级,特殊目录名会改变路由行为,layout.tsx、page.tsx、error.tsx 等约定文件则分别参与页面组合、渲染和故障处理。
要准确理解 Next.js 路由,需要同时区分四件事:
- Segment:URL 路径树中的一个层级。
- Layout:包裹某个 Segment 及其后代的持久化 UI。
- Parallel Routes:在同一个布局中,同时挂载多个独立路由插槽。
- Intercepting Routes:在客户端软导航时,用当前页面上下文中的另一个 UI 覆盖目标路由。
- Error Boundary:将渲染错误限制在某个路由子树内,并提供恢复入口。
这些能力还依赖几个前置概念:动态 Segment、路由组、loading.tsx、not-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 |
dashboard、settings |
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 时,children 是 app/dashboard/page.tsx 的结果;访问 /dashboard/settings 时,children 是 app/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
共享的 RootLayout 和 DashboardLayout 可以继续存在,变化集中在页面 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: 显示新页面
这里有两个容易混淆的点:
-
并行路由不等于 HTTP 请求一定并行。
它首先是 UI 路由树的并行插槽模型。服务端是否并行获取数据,还取决于组件中的异步代码、缓存策略和数据源。 -
布局复用不等于数据永远不重新读取。
一个共享布局可能被复用,但其服务端数据是否重新请求,受导航类型、缓存、重新验证和框架版本行为影响。不要仅依赖“布局不会卸载”来推断所有数据都不会刷新。
四、并行路由:在一个布局中挂载多个独立插槽
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/...;team和activity不会出现在 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
因此,拦截路由依赖两个条件:
- 用户必须通过客户端软导航到目标地址;
- 目标路由本身仍然必须存在,以支持直接访问和刷新。
如果用户从外部直接打开 /photos/1,浏览器没有此前的列表上下文,Next.js 会按正常详情页渲染,而不是强行构造模态框。
4. 拦截路由与浏览器历史
模态框关闭时调用:
router.back()
之所以通常有效,是因为打开模态框时地址已经从 /photos 变为 /photos/1,浏览器历史中存在这次导航。
但如果用户直接访问 /photos/1,再调用 router.back(),返回的可能是站外页面或历史中的其他地址。因此生产代码不能把 router.back() 视为无条件安全的“关闭模态框”操作,通常还需要:
- 判断是否存在应用内的来源上下文;
- 提供明确的返回链接;
- 对直接进入详情页的情况采用
router.push('/photos')或普通链接。
这属于浏览器历史语义,而不是拦截路由自动提供的保证。
5. 拦截路由的常见失败表现
失败一:始终显示完整页
可能原因:
- 点击使用的是普通
<a>,触发了完整刷新; - 目标组件不在
@modal插槽的正确拦截路径下; - 拦截目录中的相对层级写错;
- 当前导航并没有从预期的来源路由开始。
诊断方式:
- 打开浏览器 Network 面板,确认是否发生完整文档请求;
- 确认链接使用
next/link; - 检查
app/@modal/default.tsx是否存在; - 对照实际 URL Segment 重新计算
(.)或(..); - 检查布局是否真的渲染了
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.tsx与notFound()用于 Not Found 分支;default.tsx为并行插槽提供默认内容。
常见实现行为
以下行为依赖具体 Next.js 版本、导航方式和缓存设置:
- 软导航时共享布局通常被复用;
- Server Component 结果可能通过 RSC Payload 流式传输;
loading.tsx的显示时机受 Suspense 和请求阶段影响;- 数据是否重新请求受缓存与重新验证配置影响;
- 浏览器前进后退时并行插槽可能恢复先前状态。
工程取舍
并行路由和拦截路由都增加了路由树的复杂度。它们适合解决明确的 UI 结构问题:
- 多面板后台;
- 可独立加载的仪表盘区域;
- 列表与详情模态框;
- 登录、抽屉、预览等上下文相关 UI。
如果页面只是简单的嵌套内容,普通 layout.tsx 和 children 更容易维护。只有当多个区域需要独立导航状态、加载状态或错误恢复时,并行路由才真正体现价值。
最终可以用一条结构关系概括这些能力:
Segment 决定路由树层级
Layout 决定共享 UI 和持久化边界
Parallel Route 决定同层的多个 UI 插槽
Intercepting Route 决定软导航时的替代呈现
Error Boundary 决定故障传播和恢复范围
理解这五者的边界后,Next.js 的复杂路由不再是特殊目录名称的记忆题,而可以被还原为一棵带有插槽、状态和故障边界的组件树。
系列导航与关联阅读
- 系列入口:React 完整学习路线:从渲染与 Hooks 到服务端组件和生产架构
- 上一篇:React 静态交付:CDN、缓存、路由回退、压缩和回滚
- 下一篇:Next.js 数据与缓存:请求记忆、Data Cache、Revalidation 和标签
官方资料
本文依据 React 与生态项目官方文档重新梳理;正文与示例由 WR BLOG 编写。

评论
0 条讨论