Next.js App Router 中的平行路由與攔截路由

Back
Category : News

Ahmed Mahmoud

標題:平行路由讓 Next.js App Router 的 layout 透過名為 @slot 的資料夾,在同一個 URL 下能同時渲染多個頁面,而攔截路由((.)、(..)、(..)(..) 和 (...) 資料夾慣例)則讓連結可以在不改變 URL 的情況下,將該 slot 替換成不同的元件。兩者結合後,就能實現 Instagram 風格的可分享相片 Modal:同一個 URL 在軟性導航時顯示 Modal,在硬性重新整理時則顯示完整頁面,而 default.tsx 就是兩者之間的接縫。



關鍵重點

  • 平行路由是一個具名的 slot —— 以 @ 為前綴的資料夾,例如 @modal —— Next.js 會將其渲染為 prop,傳入最接近的 layout.tsx,與隱含的 children slot 並存。Slot 資料夾不會為 URL 增加片段。
  • 攔截路由慣例((.)、(..)、(..)(..)、(...))是相對於檔案系統而非 URL 來匹配路由,因此從動態消息中點擊的連結,可以將 /photo/[id] 渲染為 Modal,而 URL 中永遠不會出現名為 (.)photo 的資料夾。
  • default.tsx 是當目前 URL 在該 slot 內沒有任何匹配時,Next.js 會為該 slot 渲染的內容。如果省略它,當硬性導航到一個沒有填滿所有 slot 的路由時,就會出現 404。
  • 對 /photo/123 進行硬性重新整理或分享連結時,不應該顯示 Modal —— 而是應該渲染 app/photo/[id]/page.tsx 作為一般的完整頁面。這個後備機制才是這個模式的真正目的,而不是需要繞開的 bug。
  • 每個 slot 都是擁有自己 loading.tsx 和 error.tsx 的獨立子樹,因此 Modal 可以獨立於後方頁面的渲染進行暫停與串流。



平行路由實際上解決了什麼問題?

平行路由可以在同一個 layout 中同時渲染多個頁面,每個頁面都由一個具名 slot 而非 URL 片段來定址。我第一次使用它是在一個相片網格中:點擊縮圖應該在不離開網格的情況下開啟燈箱,但燈箱也需要有自己的可分享 URL,以便直接連結到單張相片就能開啟完整頁面。由布林值驅動的客戶端 Modal 能免費獲得前半部分,但完全無法做到後半部分 —— 因為 useState 中的狀態沒有對應的 URL。

資料夾慣例是加上 @ 前綴的名稱。Next.js 會將每個 slot 匹配到的內容,以資料夾名稱為 prop 名稱,傳入最接近的 layout,與每個 layout 原本就會收到的隱含 children slot 並存。

app/
  layout.tsx
  page.tsx
  @modal/
    default.tsx
    (.)photo/
      [id]/
        page.tsx
  photo/
    [id]/
      page.tsx

進入全螢幕模式

退出全螢幕模式

// app/layout.tsx
export default function RootLayout({
  children,
  modal,
}: {
  children: React.ReactNode;
  modal: React.ReactNode;
}) {
  return (
    <html lang="en">
      <body>
        {children}
        {modal}
      </body>
    </html>
  );
}

進入全螢幕模式

退出全螢幕模式

這裡的內容還沒有任何與 Modal 相關的特定設計 —— 一個擁有兩個 slot 的 layout 只是能渲染兩個獨立子樹的 layout。我後來也用相同的機制來實作一個儀表板,包含可以獨立導航的 @team 和 @analytics 面板:在 @analytics 內點擊連結只會重新渲染該 slot,而 @team 則會繼續顯示原本的內容。



(.) 慣例如何決定要攔截什麼?

這些點號片段慣例是相對於攔截檔案在檔案系統中的位置來匹配目標路由,而不是相對於目前的 URL:

慣例 匹配對象
(.) 同一層資料夾的路由
(..) 上一層資料夾的路由
(..)(..) 上兩層資料夾的路由
(...) 從 app 根目錄開始的路由

app/@modal/(.)photo/[id]/page.tsx 會攔截對 /photo/[id] 的客戶端導航,只要該導航是從與 @modal 資料夾處於同一層的路由觸發的。從該頁面追蹤 <Link href="/photo/123">,Next.js 就會將攔截的元件渲染到 @modal slot 中,而不是替換整個樹。將 /photo/123 作為全新頁面載入(重新整理、貼上 URL、從外部網站點擊連結)時,Next.js 會以一般方式解析 app/photo/[id]/page.tsx。攔截只會在客戶端轉場時觸發;它從來就不是用來改變完整頁面載入的回應內容。



為什麼需要 default.tsx?如果跳過它會發生什麼事?

我第一次跳過它時,在開發環境中一切正常,直到我在開啟 Modal 的路由上重新整理後得到了 404。當一個 slot 在目前 URL 下沒有匹配的片段時,它需要有東西可以渲染,而在完整頁面載入時,沒有先前的客戶端狀態可以回退 —— Next.js 必須從頭開始渲染該 slot。default.tsx 就是這個後備機制。

