Next.js 是 React 的全栈框架,主打服务端渲染,也就是 SSR(Server Side Rendering)。
它有一套非常强大但也很奇怪的路由机制。
这套路由机制是什么样的?为什么又说很奇怪呢?
我们试一下就知道了。
先创建个 Next.js 项目:
lua
npx create-next-app@latest

执行 create-next-app,输入一些信息,Next.js 项目就创建好了。
进入项目,执行 npm run dev,把它跑起来:

浏览器访问可以看到这个页面,就代表跑成功了:

在项目下可以看到 src 下有个 app 目录,下面有 page.tsx:

我们添加几个目录:

guang/liu/page.tsx
javascript
export default function Liu() {
return <div>666</div>
}
guang/shuai/page.tsx
javascript
export default function Shuai() {
return <div>帅</div>
}
然后浏览器访问下:


可以看到,添加了几个目录,就自动多了几个对应的路由。
这就是 Next.js 的基于文件系统的路由。
刚学了 page.tsx 是定义页面的,那如果多个页面有公共部分呢?
比如这种菜单和导航:

肯定不是每个页面一份。
这种定义在 layout.tsx 里。
app/layout.tsx 是定义最外层的布局:
也就是 html 结构,还有 title、description 等信息:

在网页的 html 源码里可以看到这些:


我们改一下试试:


不止是根路由可以定义 layout,每一级都可以:

javascript
export default function Layout({
children,
}: {
children: React.ReactNode
}) {
return (
<div>
<div>
左侧菜单
</div>
<div>{children}</div>
</div>
)
}
我们给 guang/shuai 这个页面添加了布局,效果是这样的:

Next.js 会自动在 page.tsx 组件的外层包裹 layout.tsx 组件。
有的同学可能会注意到有个渐变背景,这个是 global.css 里定义的,我们把它去掉:


然后继续看:
我们可以使用 Link 组件在不同路由之间导航:


有的同学说,这些都很正常啊。
那接下来看点不那么正常的:
如果我希望定义 /dong/111/xxx/222 (111、222 是路径里的参数)这样的路由的页面呢?
应该如何写?
这样:

javascript
interface Params {
params: {
param1: string;
param2: string;
}
}
export default function Xxx(
{ params }: Params
) {
return <div>
<div>xxx</div>
<div>参数:{JSON.stringify(params)}</div>
</div>
}
路径中的参数的部分使用 [xxx] 的方式命名。
Next 会把路径中的参数取出来传入组件里:

这种叫做动态路由。
那如果我希望 /dong2/a/b/c 和 /dong2/a/d/e 都渲染同一个组件呢?
这样写:

javascript
interface Params {
params: {
dong: string;
}
}
export default function Dong2(
{ params }: Params
) {
return <div>
<div>dong2</div>
<div>参数:{JSON.stringify(params)}</div>
</div>
}
[...dong] 的语法就是用来定义任意层级路由的,叫做 catch-all 的路由。


可以看到,/dong2 下的任意的路由,都会渲染这个组件。
那我直接访问 /dong2 呢?

可以看到,404 了。
但这种也可以支持,再加一个中括号,改成 [[...dong]] 就好了:

这样 /dong2 也会渲染这个组件,只不过参数是空:

这种 [[...dong]] 的路由叫做 optional catch-all。
可以看到,Next.js 项目的目录可不只是单纯的目录,都是有对应的路由含义的。
那如果我就是想加个单纯的目录,不包括在路由里呢?
这样写:

我在 dong 和 dong2 的外层添加了一个 (dongdong) 的目录,那之前的路由会变么?


试了下,依然没变。
也就是说只要在目录名外加上个 (),就不计入路由,只是分组用的,这叫做路由组。
现在,我们一个 layout 下渲染了一个 page。
那如果我想一个 layout 渲染多个 page 呢?
这样写:

guang2 下有 3 个 page,page.tsx、@aaa/page.tsx、@bbb/page.tsx
分别会以 children、aaa、bbb 的参数传入 layout.tsx
layout.tsx
javascript
export default function Layout({
children,
aaa,
bbb
}: {
children: React.ReactNode,
aaa: React.ReactNode,
bbb: React.ReactNode
}) {
return (
<div>
<div>{children}</div>
<div>{aaa}</div>
<div>{bbb}</div>
</div>
)
}
page.tsx
javascript
export default function Page() {
return <div>page</div>
}
@aaa/page.tsx
javascript
export default function Aaa() {
return <div>aaa</div>
}
@bbb/page.tsx
javascript
export default function Bbb() {
return <div>bbb</div>
}
渲染出来是这样的:

可以看到,在 layout 里包含了 3 个 page 的内容,都渲染出来了,这种叫做平行路由。
有的同学会问,那 /guang2/@aaa 可以访问么?

是不可以的。
此外,Next.js 还有一个很强大的路由机制:
之前有这样一个路由:

我们在它平级定义个路由:

javascript
import Link from "next/link";
export default function Ccc() {
return <div>
<div>
<Link href="/guang/liu">to 666</Link>
</div>
<div>ccc</div>
</div>
}
点击链接跳转到 /guang/liu

