切换语言
切换主题

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

凌晨一点,我盯着 VS Code 左侧的文件树发呆。app 目录下密密麻麻 120 多个文件夹,我想找后台的用户管理页面,结果在 dashboard-user-listadmin-usersbackend-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

这样做的好处很明显:

  1. 减少文件冲突。营销组在 (team-marketing) 里工作,产品组在 (team-product) 里工作,大家井水不犯河水。Git 合并代码时冲突少了很多。

  2. 代码审查更清晰。看 Pull Request 时,一眼就能知道这个改动影响哪个团队的代码。

  3. 独立布局。每个路由组可以有自己的 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)。当你从”活跃用户”切换到”冻结用户”时:

  • 一级布局(顶部导航、侧边栏)不会重新渲染
  • 二级布局(标签页)也不会重新渲染
  • 只有页面内容重新渲染

这意味着两个事:

  1. 性能更好。不用重复渲染相同的布局组件,页面切换更快。

  2. 客户端状态保留。如果侧边栏有个折叠/展开的状态,切换页面时这个状态不会丢失。

我之前做的一个项目,后台侧边栏有个搜索框,用户输入内容后切换页面,搜索框的内容会被清空,体验很差。用了嵌套布局后,这个问题自然就解决了——侧边栏组件根本不会重新渲染,状态当然会保留。

实战案例:多级导航

真实项目里经常有三级甚至四级导航。比如:

  • 后台管理(一级布局:顶栏 + 侧边栏)
    • 用户管理(二级布局:用户管理标签页)
      • 活跃用户(三级:页面内容)
      • 冻结用户(三级:页面内容)
    • 订单管理(二级布局:订单管理标签页)
      • 待处理订单(三级:页面内容)
      • 已完成订单(三级:页面内容)

目录结构完全对应这个层级关系,代码逻辑一目了然:

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
}

这样做的好处是,模态框和主内容完全分离,状态管理更清晰,也更容易理解。

注意事项

拦截路由有几个需要注意的点:

  1. 只在客户端导航时拦截。如果用户直接在浏览器地址栏输入 URL 或刷新页面,不会触发拦截。

  2. 需要维护两个版本。模态框版本和完整页面版本都要实现,虽然可以复用组件,但还是有点重复代码。

  3. 路径匹配规则(..) 是基于路由路径的,不是文件系统路径。如果用了路由组,要注意路由组不影响 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),目录结构清晰
模态框体验客户端状态管理,刷新丢失状态路由驱动,刷新显示完整页面
性能切换页面重复渲染布局部分渲染,只渲染变化部分

具体收益

用了新结构后,我们团队的实际收益:

  1. 找文件速度提升 50%。以前找一个页面要翻好几页,现在直接去对应的路由组就行。

  2. 布局修改效率提升。之前改后台侧边栏,要去 20 个文件里确认;现在只改 (dashboard)/layout.tsx 一个文件,全部后台页面自动生效。

  3. 代码冲突减少 60%。营销组在 (marketing) 里工作,产品组在 (shop) 里工作,后台组在 (dashboard) 里工作,大家各干各的。

  4. 新人上手更快。新来的实习生看到目录结构,五分钟就明白了整个项目的架构,不用我多解释。

一些实用建议

  1. 不要一次性重构。选一个模块(比如后台管理)先试水,验证可行后再推广到整个项目。

  2. 路由组命名要有意义。我们用 (marketing)(shop)(dashboard)(auth),团队成员一看就懂。

  3. 文档很重要。在项目 README 里加一个”目录结构说明”章节,解释各个路由组的职责,方便新人理解。

  4. 配合 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 - 冲突!

解决办法:

  1. 规划路由:在开始前画个路由图,确保所有路径唯一
  2. 加前缀:如果真的需要两个 about,给其中一个加前缀,如 /about-us/about-product
  3. 调整目录:把冲突的路由放到不同的层级

什么时候用平行路由

平行路由不是必须的,只在这些场景用:

适用场景:

  • 仪表盘的多个独立数据面板
  • 需要独立加载状态的并列内容
  • 根据用户权限条件渲染不同模块
  • A/B 测试不同的内容变体

不适用场景:

  • 简单的上下排列(直接在页面组件里写就行)
  • 不需要独立加载状态的内容
  • 静态的布局区域

