🍂落页
登 录

Next.js 笔记

  • 安装及项目结构
  • 路由
  • 获取数据
  • 渲染
  • 缓存
  • 优化
🍂落页
TALAXY
路由
🍂落页
TALAXY

路由

路由
  • 页面和布局
  • 链接及导航
  • 路由组
  • 动态路由
  • 加载界面及流式渲染
  • 错误处理
  • 平行路由
  • 拦截路由
  • 接口路由
  • 中间件
  • 项目组织
  • 国际化
  • 参考

每个应用最基础的部分就是路由。

从 13 版本开始,Next.js 推出了基于 React 服务端组件的 App Router 框架。它作用于根目录的 app 目录里,且该目录下的组件默认均为服务端组件(也可为客户端组件)。

在 app 目录下,每个文件夹对应一个 路由片段 ,同时每个路由片段对应一个 URL 路径中的片段。嵌套的文件夹会生成嵌套的路由。

Next.js 约定了一些文件名(可以是 .js .ts .tsx),用来生成对应的 UI 组件,如:

  • layout 与子路由共享的 UI
  • template 会被强制渲染的 UI
  • page 当前路由对应的页面,会内嵌在 layout 里
  • loading 当前路由及其子路由的加载界面,充当 Suspense 组件
  • not-found 当前路由及其子路由的“未找到”界面,充当错误边界
  • error 当前路由及其子路由的错误界面,充当错误边界
  • global-error 全局错误界面
  • route 服务端 API 接口

仅当存在 page 或 route 文件时才会生成路由,可以利用这一特性创建一些私有的目录或文件。

对应的页面结构大致如下:

<Layout>
    <Template>
        <ErrorBoundary fallback={<Error />}>
            <Suspense fallback={<Loading />}>
                <ErrorBoundary fallback={<NotFound />}>
                    <Page />
                </ErrorBoundary>
            </Suspense>
        </ErrorBoundary>
    </Template>
</Layout>

子路由页面会直接内嵌到父路由之下,但 page 只会应用于当前路由。

页面和布局

page

page 只会应用于一个对应的路由,且永远是整个页面组件的叶子节点。

export default function Page() {
    return <h1>Hello, Home page!</h1>;
}

layout

layout 可以在多个 page 中共用。在切换导航时,layout 会保持自身状态,不会重新渲染。

// `children` 可以是 page ,或者是内嵌 layout
export default function Layout({ children }) {
    return (
        <section> {/* 通常放一些共用组件,比如导航栏或者侧边栏 */}
            <nav>...</nav>
            {children}
        </section>
    )
}

一个 layout 会在当前路由及其子路由中应用,父布局会直接嵌套子布局。

app 目录下 必须 定义一个 layout ,且称为 根 layout ,通常用来定义初始的 HTML 模版:

export default function RootLayout({ children }) {
    return (
        <html lang="en">
            <body>{children}</body>
        </html>
    )
}

仅能(且必须)在根目录的 layout 中定义 html 和 body 标签。

template

template 功能与 layout 类似,不同在于切换路由时,template 组件会重新构建,即状态不会保留,比如这些场景:

  • 一些依赖于 useEffect(如页面日志)或者 useState 的功能;
  • 用来改变原先的框架行为。比如在没有 template 时,Suspense 只会在第一次展示回退界面,切换页面时则不会重新展示;而加了 template 则会在每次切换页面前展示回退界面。
export default function Template({ children }) {
    return <div>{children}</div>
}

template 会直接内嵌在 layout 之下:

<Layout>
    {/* 注意 template 会被赋予一个唯一的 key */}
    <Template key={routeParam}>{children}</Template>
</Layout>

修改元数据

可以在 layout 或 page 文件里导出一个 metadata 对象或者 generateMetadata 函数来定义网页的元数据:

import { Metadata } from 'next'
 
export const metadata: Metadata = {
    title: 'Next.js',
}
 
export default function Page() {/* ... */}

应当通过上述方式来修改网页的元数据,而不是 在根 layout 中添加 title 或者 meta 等头部标签。

链接及导航

Next.js 中可以通过 Link 组件或 useRouter 实现路由导航。

Link 组件

Link 组件是 Next.js 中主要的导航方式。它从 a 标签上扩展而来,支持客户端的路由导航以及预加载功能。

