了解如何使用 Client Components 和 Server Components 後,我們接著來認識 App Router 的另一大重點 - Routing Convension。
在 Day 08 時有提到,Next.js 採用 file-system based router,意思是 Next 會依照你的資料夾與檔案結構來自動定義路由,不用額外設定。 那究竟使用 App Router 該如何設計路由資料夾的結構呢?又有哪些 routing 選項我們可以使用呢?我們就來一探究竟吧!
在開始前,先來解釋幾個這個章節常見的名詞:
/
) 區隔的部分。假如覺得文字有點抽象,可以參考這兩張圖:
( 圖片來源:https://nextjs.org/docs/app/building-your-application/routing )
App Router 的路由機制,關鍵就在 /app
這個資料夾。/app
中的資料夾和特殊檔案,分別在 routing 上扮演一個角色:
page.tsx
的資料夾都是一個 route segment。page.tsx
、layout.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
呢?沒錯,這個資料夾就不會是一個 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
中。
( 圖片來源: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.tsx
和 template.tsx
可以達成這個需求,這部分就留到明天分享。
假如單純想知道 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,後續會再談到
...
},
}
}
根據小弟的解讀,簡單來說:
上面這一部分的補充,一方面是想稍微了解 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。
謝謝大家耐心的閱讀,我們明天見!