Next.js 高级路由实战:路由组、嵌套布局、平行路由、拦截路由完全指南

上周接手了一个运行两年的 Next.js 电商项目。打开 app 目录的瞬间,屏幕上密密麻麻挤着 60 多个文件夹——about、products、admin-users、marketing-campaign、shop-cart…全都平铺在一起。想找个用户相关的页面?得从一堆营销页面里翻。更崩溃的是,团队三个人同时改路由文件,Git 冲突每天至少两个,code review 的时候光理清文件关系就得半小时。
坐在电脑前盯着这堆文件夹,我突然想起来:Next.js 不是有路由组、平行路由这些高级特性吗?翻了翻官方文档,发现这些功能早在 Next.js 13 就有了,可项目里完全没用上。说白了,不是工具不够用,是压根不知道什么时候该用哪个。
那天晚上我花了三个小时研究这些路由特性,又用了两天重构项目结构。结果?目录结构一下子清爽了,团队群里再也没人喊”又冲突了”。最关键的是,我终于搞明白了这四个看起来有点绕的功能到底解决什么问题:路由组让目录井井有条、嵌套布局灵活复用结构、平行路由同时展示多个页面、拦截路由优雅实现模态框。
如果你的 Next.js 项目也越做越大,文件越来越乱,团队协作总是冲突——读完这篇文章,你会知道何时用哪种路由特性、怎么用、还有哪些坑要躲。
路由组 (Route Groups) - 让目录井井有条
什么是路由组?
先说结论:路由组就是用圆括号包起来的文件夹,比如 (marketing) 或 (shop)。神奇的地方在于——Next.js 完全不会把这个括号名加到 URL 里。
听起来没啥用?那是还没碰到真实场景。
假设你有个电商网站,既有营销页面(首页、关于我们),又有商城页面(商品列表、购物车),还有管理后台(订单管理、用户管理)。按传统做法,这些页面要么全塞在 app 根目录下挤成一团,要么强行在 URL 里加个 /marketing、/shop、/admin 前缀——但谁愿意让用户访问 yoursite.com/marketing/about 这种别扭的地址?
路由组完美解决这个矛盾:既能在文件系统里把页面分门别类,又不影响用户看到的 URL。
三个核心用途(都很实用)
用途1:按团队或功能划分目录
最直接的好处。把 60 个平铺的文件夹变成三个分组:
app/
├── (marketing)/ # 营销团队负责
│ ├── page.js # 首页 → yoursite.com/
│ ├── about/ # 关于 → yoursite.com/about
│ └── pricing/ # 定价 → yoursite.com/pricing
├── (shop)/ # 前端团队负责
│ ├── products/ # 商品 → yoursite.com/products
│ └── cart/ # 购物车 → yoursite.com/cart
└── (dashboard)/ # 后端团队负责
├── orders/ # 订单 → yoursite.com/orders
└── users/ # 用户 → yoursite.com/users看到了吗?URL 还是原来的干净路径,但文件结构一目了然。新来的实习生看一眼就知道哪个目录管什么。
用途2:不同区域用不同的根布局
这是路由组最强的地方。营销页面和后台管理的导航栏能一样吗?当然不行。但它们都是从根路径开始的 /about 和 /orders,怎么给它们套不同的布局?
答案是:每个路由组都能有自己的 layout.js。
app/
├── (marketing)/
│ ├── layout.js # 营销页专用布局:顶部导航 + 大图
│ └── ...
├── (shop)/
│ ├── layout.js # 商城专用布局:购物车图标 + 分类筛选
│ └── ...
└── (dashboard)/
├── layout.js # 后台专用布局:侧边栏 + 权限检查
└── ...三套布局,互不干扰。营销页可以放个炫酷的首页大图,后台可以塞个 sidebar,商城能固定显示购物车数量——全都基于同一个项目,不用搞什么子域名或者多个 Next.js 实例。
用途3:选择性共享布局
有时候你需要”某几个页面共享布局,其他页面不共享”。比如博客文章都需要左侧目录导航,但博客首页不需要。用路由组可以轻松实现:
app/
├── blog/
│ ├── page.js # 博客首页,没有左侧导航
│ └── (articles)/ # 文章组,共享左侧导航
│ ├── layout.js # 带左侧导航的布局
│ ├── [slug]/ # 文章详情 → /blog/xxx
│ └── ...注意 (articles) 这个路由组不会影响 URL——访问路径还是 /blog/my-first-post,但只有这个组里的页面才会应用带导航的 layout。
真实重构案例:从混乱到清爽
回到开头那个 60 个文件夹的电商项目。重构前后对比:
重构前(部分文件):
app/
├── page.js
├── about/
├── pricing/
├── products/
├── products-detail/
├── cart/
├── checkout/
├── admin-orders/
├── admin-users/
├── admin-settings/
├── marketing-campaign/
├── ...(还有50个)找个文件?ctrl+F 慢慢搜吧。想知道某个页面属于哪个模块?看文件名猜。
重构后:
app/
├── (marketing)/
│ ├── layout.js
│ ├── page.js
│ ├── about/
│ ├── pricing/
│ └── campaign/
├── (shop)/
│ ├── layout.js
│ ├── products/
│ ├── cart/
│ └── checkout/
└── (dashboard)/
├── layout.js
├── orders/
├── users/
└── settings/三层结构,清清楚楚。要改营销页?直接去 (marketing) 找。要加个后台功能?(dashboard) 里加文件。团队 code review 时,只看自己负责的路由组,冲突率直接降了 70%。
三个坑,必须躲开
坑1:URL 冲突会直接报错
路由组不影响 URL,那如果两个组里有同名路由呢?
app/
├── (marketing)/
│ └── about/page.js # → /about
└── (shop)/
└── about/page.js # → /about(冲突!)Next.js 会直接给你报错:Error: Conflicting route。解决办法很简单——要么改路径名,要么加个真实的目录层级(不用括号):
app/
├── (marketing)/
│ └── about/page.js # → /about
└── (shop)/
└── shop-info/page.js # → /shop-info(改名)坑2:多根布局会触发完整页面刷新
从 (shop) 导航到 (marketing) 时,你会发现页面”闪”了一下——不是 bug,是特性。
不同路由组的根布局完全独立,切换时 Next.js 必须卸载旧布局、挂载新布局,这需要完整刷新页面(full page load)。这是故意的设计,毕竟两个区域的布局可能完全不兼容。
如果你希望平滑过渡,那就别用多根布局——把共享的部分提到最外层的 app/layout.js,路由组里的 layout 只放差异部分。
坑3:多根布局时首页位置
如果你创建了多个路由组且每个都有自己的 layout.js,那首页 page.js 必须放在某一个组里,不能放在 app/page.js。不然 Next.js 不知道该用哪个根布局。
通常把首页放在 (marketing)/page.js,因为首页多数时候属于营销内容。
说了这么多,路由组的核心就一句话:用来组织文件,不影响 URL,还能给不同区域套不同布局。小项目用不上,但如果你的 app 目录已经塞了 20 个以上的文件夹,是时候试试路由组了。
嵌套布局 (Nested Layouts) - 灵活复用页面结构
嵌套布局解决什么问题?
你有没有遇到过这种情况:首页需要顶部导航,博客列表页需要顶部导航+左侧分类栏,文章详情页需要顶部导航+左侧分类栏+右侧目录导航。
用传统组件组合的话,你得在每个页面里手动拼装这堆组件。改个导航栏样式?三个地方都得改。
Next.js 的嵌套布局就是为了解决这个——让布局像俄罗斯套娃一样层层嵌套,外层布局自动应用到内层所有页面。关键是,每深入一层,就能在外层基础上增加新的 UI 元素,而不用重复写外层的东西。
嵌套布局怎么工作?
概念很简单:每个文件夹都能有自己的 layout.js,子文件夹会自动继承父文件夹的布局,然后在此基础上套一层自己的布局。
举个真实例子。假设你在做个在线教育平台:
app/
├── layout.js # 根布局:顶部导航 + Footer
└── courses/
├── layout.js # 课程布局:根布局 + 左侧课程分类
├── page.js # 课程列表页
└── [id]/
├── layout.js # 课程详情布局:课程布局 + 右侧进度条
└── page.js # 具体某个课程当用户访问 /courses/123 时,渲染顺序是这样的:
- 最外层:
app/layout.js包裹一切(顶部导航 + Footer) - 中间层:
courses/layout.js包在里面(左侧分类栏) - 最内层:
courses/[id]/layout.js再包一层(右侧进度条) - 页面内容:
courses/[id]/page.js在最里面
就像套娃,一层套一层。改顶部导航?只需要改 app/layout.js,所有页面自动生效。
实战案例:博客系统的三层布局
还是说人话更清楚。我之前做过一个技术博客,需求是这样的:
- 所有页面:顶部导航(首页、关于、联系)+ 页脚
- 博客相关页面:顶部导航 + 左侧文章分类筛选
- 文章详情页:顶部导航 + 左侧分类 + 右侧目录锚点
用嵌套布局实现起来特别舒服:
app/
├── layout.js # 第一层:全站通用布局
│ └── <Header /><Footer />
└── blog/
├── layout.js # 第二层:博客专属布局
│ └── <Sidebar />
├── page.js # 博客列表页(继承前两层)
└── [slug]/
├── layout.js # 第三层:文章专属布局
│ └── <TableOfContents />
└── page.js # 文章详情页(继承所有三层)代码长这样(简化版):
app/layout.js(第一层)
export default function RootLayout({ children }) {
return (
<html>
<body>
<Header />
{children} {/* 这里会渲染子布局或页面 */}
<Footer />
</body>
</html>
)
}app/blog/layout.js(第二层)
export default function BlogLayout({ children }) {
return (
<div className="blog-container">
<Sidebar />
<main>{children}</main> {/* 这里会渲染更内层的内容 */}
</div>
)
}app/blog/[slug]/layout.js(第三层)
export default function ArticleLayout({ children }) {
return (
<div className="article-container">
{children}
<TableOfContents /> {/* 右侧目录 */}
</div>
)
}注意到了吗?每层 layout 只关心自己要加的 UI,不用管外层的 Header 或 Sidebar——Next.js 会自动按顺序嵌套它们。
和路由组结合用
这才是真正强大的地方。路由组负责”横向隔离”(营销页、商城、后台),嵌套布局负责”纵向叠加”(一层层增加 UI)。
回到电商项目的例子,商城区域可以这样设计:
app/
└── (shop)/
├── layout.js # 商城根布局:购物车图标 + 顶部分类导航
├── products/
│ ├── layout.js # 商品列表布局:+ 左侧筛选栏
│ ├── page.js # 列表页
│ └── [id]/
│ ├── layout.js # 商品详情布局:+ 面包屑导航
│ └── page.js # 详情页
└── cart/
└── page.js # 购物车页(只继承商城根布局)- 商品列表页
/products:商城根布局 + 筛选栏 - 商品详情页
/products/123:商城根布局 + 筛选栏 + 面包屑(三层全有) - 购物车页
/cart:只有商城根布局(因为没有更深的 layout.js)
灵活吧?哪个页面需要哪些 UI,完全由目录层级决定,不用写一堆条件判断”如果是详情页就显示面包屑”这种逻辑。
两个细节要注意
细节1:layout 不会重新渲染(性能好)
当你从 /blog 导航到 /blog/my-post 时,app/layout.js 和 app/blog/layout.js 不会重新渲染——它们的状态、滚动位置都保持不变,只有最内层的 page.js 会重新加载。
这意味着你可以在 layout 里放个侧边栏滚动状态、搜索框输入内容,用户在内层页面之间跳转时这些都不会丢失。超级流畅。
细节2:layout 不能访问子路由的路径参数
假设你有个动态路由 app/products/[id]/page.js,这个 [id] 参数只能在 page.js 里通过 params 拿到,layout.js 拿不到。
如果 layout 需要根据 [id] 渲染不同内容(比如显示商品标题),你得通过其他方式传递,比如用 Context 或者把数据提升到更上层。
嵌套布局的核心就是让代码组织符合 UI 的视觉层级——页面上从外到内有几层结构,文件夹就嵌套几层 layout。不用到处复制粘贴组件,改一个 layout 文件就能影响一整个区域的所有页面。
平行路由 (Parallel Routes) - 同时展示多个页面
什么场景需要平行路由?
先说个真实场景。你要做个管理后台的仪表盘(Dashboard),需要同时显示三个模块:
- 左上角:销售统计图表
- 右上角:最新订单列表
- 下方:库存告警
这三个模块的数据互不相关,加载速度也不一样。统计图表可能要算半天,订单列表能秒出,库存数据需要从另一个 API 拉。
按传统做法,你得在 dashboard/page.js 里一次性请求三个接口、渲染三个组件。任何一个模块出错,整个页面都挂掉。想给某个模块加个独立的加载状态?得自己写一堆 loading state。
平行路由就是为了解决这个——它让你在同一个页面里同时渲染多个独立的”页面片段”(官方叫”插槽” slots),每个插槽可以有自己的加载状态、错误处理、甚至独立导航。
平行路由的语法:@ 符号定义插槽
核心语法就一个:用 @folder 命名文件夹,这个文件夹就变成了一个”插槽”。
还是仪表盘的例子:
app/
└── dashboard/
├── layout.js # 接收三个插槽作为 props
├── @sales/ # 插槽1:销售统计
│ └── page.js
├── @orders/ # 插槽2:订单列表
│ └── page.js
├── @inventory/ # 插槽3:库存告警
│ └── page.js
└── page.js # 主内容(可选)注意那三个文件夹名:@sales、@orders、@inventory——以 @ 开头的文件夹就是平行路由的插槽。
然后在 layout.js 里,Next.js 会把这些插槽作为 props 传进来:
export default function DashboardLayout({
children, // 对应 page.js 的内容
sales, // 对应 @sales/page.js
orders, // 对应 @orders/page.js
inventory // 对应 @inventory/page.js
}) {
return (
<div className="dashboard">
<div className="widgets">
<div className="widget">{sales}</div>
<div className="widget">{orders}</div>
</div>
<div className="main">{children}</div>
<div className="alerts">{inventory}</div>
</div>
)
}看到了吗?三个插槽就像三个独立的”子页面”,你可以随意安排它们的布局位置。不用把所有逻辑塞进一个大 page.js,每个模块有自己的文件,清爽。
实战案例:管理后台仪表盘
具体代码长这样。先看三个插槽的内容:
@sales/page.js(销售统计)
async function getSalesData() {
const res = await fetch('https://api.example.com/sales')
return res.json()
}
export default async function SalesWidget() {
const data = await getSalesData() // 这里可能很慢
return (
<div>
<h3>本月销售</h3>
<Chart data={data} />
</div>
)
}@orders/page.js(订单列表)
async function getRecentOrders() {
const res = await fetch('https://api.example.com/orders')
return res.json()
}
export default async function OrdersWidget() {
const orders = await getRecentOrders() // 这个可能很快
return (
<div>
<h3>最新订单</h3>
<ul>
{orders.map(order => <li key={order.id}>{order.title}</li>)}
</ul>
</div>
)
}@inventory/page.js(库存告警)
export default function InventoryWidget() {
// 也可以是客户端组件,用 useEffect 拉数据
return (
<div>
<h3>库存告警</h3>
<p>5 件商品库存不足</p>
</div>
)
}神奇的事情来了:这三个插槽会并行加载。订单列表 API 快,先渲染出来;销售统计慢,等数据到了再渲染。某个插槽报错了?只有那个插槽挂掉,其他两个照常显示。
这比传统方式优雅太多——不用自己管理三个 loading state、不用等最慢的接口、每个模块天然隔离。
独立的 loading 和 error 状态
更爽的是,每个插槽可以有自己的 loading.js 和 error.js:
app/
└── dashboard/
├── @sales/
│ ├── page.js
│ ├── loading.js # 销售模块的加载状态
│ └── error.js # 销售模块的错误处理
├── @orders/
│ ├── page.js
│ └── loading.js # 订单模块的加载状态
└── @inventory/
└── page.js@sales/loading.js:
export default function SalesLoading() {
return <div>加载销售数据中...</div>
}@sales/error.js:
'use client'
export default function SalesError({ error, reset }) {
return (
<div>
<p>销售数据加载失败</p>
<button onClick={reset}>重试</button>
</div>
)
}现在的效果是:页面打开时,销售模块显示”加载中…“,订单模块可能已经渲染完了,库存模块立即显示。如果销售 API 挂了,只有那个模块显示”加载失败”,其他模块完全不受影响。
用户体验瞬间上了一个台阶——不用盯着白屏等所有数据,能看的先看着。
关键点:default.js 的作用
这里有个细节。假设你在 /dashboard 页面,然后点了个链接跳到 /dashboard/settings。问题来了:@sales、@orders 这些插槽的路由不匹配 /dashboard/settings,Next.js 该渲染啥?
默认行为是保持插槽原有内容不变(有点像 SPA 的局部更新)。但有时候你希望切路由时插槽消失,这时候就需要 default.js:
app/
└── dashboard/
├── @sales/
│ ├── page.js
│ └── default.js # 不匹配时返回 null
└── ...@sales/default.js:
export default function SalesDefault() {
return null // 不匹配时不显示任何内容
}这样一来,当路由切到 /dashboard/settings 时,@sales 插槽就会渲染 default.js 的内容(也就是什么都不显示)。
平行路由用得最多的场景其实是配合拦截路由实现模态框——这个咱们下一章详细讲。单纯用平行路由做仪表盘也挺香,尤其是那种”多个独立模块拼在一起”的页面。
拦截路由 (Intercepting Routes) - 模态框的优雅实现
Instagram 的图片查看体验
你肯定用过 Instagram 或者小红书吧?在 Feed 流里点一张图片,图片会以模态框形式弹出,浏览器地址栏变成 /photo/abc123。神奇的是:
- 点浏览器后退键,模态框关闭,回到 Feed 流(而不是跳到上一个完全不同的页面)
- 刷新页面,模态框消失,直接显示完整的图片详情页
- 把 URL 分享给别人,别人打开的是完整页面,不是模态框
这种体验怎么实现?传统做法要写一堆状态管理、URL 解析、history 操作…头疼。
拦截路由(Intercepting Routes)专门为这个场景设计——它能拦截客户端导航,在当前页面用模态框展示目标路由的内容;但直接访问 URL 或刷新页面时,渲染完整页面。
拦截路由的语法:(..) 符号
核心语法是用括号加点表示”拦截哪一级的路由”:
(.)— 拦截同级路由(..)— 拦截上一级路由(..)(..)— 拦截上两级路由(...)— 拦截从根目录开始的路由
听起来抽象?看个例子就懂了。
假设你有个图片列表页 /photos,点击某张图片跳到 /photos/123。你希望:
- 点击跳转时:在列表页上弹出模态框
- 直接访问或刷新时:显示完整的图片详情页
目录结构是这样的:
app/
├── @modal/
│ ├── (.)photos/ # 拦截同级的 photos 路由
│ │ └── [id]/
│ │ └── page.js # 模态框内容
│ └── default.js # 不匹配时返回 null
├── layout.js # 接收 modal 插槽
├── page.js # 首页 Feed 流
└── photos/
└── [id]/
└── page.js # 完整的图片详情页看到 @modal/(.)photos/ 了吗?这意思是:“拦截 与 @modal 同级的 photos 路由”。因为 @modal 和 photos 都在 app/ 下,所以用 (.)。
实战案例:Instagram 风格的图片模态框
咱们完整实现一遍。需求很明确:
- 首页显示图片网格
- 点击图片,弹出模态框,URL 变为
/photos/123 - 刷新或直接访问
/photos/123,显示完整页面 - 按后退键关闭模态框
第一步:目录结构
app/
├── @modal/
│ ├── (.)photos/
│ │ └── [id]/
│ │ └── page.js # 模态框组件
│ └── default.js
├── layout.js
├── page.js # 首页图片网格
└── photos/
└── [id]/
└── page.js # 完整图片页第二步:根布局接收 modal 插槽
app/layout.js:
export default function RootLayout({ children, modal }) {
return (
<html>
<body>
{children} {/* 主内容区 */}
{modal} {/* 模态框插槽 */}
</body>
</html>
)
}第三步:首页显示图片网格
app/page.js:
import Link from 'next/link'
const photos = [
{ id: '1', url: '/images/photo1.jpg' },
{ id: '2', url: '/images/photo2.jpg' },
// ...
]
export default function HomePage() {
return (
<div className="photo-grid">
{photos.map(photo => (
<Link key={photo.id} href={`/photos/${photo.id}`}>
<img src={photo.url} alt="" />
</Link>
))}
</div>
)
}第四步:拦截路由 - 模态框组件
app/@modal/(.)photos/[id]/page.js:
'use client'
import { useRouter } from 'next/navigation'
import Image from 'next/image'
export default function PhotoModal({ params }) {
const router = useRouter()
return (
<div className="modal-backdrop" onClick={() => router.back()}>
<div className="modal-content" onClick={e => e.stopPropagation()}>
<button onClick={() => router.back()}>关闭</button>
<Image src={`/images/photo${params.id}.jpg`} fill />
</div>
</div>
)
}注意这里用了 router.back() 关闭模态框——因为是客户端导航过来的,后退就能回到 Feed 流。
第五步:完整页面
app/photos/[id]/page.js:
import Image from 'next/image'
export default function PhotoPage({ params }) {
return (
<div className="photo-page">
<nav>返回首页</nav>
<h1>图片详情</h1>
<Image src={`/images/photo${params.id}.jpg`} width={800} height={600} />
<p>图片描述...</p>
</div>
)
}第六步:default.js 确保模态框能关闭
app/@modal/default.js:
export default function Default() {
return null // 不匹配路由时,模态框不显示
}魔法时刻:体验效果
现在神奇的事情发生了:
在首页点击图片:
- Link 组件触发客户端导航到
/photos/1 - Next.js 检测到
@modal/(.)photos/[id]匹配,拦截这次导航 - 渲染模态框组件,叠加在 Feed 流上方
- URL 变成
/photos/1,但页面没有完全刷新
- Link 组件触发客户端导航到
按后退键:
router.back()回到上一个路由(首页/)@modal插槽不再匹配,渲染default.js(null)- 模态框消失,Feed 流保持原样
刷新页面或直接访问
/photos/1:- 不是客户端导航,Next.js 不拦截
- 直接渲染
app/photos/[id]/page.js的完整页面 - 没有模态框,用户看到的是一个独立的图片详情页
分享链接:
- 别人打开
/photos/1,看到的是完整页面 - 体验和直接访问一样
- 别人打开
完美!URL 可分享、后退关闭模态、刷新显示完整页面——三个需求一个不落。
拦截层级怎么选?
前面提到 (.)、(..)、(...) 这些语法,到底该用哪个?关键看拦截位置和目标路由的相对位置。
假设你的拦截路由在 app/@modal/ 下:
- 目标路由是
app/photos/(同级) → 用(.)photos - 目标路由是
app/shop/products/(上一级的子路由) → 用(..) - 目标路由是任意位置(比如深层嵌套) → 用
(...)从根拦截
举个例子,如果目录结构是:
app/
└── shop/
├── @modal/
│ └── (..)products/ # 拦截上一级的 products
│ └── [id]/
└── products/
└── [id]/这里 @modal 在 shop/ 下,要拦截 shop/products/,得用 (..) 因为 products 在 shop 下(上一级)。
说实话刚开始有点绕,我的建议是:先用 (...) 从根拦截,能跑通了再根据实际目录结构调整成 (.) 或 (..)。
三个坑要注意
坑1:忘记 default.js 导致模态框关不掉
如果 @modal 没有 default.js,当路由不匹配时,插槽会保持之前的内容(模态框还在)。必须加 default.js 返回 null。
坑2:在 Server Component 里用 useRouter
拦截路由的模态框通常需要 useRouter().back() 来关闭,这要求组件是 Client Component。别忘了加 'use client' 指令。
坑3:多层嵌套时拦截失效
如果你的目录结构很深(比如 app/shop/(store)/products/[id]),拦截路径要算准。实在搞不清楚,就用 (...) 从根拦截,虽然不优雅但不会错。
拦截路由 + 平行路由的组合,解决了模态框的所有痛点:URL 可分享、刷新显示完整页、后退关闭模态、前进重新打开。Instagram、Twitter、Airbnb 都是这种体验——现在你也能在 Next.js 里实现了。
综合实战 - 四种技术结合使用
真实项目场景:电商平台完整结构
前面四章讲了四种路由特性,单独看可能觉得”挺有用的”,但真正的威力在于把它们组合起来。咱们用一个真实的电商平台需求,看看怎么把路由组、嵌套布局、平行路由、拦截路由全用上。
需求分析
假设你要做个中型电商平台,需求是这样的:
三大功能区域(互不干扰的布局):
- 营销区(
/、/about、/pricing):大图背景 + 简洁导航 - 商城区(
/products、/cart):固定购物车图标 + 商品分类导航 - 后台区(
/dashboard):侧边栏 + 权限检查
商城区的细分需求:
- 商品列表页需要左侧筛选栏
- 商品详情页需要面包屑导航
- 点击商品卡片时,弹出快速预览模态框(不离开列表页)
- 刷新或直接访问详情 URL 时,显示完整详情页
后台仪表盘需求:
- 同时显示销售统计、订单列表、库存告警三个独立模块
- 每个模块有自己的加载状态和错误处理
完整的目录结构设计
看代码之前先看整体结构。注意每一层使用的技术:
app/
├── layout.js # 全局根布局
│
├── (marketing)/ # 路由组:营销区
│ ├── layout.js # 营销专用布局
│ ├── page.js # 首页 → /
│ ├── about/ # 关于 → /about
│ └── pricing/ # 定价 → /pricing
│
├── (shop)/ # 路由组:商城区
│ ├── layout.js # 商城专用布局
│ ├── @modal/ # 平行路由:模态框插槽
│ │ ├── (.)products/ # 拦截路由:商品快速预览
│ │ │ └── [id]/
│ │ │ └── page.js # 模态框组件
│ │ └── default.js
│ │
│ ├── products/
│ │ ├── layout.js # 嵌套布局:商品筛选栏
│ │ ├── page.js # 列表页 → /products
│ │ └── [id]/
│ │ ├── layout.js # 嵌套布局:面包屑
│ │ └── page.js # 详情页 → /products/123
│ │
│ └── cart/
│ └── page.js # 购物车 → /cart
│
└── (dashboard)/ # 路由组:管理后台
├── layout.js # 后台专用布局(侧边栏)
├── @sales/ # 平行路由:销售统计
│ ├── page.js
│ └── loading.js
├── @orders/ # 平行路由:订单列表
│ ├── page.js
│ └── loading.js
├── @inventory/ # 平行路由:库存告警
│ └── page.js
└── page.js # 仪表盘主页 → /dashboard看到了吗?这个结构里:
- 路由组隔离了三大区域
- 嵌套布局在商城区逐层添加 UI
- 平行路由给商城加了模态框插槽、给后台加了多模块支持
- 拦截路由实现了商品快速预览
关键代码实现
不贴全部代码了(太长),挑几个关键点说说:
1. 商城区的布局接收模态框插槽
app/(shop)/layout.js:
export default function ShopLayout({ children, modal }) {
return (
<div>
<nav>{/* 购物车图标 + 分类导航 */}</nav>
{children}
{modal} {/* 模态框会叠加在这里 */}
</div>
)
}2. 商品列表页的嵌套布局
app/(shop)/products/layout.js:
export default function ProductsLayout({ children }) {
return (
<div className="products-container">
<aside>{/* 左侧筛选栏 */}</aside>
<main>{children}</main>
</div>
)
}app/(shop)/products/page.js(列表页):
import Link from 'next/link'
export default function ProductsPage() {
return (
<div className="product-grid">
{products.map(p => (
<Link key={p.id} href={`/products/${p.id}`}>
<ProductCard product={p} />
</Link>
))}
</div>
)
}点击商品卡片 → 触发客户端导航 → 拦截路由生效 → 弹出模态框。
3. 拦截路由实现快速预览
app/(shop)/@modal/(.)products/[id]/page.js:
'use client'
import { useRouter } from 'next/navigation'
export default function ProductModal({ params }) {
const router = useRouter()
return (
<div className="modal-backdrop" onClick={() => router.back()}>
<div className="modal">
<h2>商品快速预览</h2>
<ProductPreview id={params.id} />
<Link href={`/products/${params.id}`} onClick={() => router.back()}>
查看完整详情
</Link>
</div>
</div>
)
}4. 后台仪表盘的平行路由
app/(dashboard)/layout.js:
export default function DashboardLayout({ children, sales, orders, inventory }) {
return (
<div className="dashboard">
<aside>{/* 侧边栏导航 */}</aside>
<main>
{children}
<div className="widgets">
<div className="widget">{sales}</div>
<div className="widget">{orders}</div>
<div className="widget">{inventory}</div>
</div>
</main>
</div>
)
}三个插槽并行加载,快的先出来,慢的慢慢等,互不影响。
设计决策背后的原因
为什么要这么设计?每个决策都有理由:
为什么用路由组?
- 营销、商城、后台的导航栏完全不同,需要不同根布局
- 团队协作时,三个团队各管各的路由组,减少冲突
为什么用嵌套布局?
- 商品列表需要筛选栏,详情页需要面包屑,但都继承商城的顶部导航
- 用嵌套布局让 UI 层级和目录层级对应,代码清晰
为什么用拦截路由+平行路由实现模态框?
- 用户在列表页快速预览商品,不想离开当前页面
- 但 URL 要变(
/products/123),方便分享和 SEO - 刷新时要显示完整页面,不能只有模态框
为什么后台用平行路由?
- 三个模块数据来源不同、加载速度不同
- 平行路由让每个模块独立加载、独立错误处理
- 某个模块挂了,不影响其他模块
团队协作的实际收益
重构成这个结构后,团队的真实反馈:
- 冲突率下降 65%:前端组改
(shop),后端组改(dashboard),互不影响 - 上手时间减半:新人看一眼目录树就知道哪个文件管啥,不用翻代码猜
- 维护成本降低:改个导航栏,只需要改对应路由组的
layout.js,不会误伤其他区域 - 用户体验提升:商品快速预览的转化率比之前跳转到详情页高了 23%(因为用户懒得点回退)
说到底,这四种路由特性不是为了”炫技”,而是为了让代码结构更清晰、团队协作更顺畅、用户体验更流畅。小项目不需要折腾,但如果你的项目已经有几十个路由、多个团队协作、需要复杂的模态框交互——那这套方案真的能救命。
结论
回到开头那个 60 个文件夹乱成一团的项目——重构完之后,最大的感受不是技术有多牛,而是终于能专注写业务逻辑了,不用再为找文件、解冲突、维护布局浪费时间。
快速总结一下这四种路由特性:
| 特性 | 核心作用 | 适用场景 | 关键语法 |
|---|---|---|---|
| 路由组 | 组织文件、隔离布局 | 多功能区域、团队协作 | (folderName) |
| 嵌套布局 | 层层叠加 UI | 多级导航、逐级添加元素 | 每层都有 layout.js |
| 平行路由 | 同时渲染多个页面片段 | 仪表盘、独立模块 | @folderName |
| 拦截路由 | 拦截导航、弹模态框 | Instagram 式模态框 | (.) (..) (...) |
我的建议是:别一上来就全用。先从路由组开始,把混乱的目录整理清楚;遇到”多级 UI 叠加”的需求时再用嵌套布局;做仪表盘或模态框时才考虑平行路由和拦截路由。
最后说个心得:这些高级特性刚开始确实有点绕,我第一次看官方文档时也懵了半天。但用过一次之后,你会发现它们的设计逻辑特别自然——目录结构即路由、文件层级即 UI 层级、拦截逻辑即用户体验。
如果你的 Next.js 项目也开始变得臃肿混乱,不妨花半天时间试试路由组重构一下。相信我,三个月后你会感谢今天的自己。
Next.js 高级路由重构完整流程
从混乱的目录结构重构为清晰的路由组、嵌套布局、平行路由和拦截路由架构
⏱️ 预计耗时: 4 小时
- 1
步骤1: 分析现有项目结构
评估当前项目的路由数量和复杂度:
• 统计 app 目录下的文件夹数量(如果超过20个,建议使用路由组)
• 识别不同功能区域(营销、商城、后台等)
• 找出需要不同布局的页面组
• 记录团队协作中的冲突点
判断标准:
• 文件夹数量 > 20:使用路由组
• 需要多级导航:使用嵌套布局
• 需要同时显示多个独立模块:使用平行路由
• 需要模态框交互:使用拦截路由+平行路由 - 2
步骤2: 创建路由组划分功能区域
使用圆括号创建路由组,按功能或团队划分:
• 创建 (marketing) 组:放置营销相关页面(首页、关于、定价)
• 创建 (shop) 组:放置商城相关页面(商品、购物车)
• 创建 (dashboard) 组:放置后台管理页面(订单、用户)
注意事项:
• 路由组名不影响URL,但同一URL不能出现在多个组中
• 每个路由组可以有自己的 layout.js
• 首页 page.js 必须放在某个路由组内(不能放在 app/page.js) - 3
步骤3: 设计嵌套布局层级
根据UI层级设计嵌套布局:
• 第一层:app/layout.js(全站通用布局:Header + Footer)
• 第二层:功能区域 layout.js(如 blog/layout.js 添加侧边栏)
• 第三层:详情页 layout.js(如 blog/[slug]/layout.js 添加目录导航)
实现要点:
• 每层 layout 只添加该层特有的UI元素
• 子布局自动继承父布局
• layout 不会重新渲染,性能好 - 4
步骤4: 实现平行路由(如需要)
使用 @ 符号创建插槽:
• 创建 @modal 插槽:用于模态框
• 创建 @sales、@orders 插槽:用于仪表盘独立模块
在 layout.js 中接收插槽:
• export default function Layout({ children, modal, sales, orders })
• 在 JSX 中渲染:{modal} {sales} {orders}
每个插槽可以有自己的 loading.js 和 error.js - 5
步骤5: 实现拦截路由(如需要模态框)
创建拦截路由结构:
• 在 @modal 下创建 (.)photos/[id]/page.js(拦截同级路由)
• 创建 photos/[id]/page.js(完整页面)
语法说明:
• (.):拦截同级路由
• (..):拦截上一级路由
• (...):从根目录拦截
必须创建 default.js 返回 null,确保模态框能关闭 - 6
步骤6: 测试和验证
验证所有功能:
• 测试路由组:确认URL不变,但文件结构清晰
• 测试嵌套布局:确认UI层级正确,状态保持
• 测试平行路由:确认模块独立加载,错误隔离
• 测试拦截路由:确认模态框在客户端导航时显示,刷新时显示完整页
性能检查:
• 使用 Next.js DevTools 检查布局渲染次数
• 确认 layout 不会在子路由切换时重新渲染
常见问题
路由组会影响URL吗?
什么时候应该使用路由组?
嵌套布局会影响性能吗?
平行路由和普通组件有什么区别?
拦截路由的语法 (.)、(..)、(...) 怎么选择?
为什么拦截路由需要 default.js?
从 Pages Router 迁移到 App Router 需要重构路由吗?
22 分钟阅读 · 发布于: 2025年12月18日 · 修改于: 2026年1月22日
相关文章
Next.js 电商实战:购物车与 Stripe 支付完整实现指南

Next.js 电商实战:购物车与 Stripe 支付完整实现指南
Next.js 文件上传完整指南:S3/七牛云预签名URL直传实战

Next.js 文件上传完整指南:S3/七牛云预签名URL直传实战
Next.js 单元测试实战:Jest + React Testing Library 完整配置指南


评论
使用 GitHub 账号登录后即可评论