import Link from 'next/link'
 
export default function Page() {
    return <Link href="/dashboard">Dashboard</Link>
}

可以用 usePathname 获取当前 URL 的路径。

如果想在导航时滚动到指定 id ,可以直接在 URL 链接里加 # hash 链接(也可直接将 hash 链接作为 href 值):

<Link href="/dashboard#settings">Settings</Link>

在 Next.js 的默认行为中,切到一个新路由时会停留在页面的最顶部,而在导航前进后退操作时会保留原本的页面滚动位置。如果要禁用这一默认行为,可以在 Link 中传递 scroll={false} ,也可在 router.push() 或 router.replace() 中使用 scroll: false 选项:

<Link href="/dashboard" scroll={false}>Dashboard</Link>
import { useRouter } from 'next/navigation'
 
const router = useRouter()
router.push('/dashboard', { scroll: false })

useRouter

useRouter 可以用来手动切换路由。它只能用在客户端组件中:

'use client'
 
import { useRouter } from 'next/navigation'
 
export default function Page() {
    const router = useRouter()
    
    return (
        <button onClick={() => router.push('/dashboard')}>
            Dashboard
        </button>
    )
}

应当优先选择使用 Link 组件,除非有特定需求再去使用 useRouter 。

路由导航的工作原理

App Router 采用了混合的方式去实现路由和导航。对于服务端,应用代码自动地根据路由做了代码拆分。而对于客户端,Next.js 会预加载并缓存路由片段代码,这意味着当切换路由时,浏览器并不会重载页面,只有相应的路由片段会被重新渲染。

在 Next.js 中,有两种方式可以实现路由的预加载:

  • Link 组件会自动进行路由的预加载:
    • 对于静态路由,prefetch 默认为 true ,它会对整个路由片段代码进行预加载;
    • 对于动态路由,prefetch 默认为自动,只有从共用的 layout 到第一个 loading 中的 Link 是被预加载的,并会缓存 30 秒;
    • 如果要禁用预加载,可以将 prefetch 设为 false 。
  • 通过 router.prefetch() 来手动进行路由预加载。

App Router 的导航还有这些特性:

  • 缓存 - 在切换路由时,Next.js 会预加载并尽可能地缓存路由代码,这样可以减少请求次数;
  • 部分渲染 - 在切换导航时,只有路由片段会被重新渲染,共用的部分会保留原本状态;
  • 软导航 - 默认情况下浏览器会在导航时重新加载页面,这会导致所有 React 状态被重置。而在 Next.js 中,App Router 会采取软导航,即会保留浏览器和 React 的状态,并不会整个页面重新加载;
  • 前进后退 - Next.js 会缓存页面的滚动位置,它会在导航前进后退时还原到页面原先的滚动位置。

路由组

在 app 目录下,内嵌的文件夹通常会被映射为 URL 路径片段。但有的时候我们仅想让文件夹起到分类路由的作用,并不想让文件夹成为 URL 路径片段,可以将文件夹名以 (folder) 的形式命名。在 Next.js 中称其为 路由组 Route Group 。

路由组里同样可以有 layout 等文件,它会对组下的所有路由应用。

路由组可能会产生路由解析的歧义。比如 (marketing)/about/page.js 和 (shop)/about/page.js 均代表一个 /about 路由,此时 Next.js 会报错。所以在使用路由组时应当避免这种情形发生。

路由组的使用可能会产生多个根 layout ,当在不同的根 layout 间进行导航时会导致整个页面重新加载。

动态路由

有时候需要根据动态数据去创建路由。可以将文件夹名以 [folder] 形式命名,表示它是一个动态路由片段,比如 [id] 或 [slug] 。动态路由片段会作为参数传给 layout page route 和 generateMetadata 函数。

比如有一个 app/blog/[slug]/page.tsx 文件:

interface Props { params: { slug: string } }

export default function Page({ params }: Props) {
    return <div>My Post: {params.slug}</div>
}

生成静态参数

generateStaticParams 可以和动态路由片段搭配使用,它会在构建时获取参数列表并直接生成静态路由。

export async function generateStaticParams() {
    const posts = await fetch('https://.../posts').then((res) => res.json())
    
    return posts.map((post) => ({
        slug: post.slug,
    }))
}

