Next.js 开发指南 路由篇 | 动态路由、路由组、平行路由和拦截路由
前言
实际项目开发的时候,有的路由场景会比较复杂,比如数据库里的文章有很多,我们不可能一一去定义路由,此时该怎么办?组织代码的时候,有的路由是用于移动端,有的路由是用于 PC 端,该如何组织?如何有条件的渲染页面,比如未授权的时候显示登录框?如何让同一个路由根据情况不同展示不同的内容?
本篇我们会一一解决这些问题,在此篇,你将会感受到 App Router 强大的路由功能。
1. 动态路由(Dynamic Routes)
有的时候,你并不能提前知道路由的地址,就比如根据 URL 中的 id 参数展示该 id 对应的文章内容,文章那么多,我们不可能一一定义路由,这个时候就需要用到动态路由。
1.1. [folderName]
使用动态路由,你需要将文件夹的名字用方括号括住,比如 [id]
、[slug]
。这个路由的名字会作为 param
prop 传给布局(layout)
、 页面(page)
、 路由处理程序(route)
以及 generateMetadata(用于生成页面元数据)
函数。
举个例子,我们在 app/blog
目录下新建一个名为 [slug]
的文件夹,在该文件夹新建一个 page.js
文件,代码如下:
// app/blog/[slug]/page.js
export default function Page({ params }) {
return <div>My Post: {params.slug}</div>
}
效果如下:
当你访问 /blog/a
的时候,params
的值为 { slug: 'a' }
。
当你访问 /blog/yayu
的时候,params
的值为 { slug: 'yayu' }
。
以此类推。
1.2. [...folderName]
在命名文件夹的时候,如果你在方括号内添加省略号,比如 [...folderName]
,这表示捕获所有后面所有的路由片段。
也就是说,app/shop/[...slug]/page.js
会匹配 /shop/clothes
,也会匹配 /shop/clothes/tops
、/shop/clothes/tops/t-shirts
等等。
举个例子,app/shop/[...slug]/page.js
的代码如下:
// app/shop/[...slug]/page.js
export default function Page({ params }) {
return <div>My Shop: {JSON.stringify(params)}</div>
}
效果如下:
当你访问 /shop/a
的时候,params
的值为 { slug: ['a'] }
。
当你访问 /shop/a/b
的时候,params
的值为 { slug: ['a', 'b'] }
。
当你访问 /shop/a/b/c
的时候,params
的值为 { slug: ['a', 'b', 'c'] }
。
以此类推。
1.3. [[...folderName]]
在命名文件夹的时候,如果你在双方括号内添加省略号,比如 [[...folderName]]
,这表示可选的捕获所有后面所有的路由片段。
也就是说,app/shop/[[...slug]]/page.js
会匹配 /shop
,也会匹配 /shop/clothes
、 /shop/clothes/tops
、/shop/clothes/tops/t-shirts
等等。
它与上一种的区别就在于,不带参数的路由也会被匹配(就比如 /shop
)
举个例子,app/shop/[[...slug]]/page.js
的代码如下:
// app/shop/[[...slug]]/page.js
export default function Page({ params }) {
return <div>My Shop: {JSON.stringify(params)}</div>
}
效果如下:
当你访问 /shop
的时候,params 的值为 {}
。
当你访问 /shop/a
的时候,params 的值为 { slug: ['a'] }
。
当你访问 /shop/a/b
的时候,params 的值为 { slug: ['a', 'b'] }
。
当你访问 /shop/a/b/c
的时候,params 的值为 { slug: ['a', 'b', 'c'] }
。
以此类推。
2. 路由组(Route groups)
在 app
目录下,文件夹名称通常会被映射到 URL 中,但你可以将文件夹标记为路由组,阻止文件夹名称被映射到 URL 中。
使用路由组,你可以将路由和项目文件按照逻辑进行分组,但不会影响 URL 路径结构。路由组可用于比如:
- 按站点、意图、团队等将路由分组
- 在同一层级中创建多个布局,甚至是创建多个根布局
那么该如何标记呢?把文件夹用括号括住就可以了,就比如 (dashboard)
。
举些例子:
2.1. 按逻辑分组
将路由按逻辑分组,但不影响 URL 路径:
你会发现,最终的 URL 中省略了带括号的文件夹(上图中的(marketing)
和(shop)
)。
2.2. 创建不同布局
借助路由组,即便在同一层级,也可以创建不同的布局:
在这个例子中,/account
、/cart
、/checkout
都在同一层级。但是 /account
和 /cart
使用的是 /app/(shop)/layout.js
布局和app/layout.js
布局,/checkout
使用的是 app/layout.js
2.3. 创建多个根布局
创建多个根布局:
创建多个根布局,你需要删除掉 app/layout.js
文件,然后在每组都创建一个 layout.js
文件。创建的时候要注意,因为是根布局,所以要有 <html>
和 <body>
标签。
这个功能很实用,比如你将前台购买页面和后台管理页面都放在一个项目里,一个 C 端,一个 B 端,两个项目的布局肯定不一样,借助路由组,就可以轻松实现区分。
再多说几点:
- 路由组的命名除了用于组织之外并无特殊意义。它们不会影响 URL 路径。
- 注意不要解析为相同的 URL 路径。举个例子,因为路由组不影响 URL 路径,所以
(marketing)/about/page.js
和(shop)/about/page.js
都会解析为/about
,这会导致报错。 - 创建多个根布局的时候,因为删除了顶层的
app/layout.js
文件,访问/
会报错,所以app/page.js
需要定义在其中一个路由组中。 - 跨根布局导航会导致页面完全重新加载,就比如使用
app/(shop)/layout.js
根布局的/cart
跳转到使用app/(marketing)/layout.js
根布局的/blog
会导致页面重新加载(full page load)。
3. 平行路由(Parallel Routes)
平行路由可以使你在同一个布局中同时或者有条件的渲染一个或者多个页面(类似于 Vue 的插槽功能)。
3.1. 条件渲染
举个例子,在后台管理页面,同时展示团队(team)和数据分析(analytics)页面:
平行路由的使用方式就是将文件夹以 @
作为开头进行命名,这个文件夹下面的 page.js
将会自动注入文件夹同级 layout
的 props 中。
注:从这张图还可以看出,children
prop 其实就是一个隐式的插槽,/app/page.js
相当于 app/@children/page.js
。
除了让它们同时展示,你也可以根据条件判断展示:
在这个例子中,在布局中获取用户的登录状态,如果登录,显示 dashboard,没有登录,显示 login。这样做的一大好处就在于代码完全分离。
3.2. 独立错误处理和加载
平行路由可以让你为每个路由定义独立的错误处理和加载界面:
此外,平行路由跟路由组一样,不会影响 URL。比如 /@team/members
对应的地址是 /members
。
3.3. 新约定文件 default.js
为了让大家更好的理解平行路由,我们写一个示例代码。项目结构如下:
app
├─ layout.js
├─ page.js
├─ about
│ └─ page.js
├─ @team
│ ├─ page.js
│ └─ member
│ └─ page.js
├─ @analytics
└─ page.js
其中 app/layout.js
代码如下:
// app/layout.js
export default function RootLayout({ children, team, analytics }) {
return (
<html>
<body>
<h1>root layout</h1>
{children}
{team}
{analytics}
</body>
</html>
)
}
app/page.js
代码如下:
// app/page.js
export default function Page() {
return <h1>Hello, App!</h1>
}
app/@team/page.js
代码如下:
// app/@team/page.js
export default function Page() {
return <h1>Hello, Team!</h1>
}
app/@analytics/page.js
代码如下:
// app/@analytics/page.js
export default function Page() {
return <h1>Hello, Analytics!</h1>
}
此时访问 /
,效果如下:
app/about/page.js
代码如下:
// app/about/page.js
export default function Page() {
return <h1>Hello, About!</h1>
}
此时访问 /about
,效果如下:
结果出现了 404 错误。因为路由匹配,此时根布局里 team
和 anaylytics
都为空。
为了解决这一问题,Next.js 添加了 default.js
文件,在 @team
和 @anaylytics
下都添加一个 default.js
:
// app/@team/default.js
export default function Page() {
return <h1>Hello, Team Default!</h1>
}
// app/@anaylytics/default.js
export default function Page() {
return <h1>Hello, Analytics Default!</h1>
}
此时访问 /
跟以前一样,访问 /about
则会出现:
3.4. 用途:实现 Modal
在实际开发中,平行路由可以用于渲染弹窗(Modal)。
我们想要实现的效果是,当跳转到 /login
的时候,渲染 Modal。
写个示例代码。项目目录如下:
app
├─ layout.js
├─ page.js
└─ @auth
├─ page.js
├─ default.js
└─ login
└─ page.js
app/layout.js
代码如下:
// app/layout.js
import './globals.css';
import Link from 'next/link'
export default function RootLayout({ children, auth }) {
return (
<html>
<body>
<div><Link href="/login">Open Auth Modal</Link></div>
<div><Link href="/">Back To Home</Link></div>
<h1>/app/layout.js</h1>
{children}
{auth}
</body>
</html>
)
}
app/page.js
代码如下:
// app/page.js
export default function Page() {
return <h1>/app/page.js</h1>
}
如果没有 @auth
下的代码,此时访问 /
,效果应该是:
考虑到我们写的是一个 Modal 效果,当我们访问 /
的时候,Modal 应该是不被渲染的。当我们访问其他地址如/about
的时候,Modal 也不应该被渲染。所以app/@auth/page.js
和 app/@auth/default.js
都应该 return
一个 null
。
两个文件代码如下:
// app/@auth/page.js
export default function Page() {
return null
}
// app/@auth/default.js
export default function Default() {
return null
}
app/@auth/login/page.js
代码如下:
'use client'
// app/@auth/login/page.js
import { useRouter } from 'next/navigation'
export default function Page() {
const router = useRouter()
return (
<div style={{width: '200px', height: '100px', backgroundColor: "red", position: "fixed", top: "20px", left: "220px"}}>
<span onClick={() => router.back()}>Close Modal</span>
<h1>Modal Content</h1>
</div>
)
}
最终效果如下:
当我们点击 Open Auth Modal
的时候,路由跳转 /login
,显示弹窗。点击弹窗里的 Close Modal
,路由跳回 /
,弹窗关闭。点击 Back To Home
,从 /login
跳到 /
,弹窗也会关闭。
之所以能实现这样一个功能,借助的就是平行路由的功能。当跳转到 /login
的时候,app/@auth/login/page.js
会作为 app/layout.js
中的 auth 参数传入,于是展示了弹窗。当跳转到 /
的时候,展示 app/@auth/page.js
,此时 return null,所以关闭了弹窗。
但是你可能发现一个问题,那就是当我们刷新 /login
页面的时候,会出现 404 错误。刷新后的结果如下:
为什么会出现这样一个内容呢?
经过排查,这个 404 提示来自于 app/layout.js
中的 children。如果你把 {children}
这行代码删除,就不会展示这个错误。
...
export default function RootLayout({ children, auth }) {
return (
...
<h1>/app/layout.js</h1>
{children}
{auth}
...
)
}
其实你把 children 理解为另外一个插槽就方便理解了。/app/page.js
相当于 app/@children/page.js
。
当访问 /login
的时候,只匹配了 /@auth/login/page.js
这个插槽,但是 /@children/page.js
就没有匹配到了。
当重新刷新的时候,Next.js 会首先尝试渲染不匹配插槽的 default.js
文件,如果不可用,再渲染 404。
所以解决这个问题也很简单,在 app
下新建一个 default.js
文件,也 return null 就可以了:
export default function Default() {
return null
}
此时再刷新 /login
页面,就没有 404 错误了:
4. 拦截路由(Intercepting Routes)
拦截路由允许你在当前布局内加载应用其他部分的路由。
4.1 效果展示
让我们直接看个案例,打开 dribbble.com 这个网站,你可以看到很多美图:
现在点击任意一张图片:
此时页面弹出了一层 Modal,Modal 中展示了该图片的具体内容。如果你想要查看其他图片,点击右上角的关闭按钮,关掉 Modal 即可继续浏览。值得注意的是,此时路由地址也发生了变化,它变成了这张图片的具体地址。如果你喜欢这张图片,直接复制或者分享当前的地址给朋友即可。
而当你的朋友打开时,其实不再需要以 Modal 的形式展现,直接展示这张图片的具体内容即可。现在刷新下该页面,你会发现页面的样式不同了:
在这个样式里没有 Modal,就是这张图片的内容。
你看同样一个路由地址,却展示了不同的内容。这就是拦截路由的效果。如果你在 dribbble.com
想要访问 dribbble.com/shots/xxxxx
,此时会拦截 dribbble.com/shots/xxxxx
这个路由地址,以 Modal 的形式展现。而当直接访问 dribbble.com/shots/xxxxx
时,则是原本的样式。
示意图如下:
这是另一个拦截路由的 Demo 演示:nextjs-app-route-interception.vercel.app/
4.2 实现方式
那么这个效果该如何实现呢?在 Next.js 中,实现拦截路由需要你在命名文件夹的时候以 (..)
开头,其中:
(.)
表示匹配同一层级(..)
表示匹配上一层级(..)(..)
表示匹配上上层级。(...)
表示匹配根目录
但是要注意的是,这个匹配的是路由的层级而不是文件夹的层级,就比如路由组、平行路由这些不会影响 URL 的文件夹就不会被计算层级。
看个例子:
/feed/(..)photo
对应的路由是 /feed/photo
,要拦截的路由是 /photo
,两者只差了一个层级,所以使用 (..)
。
我们写个 demo 来实现这个效果,目录结构如下:
app
├─ layout.js
├─ page.js
├─ data.js
├─ default.js
├─ @modal
│ ├─ default.js
│ └─ (.)photo
│ └─ [id]
│ └─ page.js
└─ photo
└─ [id]
└─ page.js
每个文件代码都很简单。先 Mock 一下图片的数据,app/data.js
代码如下:
export const photos = [
{id: '1', src: "http://placekitten.com/200/200"},
{id: '2', src: "http://placebear.com/200/200"}
]
app/page.js
代码如下:
import Link from 'next/link'
import {photos} from './data';
export default function Home() {
return (
<main className="container">
{photos.map(({ id, src }) => (
<Link key={id} href={`/photo/${id}`}>
<img width="100" src={src} />
</Link>
))}
</main>
)
}
app/layout.js
代码如下:
export default function Layout({ children, modal }) {
return (
<html>
<body>
{children}
{modal}
</body>
</html>
)
}
此时访问 /
,效果如下(图片就 2 张,你就假设这是个图片列表……):
现在我们再来实现下单独访问图片地址时的效果:
app/photo/[id]/page.js
代码如下:
import {photos} from '../../data';
export default function PhotoPage({ params: { id } }) {
const photo = photos.find((p) => p.id === id)
return (
<img style={{width: '50%', display: 'block', marginLeft: 'auto', marginRight: 'auto'}} src={photo.src} />
)
}
访问 /photo/1
,效果如下:
现在我们开始实现拦截路由,为了和单独访问图片地址时的样式区分,我们声明另一种样式效果。app/@modal/(.)photo/[id]/page.js
代码如下:
import {photos} from '../../../data';
export default function PhotoModal({ params: { id } }) {
const photo = photos.find((p) => p.id === id)
return (
<div className="modal">
<img style={{width: '200', position: 'fixed', top: '120px'}} src={photo.src} />
</div>
)
}
两个 default.js
的代码都是:
export default function Default() {
return null
}
最终的效果如下:
你可以看到,在 /
路由下,访问 /photo/1
,路由会被拦截,采用 @modal/(.)photo/[id]/page.js
的样式。该示例代码仓库地址为:github.com/mqyqingfeng…
小结
恭喜你,完成了本节内容的学习!
这一节我们介绍了动态路由、路由组、平行路由、拦截路由,它们的共同特点就需要对文件名进行修饰。其中动态路由用来处理动态的链接,路由组用来组织代码,平行路由和拦截路由则是处理实际开发中会遇到的场景问题。平行路由和拦截路由初次理解的时候可能会有些难度,但只要你跟着文章中的 demo 手写一遍,相信你一定能够快速理解和掌握!
- 初始篇 | Next.js CLI
- 路由篇 | App Router
- 路由篇 | 动态路由、路由组、平行路由和拦截路由
- 路由篇 | 路由处理程序和中间件
- 路由篇 | 国际化
- 数据获取篇 | 数据获取、缓存与重新验证
- 数据获取篇 | Server Actions 与表单
- 渲染篇 | 从 CSR、SSR、SSG、ISR 开始说起
- 渲染篇 | 服务端组件和客户端组件
- 渲染篇 | Streaming 和 Edge Runtime
- 缓存篇 | Caching
- 样式篇 | Tailwind CSS、CSS-in-JS 与 Sass
- 组件篇 | Images
- 组件篇 | Font
- 组件篇 | Link 和 Script
- 优化篇 | 懒加载
- 配置篇 | TypeScript 和 ESLint
- 配置篇 | 环境变量、路径别名与 src 目录
- 配置篇 | MDX
- 配置篇 | 草稿模式和内容安全策略
- 配置篇 | 路由段配置项
- 部署篇 | 静态导出
- Metadata 篇 | 基于配置
- Metadata 篇 | 基于文件
- API 篇 | next.config.js(上)
- API 篇 | next.config.js(下)
- API 篇 | 请求相关的常用函数与方法
- API 篇 | 常用函数与方法
- 实战篇 | React Notes | 项目介绍与创建
- 实战篇 | React Notes | 侧边栏笔记列表
- 实战篇 | React Notes | 笔记预览界面
- 实战篇 | React Notes | 笔记编辑界面
- 实战篇 | React Notes | 笔记搜索
- 实战篇 | React Notes | 国际化
- 实战篇 | React Notes | Auth
- 实战篇 | React Notes | 文件上传
- 实战篇 | React Notes | 部署(一)
- 实战篇 | React Notes | 部署(二)
- 实战篇 | 博客 | 项目创建
- 实战篇 | 博客 | 博客后台
- 实战篇 | 博客 | MDX
- 实战篇 | 博客 | Server Actions
- 实战篇 | 博客 | 渲染原理
- 实战篇 | App | 需求分析
- 实战篇 | App | 数据库设计
- 实战篇 | App | 项目创建
- 实战篇 | App | 移动端处理
- 实战篇 | App | 接口开发
- 实战篇 | App | 数据请求
- 实战篇 | App | 构建部署
- 源码篇 | 源码架构
- 源码篇 | 调试代码
- 源码篇 | 路由实现
- 源码篇 | 渲染原理
- 源码篇 | 手写 SSR
- 源码篇 | mini-next
- 源码篇 | mini-next
- 源码篇 | mini-next
- 源码篇 | mini-next
- 面试篇 | 常见面试题及解析
- 面试篇 | 常见面试题及解析
- 面试篇 | 常见面试题及解析