iT邦幫忙

2023 iThome 鐵人賽

DAY 17
0
Modern Web

深入淺出,完整認識 Next.js 13 !系列 第 17

Day 17 - Next.js 13 App Router 基本路由設定

  • 分享至 

  • xImage
  •  

了解如何使用 Client Components 和 Server Components 後,我們接著來認識 App Router 的另一大重點 - Routing Convension。

Day 08 時有提到,Next.js 採用 file-system based router,意思是 Next 會依照你的資料夾與檔案結構來自動定義路由,不用額外設定。 那究竟使用 App Router 該如何設計路由資料夾的結構呢?又有哪些 routing 選項我們可以使用呢?我們就來一探究竟吧!


在開始前,先來解釋幾個這個章節常見的名詞:

  • Tree:我們會將 routing 的資料夾以樹狀結構呈現,Tree 表示完整的資料夾結構。
  • Subtree:Tree 中的局部樹狀結構 ( Tree 中,包含 Root 和 Leaf 的某部分 )
  • Root:Tree 或 Subtree 的一個節點 ( node )。
  • Leaf:Subtree 中沒有 children node 的節點。
  • URL Segment:網址中以斜線 ( / ) 區隔的部分。
  • URL Path:網址中網域之後的部分。

假如覺得文字有點抽象,可以參考這兩張圖:
component tree
url path and segment
( 圖片來源:https://nextjs.org/docs/app/building-your-application/routing )

app 資料夾中的 page.tsx

App Router 的路由機制,關鍵就在 /app 這個資料夾。/app 中的資料夾和特殊檔案,分別在 routing 上扮演一個角色:

  • 資料夾:定義路由。 每個有 page.tsx 的資料夾都是一個 route segment。
  • 特殊檔案:定義 UI。 還記得在 Day 08 有提到 App Router 有一些特殊檔案,像是 page.tsxlayout.tsx 等等嗎?這些特殊檔案就會負責定義該路由的 UI。

補充:特殊檔案的副檔名可以接受 .js, .jsx, .tsx

舉例來說,假如我在 /app 新增一個 dashboard 資料夾,並在裡面加入 page.tsx,結構如下:

app/
├─ dashboard/
│  ├─ page.tsx

接著我在 dashboard/page.tsx default export 的 component return 一段簡單的 JSX:

/* app/dashboard/page.tsx */
export default function Page() {
  return <h1>Welcome to the dashboard</h1>;
}

假設網站網域是 sc.com,則進入 sc.com/dashboard 後你就會看到 Welcome to the dashboard。

那假設我希望 route segment 不只一層,比方說 sc.com/dashboard/settings 呢?

很簡單,我們只需要在 /dashboard 中加一個 settings 資料夾,並在 /settings 中加入 page.tsx,結構如下:

app/
├─ dashboard/
│  ├─ settings/
│  │  ├─ page.tsx
│  ├─ page.tsx

這樣 sc.com/dashboard/settings 就會顯示 dashboard/settings/page.tsx 定義的 UI 了!

app 資料夾中沒有 page.tsx

那假如 /app 中的資料夾,裡面沒有 page.tsx 呢?沒錯,這個資料夾就不會是一個 route segment 。所以不同於以往 Pages Router 架構,/page 中的檔案都會被定義成 route segment,導致頁面檔案必須與其他檔案分開放;假如你喜歡 folder-by-feature 或 folder-by-route 的結構,比方說我希望 /settings 中可以有 /components/utils 等資料夾,用來放這個 route segment 的共用元件和 functions,只要這兩個資料夾中沒有 page.tsx,就不會被定義成 route segment,因此可以放心把它們都放在 /app/settings 中。
routing convention
( 圖片來源:https://nextjs.org/docs/app/building-your-application/routing )

這時候你可能會有另個疑問,假如我希望某個 subtree 中的所有 route segments,都有一個固定的 component,比方說 /dashboard/settings/dashboard/profile 都有個相同的 header,有可以讓我不用在每個 route segment 的 page.tsx 都 import <Header> 的做法嗎?

有的,其中兩個特殊檔案 layout.tsxtemplate.tsx 可以達成這個需求,這部分就留到明天分享。


淺談 Next 路由設定原理

假如單純想知道 App Router 的基本路由設定方式的讀者,可以直接跳過這 part,到下一個分隔線看結論。接下來的內容針對跟我一樣,對於 Next file-system-based 的路由設計,一方面覺得開發上很便利,但另一方面也因為沒有手動設定路由,而很好奇 Next 是怎麼做到,想讀一點點 source code 的讀者。

假如底下 source code 解讀有誤,也懇請大神們指正!

我主要參考 route-loader.ts這份檔案,附上連結

先來看到第 248 行 getFilesForRoute 這個 function:

function getFilesForRoute(
  assetPrefix: string,
  route: string
): Promise<RouteFiles> {
  // 先確認是開發模式還是正式模式
  if (process.env.NODE_ENV === 'development') {
    // 找到目前路由對應的 script 路徑
    const scriptUrl =
      assetPrefix +
      '/_next/static/chunks/pages' +
      encodeURI(getAssetPathFromRoute(route, '.js')) +
      getAssetQueryString()

    // 假如找到檔案就 reslove,並 return 找到的 JavaSciprt 和 cSS 檔
    return Promise.resolve({
      scripts: [__unsafeCreateTrustedScriptURL(scriptUrl)],
      // Styles are handled by `style-loader` in development:
      css: [],
    })
  }

  
  return getClientBuildManifest().then((manifest) => {
    // 假如在正式環境,則會去讀 manifest 裡面的內容,看路由和檔案的 mapping 關係
    if (!(route in manifest)) {
      throw markAssetError(new Error(`Failed to lookup route: ${route}`))
    }

    const allFiles = manifest[route].map(
      (entry) => assetPrefix + '/_next/' + encodeURI(entry)
    )

    // return 目前路由相關的 JavaSciprt 和 CSS 檔
    return {
      scripts: allFiles
        .filter((v) => v.endsWith('.js'))
        .map((v) => __unsafeCreateTrustedScriptURL(v) + getAssetQueryString()),
      css: allFiles
        .filter((v) => v.endsWith('.css'))
        .map((v) => v + getAssetQueryString()),
    }
  })
}

接著往下看到 282 行 createRouteLoader 這個 function:

export function createRouteLoader(assetPrefix: string): RouteLoader {
  // 用 Map 來存路由的進入點、JavaSciprt、CSS 檔案等資料
  const entrypoints: Map<string, Future<RouteEntrypoint> | RouteEntrypoint> =
    new Map()
  const loadedScripts: Map<string, Promise<unknown>> = new Map()
  const styleSheets: Map<string, Promise<RouteStyleSheet>> = new Map()
  const routes: Map<string, Future<RouteLoaderEntry> | RouteLoaderEntry> =
    new Map()
   
  ...

  return {
    whenEntrypoint(route: string) {
      return withFuture(route, entrypoints)
    },

    // 找到並執行目前路由的 JavaScript 進入點,會 return 一個包含 component 和 exports 的 object
    onEntrypoint(route: string, execute: undefined | (() => unknown)) {
      ;(execute
        ? Promise.resolve()
            .then(() => execute())
            .then(
              (exports: any) => ({
                component: (exports && exports.default) || exports,
                exports: exports,
              }),
              (err) => ({ error: err })
            )
        : Promise.resolve(undefined)
      ).then((input: RouteEntrypoint | undefined) => {
            ...
        }
      })
    }
    
    loadRoute(route: string, prefetch?: boolean) {
          return withFuture<RouteLoaderEntry>(route, routes, () => {
            let devBuildPromiseResolve: () => void

            if (process.env.NODE_ENV === 'development') {
            ...

            return resolvePromiseWithTimeout(
              // 透過 getFilesForRoute 取得目前路由的 JavaScript 和 CSS 檔案
              getFilesForRoute(assetPrefix, route)
                .then(({ scripts, css }) => {
                  return Promise.all([
                    // 假如 JS entry point 還沒執行過,就在這邊執行一次
                    entrypoints.has(route)
                      ? []
                      : Promise.all(scripts.map(maybeExecuteScript)),
                    // 載入 CSS 檔
                    Promise.all(css.map(fetchStyleSheet)),
                  ] as const)
                })
                .then((res) => {
                  // 結合 CSS 跟 JS entry point 執行結果
                  return this.whenEntrypoint(route).then((entrypoint) => ({
                    entrypoint,
                    styles: res[1],
                  }))
                }),
                ...
            )
              .then(({ entrypoint, styles }) => {
                // 產生一個 object 存該路由的 JS entry point 和 CSS
                const res: RouteLoaderEntry = Object.assign<
                  { styles: RouteStyleSheet[] },
                  RouteEntrypoint
                >({ styles: styles! }, entrypoint)
                return 'error' in entrypoint ? entrypoint : res
              })
              .catch((err) => {
               ...
              })
              .finally(() => devBuildPromiseResolve?.())
          })
        },

    prefetch(route: string): Promise<void> {
      // 處理 prefecth,後續會再談到
      ...
    },
  }
}

根據小弟的解讀,簡單來說:

  1. 開發模式 Next 的 script 和 stylesheet 會根據路由命名,所以可以根據路由去找到 JS 和 CSS 檔案
  2. 正式模式 Next 會透過一個 manifest 檔案,找到目前路由對應到的 JS 和 CSS 檔案
  3. 找到檔案後,會產生一個 object,包含了找 JS entry point、執行路由 script 和 fetch CSS、處理 prefecthing 等 methods,供後續載入路由需要的檔案使用。

上面這一部分的補充,一方面是想稍微了解 Next 路由設定背後的原理;另一方面也是想一點點 source code,讓大家感受一下 Next 帶來開發上的便利性。

呼應 Day 02 提到的,要做 SSR 不一定要用 Next 或 Remix 等 meta-frameworks,單純用 React 也可以做到。但光是路由設定可能就要跑上面這些步驟,還沒深入去探討如何找到路由對應檔案就夠讓人頭暈腦脹了,更何況還可能要處理複雜的路由 ( ex: 巢狀、動態路由 )、navigation、hydration,或是想進一步做 streaming、code splitting、prefetching、caching 等優化機制。所以 Next.js 不是提供 SSR 的唯一解法,而是提供一個開發門檻較低的選項。

以上就是 App Router 基本路由設定的介紹,明天會帶大家認識跨路由共用 components 時很實用的 Layout 和 Template。

謝謝大家耐心的閱讀,我們明天見!


上一篇
Day 16 - 如何防止整頁白頁:Error Boundaries & error.tsx
下一篇
Day 18 - Next.js 13 App Router 跨路由共用 UI:Layout 與 Template
系列文
深入淺出,完整認識 Next.js 13 !30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言