generateStaticParams 会对其中的 fetch 请求进行缓存,如果别的文件里也在 generateStaticParams 中使用了相同的请求,会直接使用先前缓存的数据。

剩余片段捕获

可以用 [...folder] 的命名表示捕获路由中剩余片段。比如对于 app/shop/[...slug]/page.js :

  • /shop/a 生成的参数为 { slug: ['a'] }
  • /shop/a/b/c 生成的参数为 { slug: ['a', 'b', 'c'] }

或者用 [[...folder]] 表示剩余片段可空。比如对于 app/shop/[[...slug]]/page.js :

  • /shop 生成的参数为 {}
  • /shop/a/b 生成的参数为 { slug: ['a', 'b'] }

加载界面及流式渲染

loading.js 文件会搭配 React 的 Suspense 组件生成加载界面。当主体内容还未加载好时,服务端会先提供加载界面。

在 Next.js 中,导航行为有这些特点:

  • 导航行为会立即执行;
  • 导航行为是可中断的,这意味着切回路由时不必等待当前路由内容完全加载完;
  • 共用的 layout 会依然保持可交互的状态。

流式渲染

在用户看到页面前,服务端渲染大致会有以下这些步骤,且步骤间是有序且阻塞的:

  1. 服务器会先获取页面所需的数据;
  2. 服务器渲染 HTML 页面;
  3. 将 HTML/CSS/JS 文件发送给客户端;
  4. 会先给用户展示不可交互的 HTML/CSS 页面;
  5. 最终,React 会通过 hydrate 使用户界面可交互。

虽然服务端渲染会先将不可交互的界面展示给用户,但可能会卡在最先的数据请求的步骤上。

而 流式渲染 Streaming 会将页面 HTML 拆分为更小的块并逐个发送给客户端,并且在服务端获取数据前先将部分页面内容发送给客户端。

流式渲染的分块机制是组件级别的,即每个 React 组件都可以作为一个分块。一些不依赖数据或者需要优先展示的组件会先发送给客户端,同时 React 也将更早进行 hydrate 。而其余组件将会等待服务端获取完数据后再发送给客户端。

在有大量数据请求的页面下,流式渲染会显得非常有用,它会减少 首次字节时间 TTFB 和 首屏绘制时间 FCP 。可交互时间 TTI 也有一定改善,尤其在性能较差的设备上。

要使用流式渲染相关特性,可以使用 Suspense 组件,它会有如下优化:

  1. 流式服务端渲染 - 将 HTML 分块发送给客户端。
  2. 选择性 hydrate - React 会基于用户交互去抉择先将哪些组件变得可交互。

SEO

TODO: 需要后期理解。

  • Next.js 会先等待 generateMetadata 中的请求,然后才会将页面流式传输给客户端,它保证了 head 标签会先被传输过去;
  • 流式渲染属于服务端渲染,并不会影响 SEO 。可以通过谷歌的 移动端友好性测试 来查看页面在谷歌网页爬虫下的显示情况,或者查看序列化后的 HTML 。
  • 流式渲染时会返回 200 状态码,当碰到错误时并不能回头修改状态码,但会在 HTML 头部将错误信息告知给 SEO 。

错误处理

在 App Router 中,可以分层地建立 error.js 来设置错误边界。若页面内某个组件触发了错误,会原地向外寻找最近的错误边界,同时错误边界外的内容及其状态并不会受到影响,在恢复错误时也不会直接将整个页面刷新。

'use client' // 错误组件必须是客户端组件
 
import { useEffect } from 'react'
 
export default function Error({ error, reset }) {
    useEffect(() => {
        // 记录错误...
        console.error(error)
    }, [error])
    
    return (
        <div>
            <h2>Something went wrong!</h2>
            {/* 尝试内容重新渲染 */}
            <button onClick={() => reset()}>Try again</button>
        </div>
    )
}

处理根 layout 的错误

注意,error 会内嵌在同层的 layout 里,因此 layout 的错误并不会触发同层的 error 。如果要给根 layout 设置错误边界,可以建立 global-error 文件。global-error 会包裹整个应用组件,因此跟根 layout 一样,global-error 的回退内容应当包含 html 和 body 标签。

处理服务端错误

如果一个错误是从服务端组件引起的,则会将该错误作为 error 参数传递给最近的 error.js 。