这没啥问题。
但如果我在 ccc 下加一个 (..)liu 的目录:

这时候再试一下:

可以看到,这次渲染的 Liu 组件就被替换了,但要是刷新的话还是之前的组件。
很多同学会有疑惑,这个有啥用?
举个场景的例子就明白了。
比如一个表格,点击每个条目,都会跳出编辑弹窗,这个编辑页面可以分享,分享出去打开的是完整的编辑页面。
再比如登录,一些页面点击登录会弹出登录弹窗,但如果把这个登录链接分享出去,打开的是完整的登录页面。
也就是说在不同场景下,可以重写这个 url 渲染的组件,这个就是拦截路由的用处。
用法也很简单,因为要拦截的是上一级的 /guang/liu 的路由,所以前面就要加一个 (..)

同理,还有 (.)xx 代表拦截当前目录的路由,(..)(..)xx 拦截上一级的上一级的路由,(...)xxx 拦截根路由。
这个拦截路由,在特定场景下很有用。
这些就是页面相关的路由机制,是不是还挺强大的?
当然,这些路由机制不只是页面可以用,Next.js 还可以用来定义 Get、Post 等接口。
只要把 page.tsx 换成 route.ts 就好了:

javascript
import { NextResponse, type NextRequest } from 'next/server'
const data: Record<string, any> = {
1: {
name: 'guang',
age: 20
},
2: {
name: 'dong',
age: 25
}
}
export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url)
const id = searchParams.get('id');
return NextResponse.json(!id ? null : data[id])
}
我们定义了 /guang3 的路由对应的 get 方法,根据 id 来取数据。
访问下:


前面学的那些路由,都可以用来 route.ts 上。
比如这样:

[id] 定义动态路由参数,而 [...yyy] 是匹配任意的路由。
route.ts 的 GET 方法里,同样是通过 params 来取:
javascript
import { NextResponse, type NextRequest } from 'next/server'
interface Params {
params: {
id: string;
yyy: string;
}
}
export async function GET(request: NextRequest, {
params
}: Params) {
return NextResponse.json({
id: params.id,
yyy: params.yyy
})
}

感受到为啥 Next.js 被叫做全栈框架,而不是 SSR 框架了么?
因为它除了可以用来渲染 React 组件外,还可以定义接口。
这样,我们就把 Next.js 的路由机制过了一遍。
这种路由机制叫做 app router,也就是最顶层是 app 目录:

之前还有种 page router,最顶层目录是 page。
这俩只不过是两种文件、目录命名规则而已,我们只学 app router 就好了,它是最新的路由机制。
总结下我们学了什么:
aaa/bbb/page.tsx 可以定义 /aaa/bbb 的路由。
aaa/[id]/bbb/[id2]/page.tsx 中的 [id] 是动态路由参数,可以在组件里取出来。
aaa/[...xxx]/page.tsx 可以匹配 /aaa/xxx/xxx/xxx 的任意路由,叫做 catch-all 的动态路由。但它不匹配 /aaa
aaa/[[...xxx]]/page.tsx 同上,但匹配 /aaa,叫做 optional catch-all 的动态路由。
aaa/(xxx)/bbb/page.tsx 中的 (xxx) 只是分组用,不参与路由,叫做路由组
aaa/@xxx/page.tsx 可以在 layout.tsx 里引入多个,叫做平行路由
aaa/(..)/bbb/page.js 可以拦截 /bbb 的路由,重写对应的组件,但是刷新后依然渲染原组件,叫做拦截路由。
这些路由机制确实看起来挺奇怪的,它会导致 Next.js 的项目看起来这样:

相比这种基于文件系统的路由,大家可能更熟悉 React Router 那种编程式路由:

Next.js 这种声明式的路由其实熟悉了还是很方便的。
不需要单独再维护路由了,目录就是路由,一目了然。
而且这些看似奇怪的语法,细想一下也很正常:
比如 [xxx],一般匹配 url 中的参数都是这种语法。
而 [...xxx] 只是在其中加个一个 ...,这个 ... 在 js 里就是任意参数的意思,所以用来匹配任意路由。
再加一个中括号 [[...xxx]] 代表可以不带参数,这个也是很自然的设计。
(.)xx、(..)xxx 这里的 . 和 .. 本来就是文件系统里的符号,用来做拦截路由也挺自然的。
路由组 (xxx) 加了个括号来表示分组,平行路由 @xxx 加了 @ 来表示可以引入多个 page,都是符合直觉的设计。
所以说,Next.js 基于文件系统实现这套路由机制,用的这些奇怪的语法,其实都是挺合理的设计。
总结
我们学习了 Next.js 的路由机制,它是基于文件系统来定义接口或页面的路由。
Next.js 的路由机制挺强大的,支持的功能很多。
比如这样:

有动态路由参数 [xx]、catch-all 的动态路由 [...xxx]、optional catch-all 的动态路由 [[...xxx]]、路由组 (xxx)、平行路由 @xxx、拦截路由 (..)xxx。
这些语法乍看比较奇怪,但是细想一下,都是挺合理的设计。