Next.js App Router 实战:用路由组和嵌套布局解决大型项目的目录混乱问题

凌晨一点,我盯着 VS Code 左侧的文件树发呆。app 目录下密密麻麻 120 多个文件夹,我想找后台的用户管理页面,结果在 dashboard-user-list、admin-users、backend-user-management 三个文件夹之间来回切换了五分钟——它们看起来都像。
更崩溃的是早上的代码评审。小李新加了一个 /about 路由,结果和营销组上周提交的 /about 撞了。两个人大眼瞪小眼:“你的 about 是关于我们,我的 about 是关于产品,凭什么我要改?”
说实话,这不是第一次了。项目刚开始时只有十几个页面,扁平化的目录结构看着还挺清爽。半年过去,功能翻了十倍,整个 app 目录就像没人整理的衣柜——你知道东西在里面,但每次找都要翻个底朝天。
如果你也在做 Next.js 项目,如果你的团队超过三个人,如果你的页面数超过了 50 个,那你大概率会遇到同样的问题。好消息是,Next.js App Router 其实提供了四个特性专门解决这个问题:路由组、嵌套布局、平行路由、拦截路由。但网上的教程大多是 demo 级别的”Hello World”,真正用到项目里,还是一头雾水。
这篇文章,我会用一个真实电商项目的例子,带你看看这四个特性到底怎么用,以及如何把乱糟糟的目录重新整理成可维护、可扩展的结构。
痛点重现 - 传统目录结构的三大问题
扁平化目录的困境
先看看我们之前的目录长什么样:
app/
├── page.tsx # 首页
├── about/page.tsx # 关于我们
├── products/page.tsx # 商品列表
├── product-detail/[id]/page.tsx
├── cart/page.tsx
├── checkout/page.tsx
├── dashboard/page.tsx # 后台首页
├── dashboard-users/page.tsx
├── dashboard-users-active/page.tsx
├── dashboard-users-blocked/page.tsx
├── dashboard-orders/page.tsx
├── dashboard-orders-pending/page.tsx
├── dashboard-settings/page.tsx
├── auth-login/page.tsx # 登录
├── auth-register/page.tsx
└── ... (还有 80+ 个文件夹)看着就头大。更要命的是 URL 路径也变得很奇怪: /dashboard-users-active 而不是 /dashboard/users/active。为了避免冲突,我们不得不给文件夹名加各种前缀,但这只是把问题掩盖了。
你根本没办法一眼看出哪些页面属于前台,哪些属于后台,哪些是认证相关的。新人加入团队,光是熟悉目录结构就要花好几天。
布局重复和维护困难
前台和后台的布局完全不同。前台有顶部导航和底部版权信息,后台有侧边栏和权限控制。传统做法是在每个页面组件里手动引入布局:
// app/dashboard-users/page.tsx
import DashboardLayout from '@/components/DashboardLayout'
export default function UsersPage() {
return (
<DashboardLayout>
<div>用户管理内容</div>
</DashboardLayout>
)
}这样写有几个问题。首先是容易遗漏——新建一个后台页面,忘记加布局,页面就光秃秃的。其次是不统一——有人用 DashboardLayout,有人用 AdminLayout,最后维护起来一团乱。
更要命的是,如果要修改后台布局(比如改侧边栏的样式),你可能需要去 20 个文件里确认每个页面是不是都用了正确的布局组件。说实话,每次改布局我都心惊胆战。
模态框和弹窗的路由困境
产品经理提了个需求:用户在商品列表页点击某个商品,弹出模态框显示详情,同时 URL 要变成 /product/123,这样用户可以分享这个链接。听起来挺合理,但实现起来就头疼了。
传统做法是用客户端状态管理,手动控制模态框的显示隐藏,再手动操作 URL。代码写得很恶心,而且有个致命问题:用户刷新页面,模态框就消失了,体验很差。
你可能说,那就做两套呗——一套模态框版本,一套完整页面版本。确实可以,但这意味着同样的内容要维护两份代码。产品逻辑一改,两边都得改,容易出 bug。
类似 Instagram 那种体验——列表页点图片弹模态框,刷新页面显示完整大图——看起来很简单,但用传统路由方式实现真的很麻烦。
路由组(Route Groups)- 逻辑分组不影响 URL
什么是路由组
说白了就是用括号把文件夹名包起来,比如 (marketing),这个括号里的名字不会出现在 URL 里。听起来有点抽象,直接看代码:
app/
├── (marketing)/ # 前台营销页面组
│ ├── layout.tsx # 前台专用布局
│ ├── page.tsx # URL: /
│ ├── about/page.tsx # URL: /about
│ └── products/page.tsx # URL: /products
├── (shop)/ # 电商功能组
│ ├── layout.tsx
│ ├── cart/page.tsx # URL: /cart
│ └── checkout/page.tsx # URL: /checkout
└── (dashboard)/ # 后台管理组
├── layout.tsx
├── dashboard/page.tsx # URL: /dashboard
├── users/page.tsx # URL: /users (不是 /dashboard/users!)
└── orders/page.tsx # URL: /orders注意看,(marketing)、(shop)、(dashboard) 这些括号里的名字都不会出现在 URL 里。(marketing)/about/page.tsx 的路由路径还是 /about,不是 /marketing/about。
你可能会想,这不是多此一举吗?括号里的名字又不影响 URL,要它干嘛?
其实,路由组的核心价值在于组织代码而不是影响路由。它让你可以按业务逻辑、团队分工、功能模块给路由分组,目录结构一目了然,但不会让 URL 变得冗长。
实战案例:按团队分组
我们团队有三个小组:营销组负责官网和宣传页面,产品组负责电商功能,后台组负责管理系统。之前所有人都在同一个 app 目录下工作,文件冲突是家常便饭。用了路由组之后:
app/
├── (team-marketing)/ # 营销团队负责
│ ├── layout.tsx
│ ├── page.tsx # 首页
│ ├── about/page.tsx
│ └── pricing/page.tsx
├── (team-product)/ # 产品团队负责
│ ├── layout.tsx
│ ├── products/page.tsx
│ └── product/[id]/page.tsx
└── (team-backend)/ # 后台团队负责
├── layout.tsx
├── dashboard/page.tsx
└── admin/page.tsx这样做的好处很明显:
减少文件冲突。营销组在
(team-marketing)里工作,产品组在(team-product)里工作,大家井水不犯河水。Git 合并代码时冲突少了很多。代码审查更清晰。看 Pull Request 时,一眼就能知道这个改动影响哪个团队的代码。
独立布局。每个路由组可以有自己的
layout.tsx,营销页面用营销风格的布局,后台页面用后台风格的布局,不用手动在每个页面里引入。
另一个案例:按布局类型分组
有些项目不是按团队分,而是按布局类型分:
app/
├── (with-nav)/ # 有顶部导航的页面
│ ├── layout.tsx
│ ├── page.tsx
│ ├── about/page.tsx
│ └── products/page.tsx
├── (fullscreen)/ # 全屏页面(无导航)
│ ├── layout.tsx
│ └── video/[id]/page.tsx
└── (auth)/ # 认证页面(简洁布局)
├── layout.tsx
├── login/page.tsx
└── register/page.tsx登录注册页面通常不需要顶部导航栏和底部信息,用 (auth) 路由组单独管理,给它一个简洁的布局。视频播放页面需要全屏显示,也单独分组。
注意事项
路由组虽然好用,但有个坑:不同路由组里不能有相同的路由路径。
比如你不能同时有 (marketing)/about/page.tsx 和 (shop)/about/page.tsx,因为它们都会解析成 /about,Next.js 不知道该用哪个,会直接报错。
解决办法是规划好路由,确保每个路径都是唯一的。如果实在避免不了,可以给其中一个加个前缀,比如 (shop)/about-us/page.tsx。
还有一点,路由组的命名要有意义。别用 (group1)、(group2) 这种无意义的名字,用 (marketing)、(dashboard)、(auth) 这种一看就懂的名字,方便团队协作。
嵌套布局(Nested Layouts)- 自动布局继承
嵌套布局的工作原理
路由组解决了目录组织问题,但还有个问题:布局的层级关系。比如后台管理系统,通常有这样的结构:
- 一级:顶部标题栏 + 侧边栏(所有后台页面共享)
- 二级:用户管理模块有自己的标签页(活跃用户、冻结用户)
- 三级:具体的页面内容
Next.js 的嵌套布局就是为这种场景设计的。你在不同层级的文件夹里放 layout.tsx,它们会自动嵌套:
app/(dashboard)/
├── layout.tsx # 一级布局:顶部导航 + 侧边栏
├── users/
│ ├── layout.tsx # 二级布局:用户管理标签页
│ ├── active/page.tsx # /users/active
│ └── blocked/page.tsx # /users/blocked
└── orders/
├── layout.tsx # 二级布局:订单管理标签页
├── pending/page.tsx
└── completed/page.tsx当用户访问 /users/active 时,渲染层级是这样的:
DashboardLayout (一级)
└─ UsersLayout (二级)
└─ ActiveUsersPage (页面)代码长这样:
// app/(dashboard)/layout.tsx - 一级布局
export default function DashboardLayout({
children
}: {
children: React.ReactNode
}) {
return (
<div className="dashboard-container">
<TopBar />
<div className="content-area">
<Sidebar />
<main>{children}</main>
</div>
</div>
)
}
// app/(dashboard)/users/layout.tsx - 二级布局
export default function UsersLayout({
children
}: {
children: React.ReactNode
}) {
return (
<div className="users-section">
<div className="tabs">
<Link href="/users/active">活跃用户</Link>
<Link href="/users/blocked">冻结用户</Link>
</div>
{children}
</div>
)
}
// app/(dashboard)/users/active/page.tsx - 页面
export default function ActiveUsersPage() {
return <div>活跃用户列表...</div>
}看到没?页面组件里完全不需要手动引入布局,Next.js 自动帮你套好了。
部分渲染的性能优势
嵌套布局最牛的地方在于部分渲染(Partial Rendering)。当你从”活跃用户”切换到”冻结用户”时:
- 一级布局(顶部导航、侧边栏)不会重新渲染
- 二级布局(标签页)也不会重新渲染
- 只有页面内容重新渲染
这意味着两个事:
性能更好。不用重复渲染相同的布局组件,页面切换更快。
客户端状态保留。如果侧边栏有个折叠/展开的状态,切换页面时这个状态不会丢失。
我之前做的一个项目,后台侧边栏有个搜索框,用户输入内容后切换页面,搜索框的内容会被清空,体验很差。用了嵌套布局后,这个问题自然就解决了——侧边栏组件根本不会重新渲染,状态当然会保留。
实战案例:多级导航
真实项目里经常有三级甚至四级导航。比如:
- 后台管理(一级布局:顶栏 + 侧边栏)
- 用户管理(二级布局:用户管理标签页)
- 活跃用户(三级:页面内容)
- 冻结用户(三级:页面内容)
- 订单管理(二级布局:订单管理标签页)
- 待处理订单(三级:页面内容)
- 已完成订单(三级:页面内容)
- 用户管理(二级布局:用户管理标签页)
目录结构完全对应这个层级关系,代码逻辑一目了然:
app/(dashboard)/
├── layout.tsx # 一级:顶栏 + 侧边栏
├── users/
│ ├── layout.tsx # 二级:用户管理区域
│ ├── active/page.tsx # 三级:活跃用户
│ └── blocked/page.tsx # 三级:冻结用户
└── orders/
├── layout.tsx # 二级:订单管理区域
├── pending/page.tsx # 三级:待处理
└── completed/page.tsx # 三级:已完成性能优化小贴士
嵌套布局默认是服务器组件(Server Component),这是好事,意味着布局逻辑在服务器端渲染,不会增加客户端 JavaScript 体积。
但如果布局里有交互(比如搜索框、下拉菜单),你需要把交互部分提取成客户端组件:
// app/(dashboard)/layout.tsx - 保持服务器组件
import SearchBar from '@/components/SearchBar' // 客户端组件
export default function DashboardLayout({ children }) {
return (
<div>
<SearchBar /> {/* 客户端组件 */}
<main>{children}</main>
</div>
)
}
// components/SearchBar.tsx - 客户端组件
'use client'
import { useState } from 'react'
export default function SearchBar() {
const [query, setQuery] = useState('')
// ...交互逻辑
}这样既保持了布局的服务器端优势,又不影响交互功能。
还有个技巧,可以为每个布局层级设置 loading.tsx,展示加载状态。用户体验会好很多:
app/(dashboard)/
├── layout.tsx
├── loading.tsx # 一级加载状态
└── users/
├── layout.tsx
├── loading.tsx # 二级加载状态
└── active/
├── page.tsx
└── loading.tsx # 三级加载状态每个层级可以有自己的加载动画,不会互相干扰。
平行路由(Parallel Routes)- 同时渲染多个页面
平行路由解决什么问题
仪表盘(Dashboard)页面通常会同时展示多个独立的模块,比如:
- 左上角:数据分析面板
- 右上角:团队成员面板
- 下方:最新通知面板
每个面板的数据是独立获取的,加载速度也不一样。传统做法是把这些内容都写在一个页面组件里,但这样有个问题:如果某个面板的数据很慢,整个页面都会卡在加载状态。
平行路由让你可以把这些模块拆分成独立的”槽位”(slot),每个槽位有自己的加载状态、错误处理,甚至可以根据条件选择性渲染。
基本语法
创建平行路由很简单,用 @ 开头命名文件夹:
app/dashboard/
├── layout.tsx
├── @analytics/page.tsx # 分析槽位
├── @team/page.tsx # 团队槽位
├── @notifications/page.tsx # 通知槽位
└── page.tsx # 默认页面注意 @analytics、@team、@notifications,这些 @ 开头的文件夹就是槽位。
然后在 layout.tsx 里,你可以接收这些槽位作为 props:
// app/dashboard/layout.tsx
export default function DashboardLayout({
children,
analytics,
team,
notifications,
}: {
children: React.ReactNode
analytics: React.ReactNode
team: React.ReactNode
notifications: React.ReactNode
}) {
return (
<div className="dashboard-grid">
<div className="main-content">{children}</div>
<div className="top-panels">
<div className="panel">{analytics}</div>
<div className="panel">{team}</div>
</div>
<div className="bottom-panel">{notifications}</div>
</div>
)
}每个槽位对应一个独立的页面组件,可以有自己的 loading 和 error 状态:
app/dashboard/
├── @analytics/
│ ├── page.tsx
│ ├── loading.tsx # 分析面板的加载状态
│ └── error.tsx # 分析面板的错误处理
├── @team/
│ ├── page.tsx
│ ├── loading.tsx
│ └── error.tsx
└── @notifications/
├── page.tsx
├── loading.tsx
└── error.tsx这样的好处是,如果分析面板的数据很慢,只有这个面板显示加载动画,其他面板正常显示。如果某个面板出错,也不会影响整个页面。
实战案例:条件渲染
平行路由的另一个强大功能是条件渲染。比如,团队面板只有管理员才能看到:
// app/dashboard/layout.tsx
import { auth } from '@/lib/auth'
export default async function DashboardLayout({
analytics,
team,
notifications,
}) {
const user = await auth()
const isAdmin = user?.role === 'admin'
return (
<div className="dashboard-grid">
<div>{analytics}</div>
{isAdmin && <div>{team}</div>} {/* 只有管理员看到 */}
<div>{notifications}</div>
</div>
)
}default.tsx 的作用
平行路由有个容易踩的坑。当用户从 /dashboard 导航到 /dashboard/settings 时,槽位可能没有对应的页面。Next.js 不知道该渲染什么,就会报错。
解决办法是创建 default.tsx,作为后备内容:
// app/dashboard/@team/default.tsx
export default function Default() {
return null // 或者返回一个占位组件
}有了 default.tsx,当槽位没有对应页面时,会渲染这个后备内容,避免报错。
什么时候用平行路由
坦白说,平行路由的使用场景没有路由组和嵌套布局那么广泛。它适合:
- 仪表盘多模块展示:多个独立的数据面板,需要独立加载
- A/B 测试:根据用户分组显示不同的槽位内容
- 权限控制:根据用户权限选择性渲染某些槽位
但如果只是简单的上下排列内容,不需要独立加载状态,直接在页面组件里写就行了,不用搞平行路由。
拦截路由(Intercepting Routes)- 实现模态框路由
Instagram 式的体验
老实讲,拦截路由是四个特性里最难理解的一个。我第一次看文档时完全不明白它要解决什么问题,直到看到 Instagram 的例子。
你在 Instagram 上刷朋友圈(feed),点击某张图片,图片会在模态框里放大显示,同时 URL 变成了 /photo/abc123。这时:
- 如果你刷新页面,模态框消失,显示完整的图片页面
- 如果你把 URL 分享给别人,别人打开的是完整的图片页面,不是模态框
- 如果你点击关闭按钮,模态框消失,回到朋友圈
这种体验的好处很明显:URL 是可分享的,刷新页面不会丢失上下文,但又保持了流畅的客户端导航。传统做法很难实现这种效果。
拦截路由就是为了解决这个问题。
基本语法
拦截路由使用一种特殊的文件夹命名方式:
(.)匹配同级路由(..)匹配上一级路由(..)(..)匹配上上级路由(...)匹配根目录路由
听起来有点抽象,直接看代码:
app/
├── products/
│ ├── page.tsx # 商品列表页
│ └── (..)product/[id]/page.tsx # 拦截 /product/123,以模态框显示
└── product/
└── [id]/page.tsx # 完整的商品详情页当用户在 /products 页面点击某个商品链接(<Link href="/product/123">)时:
- 客户端导航:触发拦截,渲染
(..)product/[id]/page.tsx(模态框版本) - 直接访问
/product/123或刷新页面:不触发拦截,渲染正常的product/[id]/page.tsx(完整页面)
实战案例:商品详情模态框
我之前做的电商项目有个需求:商品列表页点击商品,弹模态框显示详情。
目录结构:
app/
├── (shop)/
│ └── products/
│ ├── page.tsx # 商品列表
│ └── (..)product/[id]/page.tsx # 模态框版本
└── product/
└── [id]/page.tsx # 完整页面版本模态框版本的代码:
// app/(shop)/products/(..)product/[id]/page.tsx
'use client'
import { useRouter } from 'next/navigation'
import Modal from '@/components/Modal'
import ProductDetail from '@/components/ProductDetail'
export default function ProductModal({
params
}: {
params: { id: string }
}) {
const router = useRouter()
return (
<Modal onClose={() => router.back()}>
<ProductDetail id={params.id} />
</Modal>
)
}完整页面版本:
// app/product/[id]/page.tsx
import ProductDetail from '@/components/ProductDetail'
export default function ProductPage({
params
}: {
params: { id: string }
}) {
return (
<div className="product-page">
<ProductDetail id={params.id} />
</div>
)
}注意,商品详情组件(ProductDetail)是复用的,只是外面包了不同的容器。一个是模态框,一个是完整页面。
结合平行路由使用
拦截路由单独用有时会遇到状态管理问题。更好的做法是结合平行路由:
app/(shop)/products/
├── layout.tsx
├── page.tsx
├── @modal/
│ ├── (..)product/[id]/page.tsx # 模态框槽位
│ └── default.tsx # 默认为空布局组件:
// app/(shop)/products/layout.tsx
export default function ProductsLayout({
children,
modal,
}: {
children: React.ReactNode
modal: React.ReactNode
}) {
return (
<>
{children}
{modal}
</>
)
}
// app/(shop)/products/@modal/default.tsx
export default function Default() {
return null // 没有模态框时返回 null
}这样做的好处是,模态框和主内容完全分离,状态管理更清晰,也更容易理解。
注意事项
拦截路由有几个需要注意的点:
只在客户端导航时拦截。如果用户直接在浏览器地址栏输入 URL 或刷新页面,不会触发拦截。
需要维护两个版本。模态框版本和完整页面版本都要实现,虽然可以复用组件,但还是有点重复代码。
路径匹配规则。
(..)是基于路由路径的,不是文件系统路径。如果用了路由组,要注意路由组不影响 URL,所以路径匹配可能和你想的不一样。
比如:
app/
├── (shop)/products/
│ └── (..)product/[id]/page.tsx # 拦截 /product/[id]这里 (..) 是从 /products 往上一级,到根目录,所以匹配的是 /product/[id],不是 /(shop)/product/[id]。
什么时候用拦截路由
拦截路由适合:
- 图片画廊:列表点击图片,弹模态框显示大图
- 商品详情:列表点击商品,弹模态框显示详情
- 登录弹窗:导航栏的登录按钮,弹模态框登录,但
/login路由也可以直接访问
不适合:
- 简单的弹窗(不需要 URL 变化的)
- 不需要深度链接的场景
总的来说,拦截路由是个很强大的特性,但也是最复杂的。如果你的项目没有类似 Instagram 这种需求,暂时用不上也没关系。
综合实战 - 电商项目完整目录结构
前面讲了四个特性,现在把它们组合起来,看看一个真实的电商项目该怎么组织目录。
项目需求
一个典型的电商项目通常有这些模块:
前台(面向用户):
- 首页、关于我们(营销页面)
- 商品列表、商品详情(支持模态框)
- 购物车、结账
后台(面向管理员):
- 仪表盘(多模块展示:数据分析、团队、通知)
- 用户管理(活跃用户、冻结用户)
- 订单管理(待处理、已完成)
认证:
- 登录、注册(独立布局,无导航栏)
最终目录结构
app/
├── layout.tsx # 根布局
│
├── (marketing)/ # 前台路由组
│ ├── layout.tsx # 前台布局(头部导航 + 底部)
│ ├── page.tsx # / (首页)
│ ├── about/page.tsx # /about
│ └── pricing/page.tsx # /pricing
│
├── (shop)/ # 电商功能组
│ ├── layout.tsx # 电商布局
│ ├── products/
│ │ ├── layout.tsx # 商品列表布局(包含模态框槽位)
│ │ ├── page.tsx # /products
│ │ └── @modal/
│ │ ├── (..)product/[id]/page.tsx # 商品详情模态框
│ │ └── default.tsx
│ ├── cart/page.tsx # /cart
│ └── checkout/page.tsx # /checkout
│
├── product/
│ └── [id]/page.tsx # /product/123 (完整页面)
│
├── (dashboard)/ # 后台路由组
│ ├── layout.tsx # 后台布局(侧边栏 + 顶栏)
│ ├── dashboard/
│ │ ├── layout.tsx # 仪表盘布局(平行路由)
│ │ ├── page.tsx # /dashboard (默认内容)
│ │ ├── @analytics/
│ │ │ ├── page.tsx # 数据分析模块
│ │ │ ├── loading.tsx
│ │ │ └── default.tsx
│ │ ├── @team/
│ │ │ ├── page.tsx # 团队模块
│ │ │ ├── loading.tsx
│ │ │ └── default.tsx
│ │ └── @notifications/
│ │ ├── page.tsx # 通知模块
│ │ ├── loading.tsx
│ │ └── default.tsx
│ ├── users/
│ │ ├── layout.tsx # 用户管理二级布局
│ │ ├── active/page.tsx # /users/active
│ │ └── blocked/page.tsx # /users/blocked
│ └── orders/
│ ├── layout.tsx # 订单管理二级布局
│ ├── pending/page.tsx # /orders/pending
│ └── completed/page.tsx # /orders/completed
│
└── (auth)/ # 认证路由组
├── layout.tsx # 简洁布局(无导航)
├── login/page.tsx # /login
└── register/page.tsx # /register和传统结构的对比
让我们对比一下传统扁平结构和新结构:
| 维度 | 传统扁平结构 | 使用路由组+嵌套布局 |
|---|---|---|
| 文件查找 | 需要在 100+ 文件中找,靠命名前缀区分 | 按业务模块分组,一目了然 |
| 布局管理 | 每个页面手动引入布局组件 | 自动继承,修改一处生效全部 |
| 团队协作 | 所有人在同一个目录工作,容易冲突 | 不同团队/模块在不同文件夹 |
| URL 清晰度 | 需要前缀避免冲突(dashboard-users-active) | URL 简洁(/users/active),目录结构清晰 |
| 模态框体验 | 客户端状态管理,刷新丢失状态 | 路由驱动,刷新显示完整页面 |
| 性能 | 切换页面重复渲染布局 | 部分渲染,只渲染变化部分 |
具体收益
用了新结构后,我们团队的实际收益:
找文件速度提升 50%。以前找一个页面要翻好几页,现在直接去对应的路由组就行。
布局修改效率提升。之前改后台侧边栏,要去 20 个文件里确认;现在只改
(dashboard)/layout.tsx一个文件,全部后台页面自动生效。代码冲突减少 60%。营销组在
(marketing)里工作,产品组在(shop)里工作,后台组在(dashboard)里工作,大家各干各的。新人上手更快。新来的实习生看到目录结构,五分钟就明白了整个项目的架构,不用我多解释。
一些实用建议
不要一次性重构。选一个模块(比如后台管理)先试水,验证可行后再推广到整个项目。
路由组命名要有意义。我们用
(marketing)、(shop)、(dashboard)、(auth),团队成员一看就懂。文档很重要。在项目 README 里加一个”目录结构说明”章节,解释各个路由组的职责,方便新人理解。
配合 TypeScript。路由组和嵌套布局配合 TypeScript 的路径映射(
@/*),代码组织会更清晰。
// tsconfig.json
{
"compilerOptions": {
"paths": {
"@/*": ["./src/*"],
"@/components/*": ["./src/components/*"],
"@/app/*": ["./src/app/*"]
}
}
}最佳实践和注意事项
路由组命名规范
路由组的命名直接影响代码的可维护性。我们团队总结了几条规则:
推荐命名:
(marketing)- 营销相关页面(dashboard)或(admin)- 后台管理(auth)- 认证相关(team-xxx)- 按团队分组时使用(feature-xxx)- 按功能分组时使用
避免使用:
(group1)、(group2)- 无意义的名称(temp)、(test)- 临时性的名称- 过长的名称如
(marketing-and-sales-pages)- 不简洁
记住,路由组的名字只是给开发者看的,不会出现在 URL 里,所以要起得让团队成员一看就懂。
避免路由冲突
不同路由组不能有相同的路由路径,这是最容易踩的坑:
❌ 错误示例:
app/
├── (marketing)/about/page.tsx # URL: /about
└── (shop)/about/page.tsx # URL: /about - 冲突!解决办法:
- 规划路由:在开始前画个路由图,确保所有路径唯一
- 加前缀:如果真的需要两个 about,给其中一个加前缀,如
/about-us和/about-product - 调整目录:把冲突的路由放到不同的层级
什么时候用平行路由
平行路由不是必须的,只在这些场景用:
适用场景:
- 仪表盘的多个独立数据面板
- 需要独立加载状态的并列内容
- 根据用户权限条件渲染不同模块
- A/B 测试不同的内容变体
不适用场景:
- 简单的上下排列(直接在页面组件里写就行)
- 不需要独立加载状态的内容
- 静态的布局区域
如果你不确定要不要用平行路由,那大概率是不需要。它是个高级特性,大部分项目用路由组和嵌套布局就够了。
拦截路由的局限性
拦截路由很强大,但有几个需要注意的点:
只在客户端导航时拦截。用户直接输入 URL 或刷新页面,不会触发拦截。
需要维护两个版本。模态框版本和完整页面版本都要实现,虽然可以复用组件,但还是有维护成本。
路径匹配基于路由而非文件系统。路由组不影响 URL,所以
(..)匹配的是 URL 路径,不是文件夹路径,容易搞混。
如果你的需求不需要 URL 变化,或者不需要深度链接,那用普通的客户端模态框就够了,不用搞拦截路由。
性能优化技巧
布局组件保持服务器组件。默认情况下布局是服务器组件,尽量保持这个特性,只把需要交互的部分提取成客户端组件。
使用 loading.tsx。为每个路由层级添加 loading.tsx,提供友好的加载状态,用户体验会好很多。
合理使用 Suspense。配合 loading.tsx 使用 Suspense,可以实现更精细的流式渲染。
避免过度嵌套。布局层级不要超过 4 层,太深会增加复杂度,反而不利于维护。
迁移策略
如果你是从 Pages Router 迁移到 App Router,建议这样做:
增量迁移:app 目录和 pages 目录可以共存,一个模块一个模块地迁移,不要一次性全改。
先迁移布局:用路由组和嵌套布局重构布局逻辑,这是收益最明显的部分。
再迁移数据获取:把 getServerSideProps 改成 fetch,把 getStaticProps 改成 Server Component。
最后迁移路由:把 getStaticPaths 改成 generateStaticParams。
灰度发布:用 feature flag 控制新旧路由的切换,出问题可以快速回滚。
团队协作建议
制定目录结构规范。在 README 或 Wiki 里写清楚各个路由组的职责、命名规则、添加新页面的流程。
代码审查重点。PR 审查时重点检查是否遵守了路由规范,是否有路由冲突,布局是否正确嵌套。
使用 ESLint 规则。可以配置 ESLint 检查路由组命名、禁止某些路径等。
定期重构。每个迭代结束后,抽时间整理一下目录结构,删除废弃的页面,重命名不合理的路由组。
调试技巧
路由组和嵌套布局的调试有时会有点棘手,这里分享几个技巧:
看 React DevTools。在浏览器开发工具的 React tab 里,可以看到完整的组件树,确认布局是否正确嵌套。
用 console.log。在布局组件里加 console.log,看看是否重复渲染,是否每次导航都触发。
检查 Network tab。看看哪些请求是服务器端发的,哪些是客户端发的,确认数据获取逻辑正确。
读 Next.js 的输出。开发模式下 Next.js 会输出很多有用的信息,比如路由冲突、布局缺失等,留意终端的警告和错误。
结论
回到文章开头的那个场景:凌晨一点,盯着密密麻麻 120 个文件夹发呆,找个页面要花五分钟。
如果你的 Next.js 项目也遇到了同样的问题,那这篇文章提到的四个特性会帮到你:
路由组让你按业务逻辑、团队分工给路由分组,目录结构一目了然,但不会让 URL 变冗长。
嵌套布局自动处理布局继承,不用在每个页面里手动引入布局组件,修改布局只需改一个文件。
平行路由让你同时渲染多个独立的模块,每个模块有自己的加载状态和错误处理,适合仪表盘这种多面板场景。
拦截路由实现类似 Instagram 的模态框体验——URL 可分享,刷新不丢失上下文,但保持流畅的客户端导航。
我的建议是,从小做起。选一个模块(比如后台管理)试水,用路由组和嵌套布局重构一下,看看效果如何。验证可行后,再推广到整个项目。
别一次性重构所有代码,那样风险太大。增量迁移,一步一步来,出了问题也容易回滚。
最后放个完整的电商项目目录结构模板,你可以直接参考或复制使用。如果有问题,欢迎留言讨论。
祝你的 Next.js 项目目录从此告别混乱,维护起来更轻松!
Next.js 大型项目目录结构重构完整流程
使用路由组、嵌套布局、平行路由、拦截路由重构混乱的目录结构
⏱️ 预计耗时: 8 小时
- 1
步骤1: 分析现有目录结构
评估当前问题:
• 统计文件夹数量(超过50个建议重构)
• 识别路由冲突点
• 找出重复的布局代码
• 记录团队协作中的痛点
识别功能区域:
• 营销页面(首页、关于、定价)
• 商城页面(商品、购物车、订单)
• 后台管理(用户、订单、设置) - 2
步骤2: 创建路由组划分功能区域
使用圆括号创建路由组:
• (marketing):营销相关页面
• (shop):商城相关页面
• (dashboard):后台管理页面
注意事项:
• 路由组名不影响URL
• 同一URL不能出现在多个组中
• 每个路由组可以有自己的layout.js - 3
步骤3: 设计嵌套布局层级
根据UI层级设计:
• 第一层:app/layout.js(全站通用)
• 第二层:功能区域layout.js(如shop/layout.js)
• 第三层:详情页layout.js(如shop/products/[id]/layout.js)
实现要点:
• 每层只添加该层特有的UI元素
• 子布局自动继承父布局
• 切换页面时layout不会重新渲染 - 4
步骤4: 实现平行路由(如需要)
使用@符号创建插槽:
• @modal:模态框插槽
• @sales、@orders:仪表盘独立模块
在layout.js中接收:
• export default function Layout({ children, modal })
• 在JSX中渲染:{modal}
每个插槽可以有自己的loading.js和error.js - 5
步骤5: 实现拦截路由(如需要模态框)
创建拦截路由:
• 在@modal下创建(.)photos/[id]/page.js
• 创建photos/[id]/page.js(完整页面)
语法:
• (.):拦截同级路由
• (..):拦截上一级路由
• (...):从根目录拦截
必须创建default.js返回null - 6
步骤6: 测试和验证
验证要点:
• 所有路由是否正常
• 布局是否正确嵌套
• 页面切换是否流畅
• 模态框是否正常工作
调试工具:
• React DevTools查看组件树
• console.log检查渲染次数
• Network tab检查请求
• Next.js终端输出检查警告
常见问题
什么时候应该使用路由组?
路由组会影响URL吗?
嵌套布局会影响性能吗?
平行路由和普通组件有什么区别?
拦截路由的语法怎么选择?
如何避免路由冲突?
重构大型项目需要多长时间?
23 分钟阅读 · 发布于: 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 账号登录后即可评论