在生产环境中,只会往错误边界传递一个简单的 message 和 digest 信息,不会向用户暴露更多信息。digest 会包含一个自动生成的该错误的哈希编码,它会与服务端日志中的一条错误相匹配。

在开发环境中,会直接传递原错误对象,方便调试。

平行路由

平行路由 Parallel Routing 可让你在同一 layout 中放置多个 page 。可以在 layout 同级目录下建立 @folder 文件夹(也称之为 插槽 slot ),插槽中同样可以有 page error 等文件。而在 layout 中可以直接使用插槽:

import { getUser } from '@/lib/auth';

export default function Layout(props: {
    dashboard: React.ReactNode  // 对应 `@dashboard` 内容
    login: React.ReactNode      // 对应 `@login` 内容
}) {
    const isLoggedIn = getUser();

    // 根据用户登录状态区分渲染的内容
    return isLoggedIn ? dashboard : login;
}

不匹配的路由

TODO:原文档似乎写得有问题,暂时搁置。

插槽下也可以建立子文件,用于匹配子路由。如果 Next.js 未找到对应的子路由页面,则会使用插槽目录下的 default 文件。如果仍未找到 default 文件,则会渲染 404 页面。

在导航切换时,若 Next.js 未在插槽中找到对应的 page ,则保持渲染先前的状态,而不会渲染 default 文件。如果想强制更换渲染的内容,可以用剩余捕获路由,比如 @teams/[...catchAll]/page.js 。

拦截路由

拦截路由可以让你在当面路由页面中加载另一个路由下的内容。可在目标路由下建立这些拦截路由:

  • (.)folder 在同层路由下设置拦截
  • (..)folder 在上层路由下设置拦截
  • (..)(..)folder 在上上层路由下设置拦截
  • (...)folder 在根路由下设置拦截

如下面这个例子中,如果用户是通过 /feed 访问了 /photo ,Next.js 则会渲染 (..)photo 中的内容。如果用户是直接访问了 /photo 则直接渲染 photo 中的内容:

+ feed
    - page.js
    + (..)photo
        - page.js
+ photo
    - page.js
- page.js

接口路由

可以通过 router.js 文件来创建 Route Handler :

export const dynamic = 'force-dynamic' // 默认为 "force-static"
export async function GET(request: Request) {}

支持定义 GET POST PUT PATCH DELETE HEAD OPTIONS 方法。如果使用了 Next.js 未支持的 HTTP 方法,会返回 405 Method Not Allowed 响应。

Next.js 扩展了原生的 Request 和 Response 对象为 NextRequest 和 NextResponse ,提供了许多有用的方法。

缓存

当使用 GET 方法时,可以通过 Response 对象来告知 Route Handler 进行缓存:

export async function GET() {
    const res = await fetch('https://data.mongodb-api.com/...', {
        headers: {
            'Content-Type': 'application/json',
            'API-Key': process.env.DATA_API_KEY,
        },
    })
    const data = await res.json()
    return Response.json({ data })
}

Response.json() 只能在高于 5.2 版本的 TypeScript 中使用,或者可以使用 NextResponse.json() 。

若不想使用缓存机制,可以通过这几种方式:

  • 对于 GET 方法使用 Request 对象;
  • 使用其他任何 HTTP 方法;
  • 使用 cookies 或 headers 等 动态函数 ;
  • 通过 路由片段配置 将接口设为动态模式。

路由解析

同层目录下 route 不能与 page 共存。比如 app/page.js 不能与 app/route.js 共存。

app/[user]/page.js 与 app/api/route.js 是可以共存。

重新验证缓存数据

可以在 fetch 请求中通过 next.revalidate 选项来设置重新验证时间间隔:

export async function GET() {
    const res = await fetch('https://data.mongodb-api.com/...', {
        next: { revalidate: 60 }, // Revalidate every 60 seconds
    })
    // ...
}

或者利用路由片段配置,直接在文件中导出 revalidate 配置:

export const revalidate = 60

动态函数

cookie

可以在 Route Handler 中通过 cookies() 访问 cookie ,它会返回一个只读的实例。如果要设置 cookie ,可以在 Response 中设置 Set-Cookie 标头:

import { cookies } from 'next/headers'
 