如果你不确定要不要用平行路由,那大概率是不需要。它是个高级特性,大部分项目用路由组和嵌套布局就够了。

拦截路由的局限性

拦截路由很强大,但有几个需要注意的点:

  1. 只在客户端导航时拦截。用户直接输入 URL 或刷新页面,不会触发拦截。

  2. 需要维护两个版本。模态框版本和完整页面版本都要实现,虽然可以复用组件,但还是有维护成本。

  3. 路径匹配基于路由而非文件系统。路由组不影响 URL,所以 (..) 匹配的是 URL 路径,不是文件夹路径,容易搞混。

如果你的需求不需要 URL 变化,或者不需要深度链接,那用普通的客户端模态框就够了,不用搞拦截路由。

性能优化技巧

  1. 布局组件保持服务器组件。默认情况下布局是服务器组件,尽量保持这个特性,只把需要交互的部分提取成客户端组件。

  2. 使用 loading.tsx。为每个路由层级添加 loading.tsx,提供友好的加载状态,用户体验会好很多。

  3. 合理使用 Suspense。配合 loading.tsx 使用 Suspense,可以实现更精细的流式渲染。

  4. 避免过度嵌套。布局层级不要超过 4 层,太深会增加复杂度,反而不利于维护。

迁移策略

如果你是从 Pages Router 迁移到 App Router,建议这样做:

  1. 增量迁移:app 目录和 pages 目录可以共存,一个模块一个模块地迁移,不要一次性全改。

  2. 先迁移布局:用路由组和嵌套布局重构布局逻辑,这是收益最明显的部分。

  3. 再迁移数据获取:把 getServerSideProps 改成 fetch,把 getStaticProps 改成 Server Component。

  4. 最后迁移路由:把 getStaticPaths 改成 generateStaticParams。

  5. 灰度发布:用 feature flag 控制新旧路由的切换,出问题可以快速回滚。

团队协作建议

  1. 制定目录结构规范。在 README 或 Wiki 里写清楚各个路由组的职责、命名规则、添加新页面的流程。

  2. 代码审查重点。PR 审查时重点检查是否遵守了路由规范,是否有路由冲突,布局是否正确嵌套。

  3. 使用 ESLint 规则。可以配置 ESLint 检查路由组命名、禁止某些路径等。

  4. 定期重构。每个迭代结束后,抽时间整理一下目录结构,删除废弃的页面,重命名不合理的路由组。

调试技巧

路由组和嵌套布局的调试有时会有点棘手,这里分享几个技巧:

  1. 看 React DevTools。在浏览器开发工具的 React tab 里,可以看到完整的组件树,确认布局是否正确嵌套。

  2. 用 console.log。在布局组件里加 console.log,看看是否重复渲染,是否每次导航都触发。

  3. 检查 Network tab。看看哪些请求是服务器端发的,哪些是客户端发的,确认数据获取逻辑正确。

  4. 读 Next.js 的输出。开发模式下 Next.js 会输出很多有用的信息,比如路由冲突、布局缺失等,留意终端的警告和错误。

结论

回到文章开头的那个场景:凌晨一点,盯着密密麻麻 120 个文件夹发呆,找个页面要花五分钟。

如果你的 Next.js 项目也遇到了同样的问题,那这篇文章提到的四个特性会帮到你:

路由组让你按业务逻辑、团队分工给路由分组,目录结构一目了然,但不会让 URL 变冗长。

嵌套布局自动处理布局继承,不用在每个页面里手动引入布局组件,修改布局只需改一个文件。

平行路由让你同时渲染多个独立的模块,每个模块有自己的加载状态和错误处理,适合仪表盘这种多面板场景。

拦截路由实现类似 Instagram 的模态框体验——URL 可分享,刷新不丢失上下文,但保持流畅的客户端导航。

我的建议是,从小做起。选一个模块(比如后台管理)试水,用路由组和嵌套布局重构一下,看看效果如何。验证可行后,再推广到整个项目。

别一次性重构所有代码,那样风险太大。增量迁移,一步一步来,出了问题也容易回滚。

最后放个完整的电商项目目录结构模板,你可以直接参考或复制使用。如果有问题,欢迎留言讨论。