// app/@modal/default.tsx
export default function Default() {
  return null;
}

進入全螢幕模式

退出全螢幕模式

客戶端導航則比較寬容:如果一個 slot 沒有 default.tsx,且新的 URL 在其中沒有任何匹配,Next.js 會繼續渲染該 slot 上次顯示的內容,而不是將其卸載。這對於儀表板分頁的案例非常有用 —— 在 @analytics 中切換分頁不應該重置 @team —— 但這也意味著缺少 default.tsx 可能在每次手動點擊測試中都隱藏起來,只有在硬性重新載入時才會浮現,而這正是分享連結所採用的路徑。



為什麼重新整理 Modal 路由會顯示完整頁面而不是 Modal?

因為它本來就應該這樣。這是我在正確理解這個模式之前一直抗拒的部分:我想要 Modal 能在重新整理後繼續存在,但實際的設計是它不應該繼續存在。app/photo/[id]/page.tsx 是一個完整的、獨立的頁面 —— 相同的內容、沒有 Modal 的外觀,而且不需要先執行任何客戶端 JavaScript 就能存取。這才是讓 URL 真正可分享且可被索引的原因:搜尋引擎或不執行你客戶端路由器的連結預覽機器人,會得到真正的頁面,而不是一個等待 JavaScript 開啟對話框的空殼。



如何關閉 Modal 並回到原本的位置?

Modal 元件是一個客戶端元件,它會呼叫來自 next/navigation 的 router.back(),這會反轉開啟它的軟性導航,讓被攔截的 slot 回退到它的 default.tsx。

'use client';
import { useRouter } from 'next/navigation';

export function Modal({ children }: { children: React.ReactNode }) {
  const router = useRouter();
  return (
    <div className="modal-overlay" onClick={() => router.back()}>
      <div className="modal-content" onClick={(e) => e.stopPropagation()}>
        {children}
      </div>
    </div>
  );
}

進入全螢幕模式

退出全螢幕模式

我在正式環境中發現的缺口是:如果訪客從分享的連結直接在新分頁中開啟 /photo/123,就沒有歷史記錄可以返回,因此 router.back() 要嘛什麼都不做,要嘛離開應用程式。我最後在疊加層處理程式旁邊加上了一個明確的關閉連結,指向一個真正的返回相簿的 href,這樣關閉 Modal 就不再依賴歷史記錄是否存在。



客戶端狀態 Modal 與攔截路由 Modal —— 真正的權衡是什麼?

布林狀態 Modal 攔截路由 Modal
擁有自己的 URL 否 是
可分享 / 重新整理時顯示 否 是 —— 渲染為完整頁面
是否需要客戶端 JS 才能渲染內容 是 否(直接導航時)
設定成本 一個 useState 一個 slot、一個攔截資料夾、一個後備路由、default.tsx

對於真正屬於一次性 UI 的對話框(例如確認提示、設定面板),布林值仍然是正確的工具。只有當 Modal 後方的內容值得擁有自己的 URL 時,才應該選擇路由版本。



常見問題

問:像 @modal 這樣的平行路由 slot 資料夾會出現在 URL 中嗎?
答:不會。@ 前綴會將資料夾標記為 slot 而非路由片段,因此它對 URL 是隱形的,只會影響 layout 的哪一個 prop 會收到它的內容。

問:如果有人不是從動態消息中的縮圖點擊,而是直接導航到 /photo/123,會發生什麼?
答:Next.js 會將 app/photo/[id]/page.tsx 渲染為一般的完整頁面。攔截只會在從匹配路由進行的客戶端轉場時發生,這是設計上的意圖。

問:平行路由 slot 可以有自己的 loading.tsx 嗎?
答:可以。每個 slot 都是獨立的子樹,可以定義自己的 loading.tsx、error.tsx 和 not-found.tsx,因此 Modal 可以獨立於後方頁面進行資料擷取的暫停,而不會阻擋後方的頁面。

問:為什麼 router.back() 無法為直接開啟連結的人關閉 Modal?
答:當一個路由是分頁中第一個載入的內容時,就沒有歷史記錄可以返回。請將關閉處理程式與指向真實後備路由的明確連結搭配使用,而不是只依賴歷史記錄。

問:我可以將 (..)(..) 與定義在樹上更高層的平行路由 slot 結合使用嗎?
答:可以 —— 點號的數量與 @slot 資料夾所在的位置無關;它只描述目標路由相對於攔截檔案自身位置往上幾層。


原文發表於 devya.dev。也刊登於 eng-ahmed.com。由 Devya Solutions 建置。

https://dev.to/ahmed_mahmoud360/parallel-routes-and-intercepting-routes-in-the-nextjs-app-router-field-notes-on-the-photo-modal-56ko

https://www.worldprogramming.org/posts/parallel-routes-and-intercepting-routes-in-the-nextjs-app-router-field-notes-on-the-photo-modal-pattern-jkslmd