export async function GET(request: Request) {
    const cookieStore = cookies()
    const token = cookieStore.get('token')
    
    return new Response('Hello', {
        status: 200,
        headers: { 'Set-Cookie': `token=${token.value}` },
    })
}

或者通过标明 NextRequest 类型来直接访问 cookie :

import { type NextRequest } from 'next/server'
 
export async function GET(request: NextRequest) {
    const token = request.cookies.get('token')
}

标头

可以在 Route Handler 中通过 headers() 直接获取请求标头。同样返回只读的实例:

import { headers } from 'next/headers'
 
export async function GET(request: Request) {
    const headersList = headers()
    const referer = headersList.get('referer')
    
    return new Response('Hello, Next.js!', {
        status: 200,
        headers: { referer: referer },
    })
}

或者通过标明 NextRequest 类型来直接访问 headers :

import { type NextRequest } from 'next/server'
 
export async function GET(request: NextRequest) {
    const requestHeaders = new Headers(request.headers)
}

重定向

import { redirect } from 'next/navigation'
 
export async function GET(request: Request) {
    redirect('https://nextjs.org')
}

动态路由片段

同 layout 等一致,Route Handler 会读取路由中的动态数据。比如对于 app/[slug]/route.js :

export async function GET(request: Request, { params }) {
    const slug = params.slug    // URL 中的 slug 片段
}

URL 查询参数

import { type NextRequest } from 'next/server'
 
export function GET(request: NextRequest) {
    const searchParams = request.nextUrl.searchParams
    const query = searchParams.get('query')
    // 如对于 "/api/search?query=hello" 来说 query 是 "hello"
}

流式传输

流式传输通常在一些大语言模型中使用,比如 OpenAI 。具体可见原文档介绍:Streaming 。

请求体及表单数据

可通过 request.json() request.formData() 获取请求体或表单数据:

export async function POST(request: Request) {
    const formData = await request.formData()
    const name = formData.get('name')
    const email = formData.get('email')
    return Response.json({ name, email })
}

跨域设置

可以直接在 Response 选项参数里设置 CORS 标头:

export const dynamic = 'force-dynamic' // defaults to force-static
 
export async function GET(request: Request) {
    return new Response('Hello, Next.js!', {
        status: 200,
        headers: {
            'Access-Control-Allow-Origin': '*',
            'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
            'Access-Control-Allow-Headers': 'Content-Type, Authorization',
        },
    })
}

路由片段配置

和 layout page 一样,Route Handler 也会使用 路由片段配置 :

export const dynamic = 'auto'
export const dynamicParams = true
export const revalidate = false
export const fetchCache = 'auto'
export const runtime = 'nodejs'
export const preferredRegion = 'auto'

中间件

中间件可以让你在请求完成前执行一些代码,比如重写、重定向、修改请求或者响应。中间件会在路由(及缓存内容)匹配之前运行。

可以在项目根目录创建 middleware.js 文件来定义中间件:

import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'

// 也可以导出一个异步 async 函数
export function middleware(request: NextRequest) {
    return NextResponse.redirect(new URL('/home', request.url))
}

路径匹配

项目中的每一个路由都会调用中间件。一下是具体的执行顺序:

  1. 依次执行 next.config.js 的 headers 和 redirects 。
  2. 中间件 。
  3. next.config.js 的 beforeFiles 重写。
  4. 路由文件,如 public/ app/ pages/ _next/static 。
  5. next.config.js 的 afterFiles 重写。
  6. 动态路由(如 /blog/[slug])。
  7. next.config.js 的 fallback 重写。

在 middleware.js 中可以设置允许中间件执行的路径的匹配规则:

export const config = {
    matcher: '/about/:path*',   // 也可以是个数组,用来设置多条匹配规则
}

matcher 配置规则(详细见 Path to regexp):

  1. 必须 以斜杠 / 开头。
  2. 可以包含命名参数,如 /about/:path 。命名参数后可加修饰符 * ? + ,功能与正则中的类似,比如 /about/:path* 可以匹配 /about/a/b/c ,因为 * 代表匹配 0 个及以上。
  3. 可以设置圆括号来使用正则,如 /about/(.*) 与 /about/:path* 效果一致。

为了向后兼容,Next.js 会把 /public 视为 /public/index ,此时 /public/:path 会匹配上。

条件语句

import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'
 
