Skip to content
工程与部署
Next.js 进阶能力:流式渲染、并行路由与拦截路由
Next.js 进阶能力:流式渲染、并行路由与拦截路由 的概念、用法、示例和注意点
2024/01/2110 分钟工程与部署
Next.js 进阶能力:流式渲染、并行路由与拦截路由
概述
Next.js App Router 在路由层提供了三种相互配合的能力:流式渲染(Streaming)、并行路由(Parallel Routes)和拦截路由(Intercepting Routes)。流式渲染让页面内容可以分块从服务器渐进地发送到客户端,配合 React Suspense 控制每个分块的加载态。并行路由允许同一个布局中同时渲染多个独立的路由区域,各自拥有独立的加载和错误状态。拦截路由则能在不离开当前上下文的情况下,以覆盖层(如模态框)的形式展示另一个路由的内容,同时保留该路由的独立 URL。这三者组合在一起,可以在保持代码结构清晰的同时,为仪表盘等复杂页面赋予渐进式加载体验与上下文相关的导航交互。
流式渲染:从 loading.tsx 到组件级 Suspense
Next.js 在 App Router 中默认支持 streaming,即服务端渲染的 HTML 不会一次性完整发送,而是分块(chunk)流式传输。当组件还在等待数据时,可以先发送一个回退 UI;数据就绪后,再发送真正的 HTML 替换掉原先的占位内容。这个行为由 React Suspense 驱动,Next.js 提供了两级控制:路由段级别的 loading.tsx 文件约定,以及组件级别的 <Suspense> 边界。
loading.tsx 与整段加载
在任意路由段目录(也就是 page.tsx 所在目录)中创建一个 loading.tsx 文件,并默认导出一个 React 组件。Next.js 会在导航开始时立刻显示该组件,并在该段页面内容流式到达后自动将其替换。
例如,app/dashboard/loading.tsx:
tsx
export default function Loading() {
return <div>Loading dashboard…</div>;
}当用户访问 /dashboard 或在该目录内部导航时,Next.js 会先返回这段 Loading 组件的 HTML,等 page.tsx 及其子组件的数据全部就绪后再发送最终的页面内容。整个过程不需要手动编写 <Suspense>,因为 loading.tsx 会被自动作为当前布局的嵌套子元素,将同层的 page.tsx 以及更深的子路由一并包裹在一个 Suspense 边界内。
这意味着一个 loading.tsx 对应该路由段以及它以下所有后代的加载状态,是一种“整段”级别的流式控制。对于需要更快展现首屏骨架的场景,这种方案可以迅速向用户给出反馈,而不必等待所有数据都准备好。
但如果页面不同区域的数据准备耗时差异很大,用整段加载就不够灵活——一个缓慢的组件会拖慢整个段的显示。此时就需要组件级的 Suspense。
<Suspense> 与组件级流式传输
在页面组件(通常是一个服务端组件)内部,可以直接使用 React 的 <Suspense> 包裹数据依赖较重的子组件。服务端组件本身可以是 async 函数,在内部 await 数据。当执行到被 <Suspense> 包裹的异步子组件时,Next.js 会先发送 fallback,然后等到该子组件的数据就绪后,再把它的 HTML 流式发送到客户端。
tsx
import { Suspense } from 'react';
import { PostFeed, Weather } from './Components';
export default async function Posts() {
return (
<section>
<Suspense fallback={<p>Loading feed…</p>}>
<PostFeed />
</Suspense>
<Suspense fallback={<p>Loading weather…</p>}>
<Weather />
</Suspense>
</section>
);
}PostFeed 和 Weather 各自独立等待数据。Posts 组件本身可以在服务端快速渲染完毕并流式输出,而两个子组件则按各自的数据就绪时刻分别注入页面。每个 <Suspense> 边界都会产生一个独立的流式分块。
loading.tsx 与组件级 <Suspense> 可以嵌套使用。例如,loading.tsx 提供一个页面骨架,内部再用 <Suspense> 控制各个面板的独立加载。两者的分块会按层级组合,浏览器收到的是多个依次到达的 HTML 片段,最终拼接成完整页面。
并行路由:用 @ 槽组织页面区域
一个布局中有时需要同时渲染多个互不干扰的区域,比如仪表盘页面上方是团队面板,下方是分析图表。这些区域各自可能需要独立的加载状态、错误边界,甚至独立的路由导航。并行路由允许在一个布局中定义多个“槽”(slot),每个槽可以有自己的路由树,URL 更改时各个槽会同时根据路径匹配自己的页面。
@ 槽的目录约定与 layout 接收 prop
在 app 目录下,用 @ 前缀创建文件夹作为槽。例如在 app/dashboard 下同时建立 @team 和 @analytics:
app/
dashboard/
layout.tsx
page.tsx
@team/
page.tsx
@analytics/
page.tsx父布局 app/dashboard/layout.tsx 会接收到 team、analytics 以及隐式的 children 作为 prop:
tsx
export default function Layout({
children,
team,
analytics,
}: {
children: React.ReactNode;
team: React.ReactNode;
analytics: React.ReactNode;
}) {
return (
<div>
<section>{team}</section>
<section>{analytics}</section>
<section>{children}</section>
</div>
);
}children 对应 app/dashboard/page.tsx(以及更深的子路由),其他槽则由对应的 @ 目录提供。这些槽文件夹 不会 参与 URL 路径的构建。URL /dashboard 访问时,@team/page.tsx 和 @analytics/page.tsx 会同时被渲染到各自的槽位中。如果有更深的路由,比如 /dashboard/settings,那么只要 @team/settings/page.tsx 存在,team 槽就会自动渲染该页面;如果不存在,Next.js 会查找该槽的 default.tsx 作为回退。
独立子导航、条件渲染与 default.tsx
每个槽都相当于一个独立的小型路由系统,可以有自己的 loading.tsx、error.tsx,也可以跟随 URL 变化而切换内部页面。需要明确的是,槽内的页面渲染由 URL 路径匹配决定,而不是依赖客户端状态或查询参数来切换内容。
例如,导航到 /dashboard/settings 时,@team 槽会根据自身目录结构寻找 app/@team/settings/page.tsx。如果没有,就会尝试 app/@team/settings/default.tsx,再没有则回到 app/@team/default.tsx。如果连 default.tsx 都没有,该槽将渲染 404,进而可能导致整个页面 404。
default.tsx 的典型用途是处理初始加载或硬刷新时,URL 与该槽的页面不完全匹配的场景。它类似于槽的 page.tsx 文件,只是当没有显式匹配项时才会使用。例如,@team/default.tsx 可以返回 null 或者一个占位提示。
利用槽的独立匹配特性,可以实现复杂的子导航:在同一个布局的不同区域分别渲染不同的子路由内容。例如,@team 槽可以设计为在 /dashboard 展示团队概览,在 /dashboard/members 展示成员列表;而 @analytics 槽在 /dashboard 展示图表,在 /dashboard/reports 展示报告。导航时,URL 同时变化,两个槽各自匹配自己的对应页面,互不干扰。
布局中还可以根据条件渲染某个槽,比如根据用户角色决定显示哪个面板:
tsx
const isAdmin = user.role === 'admin';
return (
<div>
{isAdmin ? team : analytics}
{children}
</div>
);这样做的好处是同一个 URL 可以按权限展示不同区域,而无需编写额外的客户端判断逻辑来切换组件。
拦截路由:上下文导航的层级匹配
常规路由导航会让整个页面重新渲染,上下文丢失。拦截路由提供了一种“在当前页面之上”加载另一个路由内容的机制,同时保持浏览器地址栏的 URL 更新。典型场景是从列表页点击某张图片,不跳转到详情页,而是在当前页弹出一个模态框显示图片大图,URL 却变为 /photo/123。如果用户直接访问这个 URL(硬刷新或分享链接),则展示真正的独立详情页。
匹配规则:(.)、(..)、(…) 与 (...)
拦截路由通过特殊的文件夹命名约定来声明对目标路由的“拦截”。约定基于相对路径的语义:
(.)folder— 匹配同层路由段(类似./folder)(..)folder— 匹配上一层路由段(类似../folder)(..)(..)folder— 匹配上两层(...)folder— 从app根目录开始匹配
例如,存在一个正常的照片详情页 app/photo/[id]/page.tsx。希望从 app/feed/page.tsx 列表页点击照片时,用模态框展示详情,可以创建一个拦截路由 app/(.)photo/[id]/page.tsx。这个路径中的 (.)photo 表示拦截同层 photo 路由。注意 (.) 出现在 app 目录下的具体位置决定了它相对于哪个布局进行拦截。
实际的拦截路由通常不会直接放在 app 根下,而是配合并行路由的模态框槽来使用。一个典型的目录结构如下:
app/
layout.tsx
page.tsx // 例如首页
@modal/
default.tsx // 返回 null
(.)photo/
[id]/
page.tsx // 拦截后的模态框内容
photo/
[id]/
page.tsx // 真实的照片详情页@modal 是一个并行路由槽。在根布局 app/layout.tsx 中渲染它:
tsx
export default function RootLayout({
children,
modal,
}: {
children: React.ReactNode;
modal: React.ReactNode;
}) {
return (
<html>
<body>
{children}
{modal}
</body>
</html>
);
}当用户在 /feed 页点击 <Link href="/photo/123"> 时,客户端导航发生,Next.js 发现匹配了 @modal/(.)photo/[id]/page.tsx,于是将它渲染到 modal 槽中。此时 URL 变成 /photo/123,但页面主体 children 部分仍然是 /feed 的内容,只是在上面覆盖了一个模态框。这就是拦截路由的作用——它只对客户端导航(<Link>、router.push)生效。如果用户直接在浏览器地址栏输入 /photo/123 或刷新页面,则该拦截路由被忽略,转而渲染 app/photo/[id]/page.tsx,即完整的照片详情页。这种设计保证了分享 URL 时的页面完整性。
@modal/default.tsx 在这里很关键。当访问首页 / 时,@modal 槽没有匹配任何拦截路由,会回退到 default.tsx。通常将其设为返回 null,这样模态框槽就不会渲染任何内容。如果忘记提供 default.tsx,未匹配时该槽会导致 404。
与并行路由组合的模态框模式
拦截路由和并行路由组合的模态框模式可以归纳为几个要点:
- 正常详情页:
app/photo/[id]/page.tsx作为独立页面,可被搜索引擎抓取,也支持直接访问。 - 拦截层:
app/@modal/(.)photo/[id]/page.tsx在客户端导航时生效,内容渲染在modal槽中,作为覆盖层显示。 - 槽的默认状态:
app/@modal/default.tsx返回null确保无拦截时槽不显示。 - 布局渲染:根布局同时接收
children和modal,在同一个 DOM 树中并排渲染,模态框通过 CSS 定位覆盖在内容上方。
这种模式下,列表页和详情页的切换不会丢失列表的滚动位置和状态,体验流畅。
综合示例:仪表盘中的流式面板与详情模态框
以下示例将流式渲染、并行路由和拦截路由组合到一起,构建一个仪表盘页面。仪表盘包含两个并行面板:分析面板(@analytics)和团队面板(@team),每个面板都可以独立流式加载;同时通过 @modal 槽拦截详情路由,在仪表盘内弹出详情模态框。
目录结构概览:
app/
layout.tsx
page.tsx
dashboard/
layout.tsx
page.tsx
@analytics/
page.tsx
loading.tsx
@team/
page.tsx
loading.tsx
@modal/
default.tsx
(..)details/
[id]/
page.tsx
details/
[id]/
page.tsx拦截路由文件夹 @modal/(..)details/[id] 使用了 (..),表示从当前布局的上一级(即 app 目录)匹配 details。因为 @modal 槽位于 dashboard 布局内,(.) 只能匹配同层的 dashboard/details,而这里需要拦截全局的 /details 路由。
app/dashboard/layout.tsx —— 接收三个槽与 modal:
tsx
export default function DashboardLayout({
children,
analytics,
team,
modal,
}: {
children: React.ReactNode;
analytics: React.ReactNode;
team: React.ReactNode;
modal: React.ReactNode;
}) {
return (
<div className="dashboard">
<div className="panels">
<section className="panel">{analytics}</section>
<section className="panel">{team}</section>
</div>
<div>{children}</div>
{modal}
</div>
);
}analytics 和 team 各自对应一个并行路由槽。children 是 /dashboard/page.tsx(可以放一些公用信息)。modal 槽用于显示拦截的详情模态框。
流式面板示例:app/dashboard/@analytics/page.tsx 内部使用 <Suspense> 包裹一个异步图表组件,同时提供 loading.tsx 作为整槽的加载回退。
tsx
// app/dashboard/@analytics/page.tsx
import { Suspense } from 'react';
import { Chart, Summary } from './components';
export default async function AnalyticsPage() {
return (
<div>
<h2>Analytics</h2>
<Suspense fallback={<p>Loading chart…</p>}>
<Chart />
</Suspense>
<Suspense fallback={<p>Loading summary…</p>}>
<Summary />
</Suspense>
</div>
);
}app/dashboard/@analytics/loading.tsx 可以在整槽首次加载时显示骨架屏:
tsx
export default function AnalyticsLoading() {
return <div>Loading analytics panel…</div>;
}@team 槽的实现类似。两个面板各自独立流式加载,互不阻塞。当一个面板数据较慢时,另一个面板可能已经完整显示。
详情模态框:在 app/details/[id]/page.tsx 放置正常的详情页面。在 app/dashboard/@modal/(..)details/[id]/page.tsx 实现拦截版本,渲染一个模态框组件:
tsx
// app/dashboard/@modal/(..)details/[id]/page.tsx
import { Modal } from '@/components/Modal';
import { getDetail } from '@/lib/data';
export default async function InterceptedDetail({
params: { id },
}: {
params: { id: string };
}) {
const detail = await getDetail(id);
return (
<Modal>
<h2>{detail.title}</h2>
<p>{detail.content}</p>
</Modal>
);
}Modal 是一个客户端组件,负责以覆盖层形式展示内容,并提供关闭按钮,关闭时通过 router.back() 返回。
在 app/dashboard/@modal/default.tsx 中返回 null:
tsx
export default function ModalDefault() {
return null;
}这样,当访问 /dashboard 而不触发任何详情拦截时,modal 槽不渲染任何内容。
从仪表盘面板内的链接点击 <Link href="/details/42"> 时,URL 变化为 /details/42,页面没有刷新,但 modal 槽渲染了拦截的详情页,覆盖在仪表盘之上。如果用户直接访问 /details/42 或者刷新页面,则会进入 app/details/[id]/page.tsx 渲染完整的详情页。模态框的拦截只在客户端导航中生效。
注意点与限制
- 拦截路由仅对客户端导航有效。
<Link>点击或router.push会触发拦截;浏览器的硬刷新、地址栏输入或从外部链接打开都会跳过拦截,渲染目标路由的真实页面。设计时需同时考虑两者的 UI 一致性。 - 并行路由槽的未匹配处理。如果某个槽的页面树中没有任何页面能匹配当前 URL,必须提供
default.tsx,否则该槽会导致 404,进而使整个页面无法渲染。在初始加载或硬刷新时尤其容易出现未匹配。 @槽不参与 URL。槽的名称仅影响布局的 prop 名,对 URL 路径无任何影响。URL 仍由常规的page.tsx和动态段决定。- 流式渲染与运行时的依赖。Next.js 在 Node.js 和 Edge Runtime 下都支持 streaming,但具体限制不同。例如 Edge Runtime 下有响应大小和流式分块数量的约束,细节参考官方文档。
- Suspense 边界的嵌套与顺序。多个
<Suspense>边界会各自生成独立的流式分块。服务器的发送顺序与 Suspense 在组件树中的位置以及数据就绪顺序有关,并不一定按照代码书写顺序。浏览器会按 HTML 位置正确插入片段。 - 拦截路由与静态生成。如果页面使用了静态生成(
generateStaticParams),拦截路由的页面也应在对应槽中静态生成,否则可能在客户端导航时不能正确拦截。 - 模态框的无障碍和焦点管理。组合模式生成的模态框是纯 UI 层面的,Next.js 不提供内置的焦点陷阱或键盘导航。这些需要自行实现或使用 UI 库。
参考链接
- [1] https://nextjs.org/docs/app/building-your-application/routing/loading-ui-and-streaming
- [2] https://nextjs.org/docs/app/api-reference/file-conventions/loading
- [6] https://react.dev/reference/react/Suspense
- [9] https://nextjs.org/docs/app/building-your-application/routing/parallel-routes
- [13] https://nextjs.org/docs/app/api-reference/file-conventions/default
- [15] https://nextjs.org/docs/app/building-your-application/routing/intercepting-routes
相关推荐
2024/01/23 · 6 分钟Next.js 实战应用:构建全栈博客与常见问题
Next.js 实战应用:构建全栈博客与常见问题 的概念、用法、示例和注意点
2024/01/19 · 5 分钟Next.js 关键 API:路由处理程序、中间件与 Server ActionsNext.js 关键 API:路由处理程序、中间件与 Server Actions 的概念、用法、示例和注意点
2025/03/09 · 7 分钟Nuxt.js 渲染模式与部署Nuxt.js 渲染模式与部署 的概念、用法、示例和注意点
2025/03/03 · 8 分钟Nuxt.js 核心组件与 APINuxt.js 核心组件与 API 的概念、用法、示例和注意点