祝你的 Next.js 项目目录从此告别混乱,维护起来更轻松!

Next.js 大型项目目录结构重构完整流程

使用路由组、嵌套布局、平行路由、拦截路由重构混乱的目录结构

⏱️ 预计耗时: 8 小时

  1. 1

    步骤1: 分析现有目录结构

    评估当前问题:
    • 统计文件夹数量(超过50个建议重构)
    • 识别路由冲突点
    • 找出重复的布局代码
    • 记录团队协作中的痛点

    识别功能区域:
    • 营销页面(首页、关于、定价)
    • 商城页面(商品、购物车、订单)
    • 后台管理(用户、订单、设置)
  2. 2

    步骤2: 创建路由组划分功能区域

    使用圆括号创建路由组:
    • (marketing):营销相关页面
    • (shop):商城相关页面
    • (dashboard):后台管理页面

    注意事项:
    • 路由组名不影响URL
    • 同一URL不能出现在多个组中
    • 每个路由组可以有自己的layout.js
  3. 3

    步骤3: 设计嵌套布局层级

    根据UI层级设计:
    • 第一层:app/layout.js(全站通用)
    • 第二层:功能区域layout.js(如shop/layout.js)
    • 第三层:详情页layout.js(如shop/products/[id]/layout.js)

    实现要点:
    • 每层只添加该层特有的UI元素
    • 子布局自动继承父布局
    • 切换页面时layout不会重新渲染
  4. 4

    步骤4: 实现平行路由(如需要)

    使用@符号创建插槽:
    • @modal:模态框插槽
    • @sales、@orders:仪表盘独立模块

    在layout.js中接收:
    • export default function Layout({ children, modal })
    • 在JSX中渲染:{modal}

    每个插槽可以有自己的loading.js和error.js
  5. 5

    步骤5: 实现拦截路由(如需要模态框)

    创建拦截路由:
    • 在@modal下创建(.)photos/[id]/page.js
    • 创建photos/[id]/page.js(完整页面)

    语法:
    • (.):拦截同级路由
    • (..):拦截上一级路由
    • (...):从根目录拦截

    必须创建default.js返回null
  6. 6

    步骤6: 测试和验证

    验证要点:
    • 所有路由是否正常
    • 布局是否正确嵌套
    • 页面切换是否流畅
    • 模态框是否正常工作

    调试工具:
    • React DevTools查看组件树
    • console.log检查渲染次数
    • Network tab检查请求
    • Next.js终端输出检查警告

常见问题

什么时候应该使用路由组?
当项目文件夹超过50个,或者需要为不同功能区域设置不同根布局时,应该使用路由组。路由组特别适合多团队协作的项目,可以显著降低Git冲突率,让目录结构更清晰。
路由组会影响URL吗?
不会。路由组用圆括号命名(如(marketing)),Next.js会忽略括号名,URL保持不变。例如(marketing)/about/page.js的URL仍然是/about,而不是/marketing/about。
嵌套布局会影响性能吗?
不会,反而会提升性能。Next.js的嵌套布局在子路由切换时不会重新渲染,只有最内层的page.js会重新加载。这意味着你可以在layout中保存状态(如侧边栏滚动位置),这些状态在页面切换时会保持不变。
平行路由和普通组件有什么区别?
平行路由的每个插槽都是独立的页面片段,可以有自己的loading.js和error.js,并行加载互不影响。而普通组件需要等待所有数据加载完成才能渲染,一个组件出错会影响整个页面。
拦截路由的语法怎么选择?
(.)拦截同级路由(如@modal和photos都在app下),(..)拦截上一级路由,(...)从根目录拦截。如果不确定,先用(...)从根拦截,能跑通后再根据目录结构调整。
如何避免路由冲突?
使用路由组按功能划分,确保同一URL不会出现在多个组中。如果确实需要同名路由,可以在URL中添加真实的目录层级(不用括号),或者重命名其中一个路由。
重构大型项目需要多长时间?
根据项目规模而定。中等项目(50-100个页面)可能需要1-2周,大型项目可能需要1个月。建议先选一个模块试水,验证可行后再推广到整个项目,增量迁移风险更小。

23 分钟阅读 · 发布于: 2025年12月18日 · 修改于: 2026年1月22日

评论

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

相关文章