Next-js 的路由爲什麼這麼奇怪?

Next.js 是 React 的全棧框架,主打服務端渲染,也就是 SSR(Server Side Rendering)。

它有一套非常強大但也很奇怪的路由機制。

這套路由機制是什麼樣的?爲什麼又說很奇怪呢?

我們試一下就知道了。

先創建個 Next.js 項目:

npx create-next-app@latest

執行 create-next-app,輸入一些信息,Next.js 項目就創建好了。

進入項目,執行 npm run dev,把它跑起來:

瀏覽器訪問可以看到這個頁面,就代表跑成功了:

在項目下可以看到 src 下有個 app 目錄,下面有 page.tsx:

我們添加幾個目錄:

guang/liu/page.tsx

export default function Liu() {
  return <div>666</div>
}

guang/shuai/page.tsx

export default function Shuai() {
    return <div>帥</div>
}

然後瀏覽器訪問下:

可以看到,添加了幾個目錄,就自動多了幾個對應的路由。

這就是 Next.js 的基於文件系統的路由。

剛學了 page.tsx 是定義頁面的,那如果多個頁面有公共部分呢?

比如這種菜單和導航:

肯定不是每個頁面一份。

這種定義在 layout.tsx 裏。

app/layout.tsx 是定義最外層的佈局:

也就是 html 結構,還有 title、description 等信息:

在網頁的 html 源碼裏可以看到這些:

我們改一下試試:

不止是根路由可以定義 layout,每一級都可以:

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 是路徑裏的參數)這樣的路由的頁面呢?

應該如何寫?

這樣:

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 都渲染同一個組件呢?

這樣寫:

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

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

export default function Page() {
    return <div>page</div>
}

@aaa/page.tsx

export default function Aaa() {
    return <div>aaa</div>
}

@bbb/page.tsx

export default function Bbb() {
    return <div>bbb</div>
}

渲染出來是這樣的:

可以看到,在 layout 裏包含了 3 個 page 的內容,都渲染出來了,這種叫做平行路由。

有的同學會問,那 /guang2/@aaa 可以訪問麼?

是不可以的。

此外,Next.js 還有一個很強大的路由機制:

之前有這樣一個路由:

我們在它平級定義個路由:

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 就好了:

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 來取:

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。

這些語法乍看比較奇怪,但是細想一下,都是挺合理的設計。

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/YNnliv3RGc6NRXE5UtyhRQ