export function middleware(request: NextRequest) {
    if (request.nextUrl.pathname.startsWith('/about')) {
        return NextResponse.rewrite(new URL('/about-2', request.url))
    }
    
    if (request.nextUrl.pathname.startsWith('/dashboard')) {
        return NextResponse.rewrite(new URL('/dashboard/user', request.url))
    }
}

使用 cookie

通过 NextRequest 和 NextRequest 可以设置 cookie :

export function middleware(request: NextRequest) {
    // 假设一个带有 "Cookie:nextjs=fast" 标头的请求
    const allCookies = request.cookies.getAll()
    console.log(allCookies) // => [{ name: 'nextjs', value: 'fast' }]
    let cookie = request.cookies.get('nextjs')
    console.log(cookie) // => { name: 'nextjs', value: 'fast', Path: '/' }
    request.cookies.has('nextjs') // => true
    request.cookies.delete('nextjs')
    request.cookies.has('nextjs') // => false

    // 设置响应中的 Cookie 标头
    const response = NextResponse.next()
    response.cookies.set('vercel', 'fast')
    response.cookies.set({ name: 'vercel', value: 'fast', path: '/' })
    cookie = response.cookies.get('vercel')
    console.log(cookie) // => { name: 'vercel', value: 'fast', Path: '/' }

    // 响应中会带上 `Set-Cookie:vercel=fast;path=/test` 标头
    return response
}

设置标头

通过 NextResponse 可以设置请求和响应标头:

export function middleware(request: NextRequest) {
    // 克隆原请求头,并设置新标头 `x-hello-from-middleware1`
    const requestHeaders = new Headers(request.headers)
    requestHeaders.set('x-hello-from-middleware1', 'hello')
    
    // 也可以在 `NextResponse.rewrite` 中设置请求标头
    const response = NextResponse.next({
        // 新的请求标头
        request: { headers: requestHeaders },
    })
    
    // 在响应中设置 `x-hello-from-middleware2` 标头
    response.headers.set('x-hello-from-middleware2', 'hello')
    return response
}

直接响应请求

可以通过 Response 或 NextResponse 直接在中间件中进行请求响应:

import { NextRequest } from 'next/server'
import { isAuthenticated } from '@lib/auth'

export function middleware(request: NextRequest) {
    if (!isAuthenticated(request)) {
        return Response.json(
            { success: false, message: 'authentication failed' },
            { status: 401 }
        )
    }
}

高级中间件设置

在 v13.1 中 Next.js 引入了 skipMiddlewareUrlNormalize 和 skipTrailingSlashRedirect 设置。

skipTrailingSlashRedirect 可以禁止让 Next.js 自行处理 URL 的尾斜杠 :

// next.config.js
module.exports = {
    skipTrailingSlashRedirect: true,
}
const legacyPrefixes = ['/docs', '/blog']
 
export default async function middleware(req) {
    const { pathname } = req.nextUrl
    
    // 仅对一些路由不做尾斜杠处理
    if (legacyPrefixes.some((prefix) => pathname.startsWith(prefix))) {
        return NextResponse.next()
    }
    
    // 尾斜杠处理
    if (!pathname.endsWith('/')) {
        req.nextUrl.pathname += '/'
        return NextResponse.redirect(req.nextUrl)
    }
}

skipMiddlewareUrlNormalize 可以禁止让 Next.js 对 URL 做规范化处理。

项目组织

在 app 目录下,Next.js 只会将含有 page.js 或 route.js 的文件夹视为路由,且仅关心符合文件命名约定的文件。所以可以放心地在 app 目录下放置其它代码文件,或者对代码文件进行分类。

私有文件夹

可以通过 _folder 命名方式声明一个私有文件夹。私有文件夹及其子文件夹均不会被视为路由。

如果你确实想创建一个 _ 开头的路由,可以用 %5F 代替,即以 %5Ffolder 命名。

组织方式

在 Next.js 项目中没有正确或错误的源代码组织方式,但以下是常见三种:

  • 在 app 目录外部组织你的源代码;
  • 在 app 目录顶层组织你的源代码;
  • 根据特性或者路由来拆分组织源代码。

国际化

TODO: 用不到

参考

Routing Fundamentals - nextjs.org

URI normalization - Wikipedia

TALAXY 落于 2023年11月22日 。