iT邦幫忙

2025 iThome 鐵人賽

DAY 24
0
Modern Web

從 React 學 Next.js:不只要會用,還要真的懂系列 第 24

【Day 24】App Router 的進階用法 3 - Intercepting Routes

  • 分享至 

  • xImage
  •  

昨天看了 App Router 中一個可以透過建立 slot 設定為平行路由的實作方式,今天再來看一個很類似,但應用情境有點不同的 App Router 的進階用法 - Intercepting Routes。

從實際需求開始發想

在正式講到 Next.js App Router 的 Intercepting Routes 前,我們來思考一個需求應該要怎麼實作。

需求內容是「現在有一個頁面,頁面上有篩選內容和依照篩選顯示的列表內容,可以點擊列表內其中一個項目的查看詳細內容」。
目前有兩個做法:

  1. 用彈窗的方式實作,以此維持篩選的內容。
  2. 以直接換頁的方式實作,讓點擊其中一個項目查看詳細內容的部分能有一個網址,可供使用者進行這個頁面網址的分享。

以上這兩個實作方式,都可以呈現最基本的功能,就是顯示點擊項目的詳細內容,但是各有缺點。
第一個方法,由於沒有真的做切頁的動作,雖然可以維持篩選的內容,但是卻無法讓這個詳細內容有獨立的網址。
第二個方式,讓詳細內容有獨立的網址了,但是因為做了一個切換頁面的動作,當返回到列表頁的時候,篩選內容會被清空,也就沒辦法維持前一次篩選的內容。

那如果想要讓兩個方式的優點同時存在的話,可以怎麼做呢?
大家可以先稍微記住目前說的這個實作情境,我們先進到這次的主題。

Intercepting Routes 是什麼?

我們一樣先來認識一下「Intercepting Routes」是什麼。

「Intercepting Routes」指的是攔截路由,那這個攔截路由是在攔截什麼呢?這個攔截路由其實攔截的是原本應該要完整前往另一個頁面的行為,也就是說 Intercepting Routes 可以讓我們在不離開當前畫面的情況下,在原本的路由載入另一個路由的內容 ,這個行為通常會以 Modal 或是 Drawer 呈現。

這裡舉一個小例子來看看。
當我們在一個頁面裡想要從畫面點擊一個按鈕看到 detail 資訊彈窗,通常會透過類似 isOpen 這樣的狀態結合 Modal 下去呈現。
https://i.imgur.com/G2iIWsA.gif

在這樣的狀態下,因為打開 Modal 元件時,網址不會有變動,Modal 是否開啟是由狀態控制,所以當頁面重整時,並不會維持 Modal 開啟的狀態。當點擊返回按鈕時,也不是單純把 Modal 關掉的行為,而是返回開啟 Modal 頁面的上一頁。

但是當我們使用 Intercepting Routes 時,我們若是想實作從頁面裡點擊按鈕會看到 detail 資訊彈窗的功能,可以讓點擊跳出彈窗的這個部分,改成不是單純以元件狀態來呈現,而是以攔截路由的功能來呈現。

這樣呈現功能後,就會變成點擊按鈕,不僅會顯示彈窗,網址也會有相對應的改變,當點擊返回頁面,會回到顯示著按鈕的頁面。當點擊按鈕,顯示彈窗的狀態下,重新整理頁面時,不會回到前一個頁面,會維持在顯示 detail 的頁面。
https://i.imgur.com/iTdl2OA.gif

在這個情境下,真正被攔截的是「前往 detail/1 頁面的導航行為」,並且將它改成以彈窗的形式來顯示 detail 內容。

Intercepting Routes 的用法

接著進入正題,來看看 Intercepting Routes 的用法吧!

如果想要使用 Intercepting Routes,會需要準備一個真正的路由頁面,還有攔截時要使用的畫面。
真正的路由頁面,指的是要被攔截的頁面,也就是重新整理後要顯示的頁面,這個頁面我們就用我們知道的建立頁面的方式來建立就好。例如:detail/123 這個路由的頁面,就是在 src/app/detail/[id] 下建立一個 page 檔案。

攔截時要使用的畫面,就需要透過(.)、(..)、(..)(..)或(...)來命名資料夾,去讓進入的路由進行相對應的匹配,進而攔截要前往的路由。

要使用這幾種方法中的哪一種寫法來命名資料夾,會依照想要攔截的範圍與路由段層級來決定。

  • (.):指的是同階層的路由段(Route Segment)。例如:目標要攔截的路由段(Route Segment)是 photo/[id],目前所在路由的頁面在 / 這個路由,與 photo/[id] 都一樣在根目錄底下,也就是說位在同一層,所以可以使用(.)photo
    • (..):指的是上一層的路由段(Route Segment)。例如:目標要攔截的路由段(Route Segment)是 photo/[id],要前往的這個目標路由的頁面在 /list 上,需要往上一層在進入 /photo,所以要透過在 list/(..)photo/[id] 資料夾底下加 page 檔案建立攔截路由。
    • (..)(..):指的是上兩層層的路由段(Route Segment)。例如:目標攔截的路由段(Route Segment)是 photo/[id],要前往這個路由的頁面在 /content/list 這個路由,這時就需要往外跳出兩層,使用 content/list/(..)(..)photo/[id]/page.tsx
  • (...):指的是從根目錄去全域配對路由段(Route Segment)。例如:目標攔截的路由段(Route Segment)是 photo/[id],不管你當下在哪一層路由,只要有跳轉到 /photo/[id],都會由 /(...)photo/[id]/page.tsx 進行攔截。

這裡也使用一個例子來看看 Intercepting Routes 的使用方式。
在這裡我們在 src/app 底下建立一個 list 資料夾,這是顯示 list 的頁面。

import Link from "next/link";
const ListPage = () => {
  return (
    <div>
      <h1>List 頁面</h1>
      <Link className="text-blue-500 underline" href="/photo/123">
        點我打開攔截頁面
      </Link>
    </div>
  );
};

export default ListPage;

另外也 src/app/photo 底下建立一個 [id] 資料夾,用來顯示 photo 的詳細頁。

const PhotoPage = () => {
  return <div>這是正式的 photo 完整頁面(完整跳轉才看到)</div>;
};

export default PhotoPage;

在加入 Intercepting Routes 之前,點擊連結會跳往 /photo 資料夾底下建立的這個頁面
https://ithelp.ithome.com.tw/upload/images/20250920/20130914yft52rRDLm.png

接下來透過 (..) 命名規則來建立 Intercepting Route,因為這個情境是要攔截從 list 資料夾跳出,前往 /photo/[id] 的路由。

const InterceptedRoutePhotoPage = () => {
  return <div className="bg-yellow-50 p-3">被攔截的 photo 畫面</div>;
};

export default InterceptedRoutePhotoPage;

加上 Intercepting Route 後,當點擊連結就不會直接進入原本的 photo/[id] 頁面,會先顯示 (..)photo/[id] 的頁面內容,但是網址一樣會顯示 photo/[id],當重新整理頁面,或是直接輸入這個網址時,才會顯示原本 photo/[id] 的頁面。
https://i.imgur.com/7HViM6E.gif

用 Intercepting Routes 實作需求

已經了解使用方式了,這邊再回到一開始提過的需求內容,目標是把兩個實作方法的優點都保存,也就是讓詳細資訊頁有獨立的網址,同時也要讓看到詳細內容後,再返回列表頁面,原本的篩選值不要被重置。這時候就可以使用我們今天看的 Intercepting Routes,並且結合昨天提到過的 Parallel Routes。

我的的清單頁設定的在 src/app/list/page.tsx,所以進入的網址會是 localhost:3000/list
https://ithelp.ithome.com.tw/upload/images/20250920/20130914OZSmPOxALg.png

這樣的做法下,我們一樣要準備一個 detail 頁面。

// src/app/list/[id]/page.tsx
"use client";

import DetailContent from "@/app/components/DetailContent";
import { useParams } from "next/navigation";

const DetailPage = () => {
  const params = useParams();
  return (
    <div>
      <DetailContent id={Number(params.id)} />
    </div>
  );
};

export default DetailPage;

設定好這個檔案後,當我們點擊 Detail 就可以進入到像這樣的詳細資訊頁面。
https://ithelp.ithome.com.tw/upload/images/20250920/20130914LEsZAUcpiZ.png

但就如同前面提到過的狀況一樣,因為我們做了切頁的動作,所以當我們點進頁面時,再返回我們原本輸入的篩選值會重置,變成沒有刪選過的列表。
https://i.imgur.com/8SYAa98.gif

接著使用 Parallel Routes + Intercepting Routes 的方式設定攔截這個 detail 的畫面。

在 list 資料夾底下加上 @modal 資料夾,接著用 (..)list 的寫法建立攔截對應頁面的資料夾,因為是要攔截 list/[id] 這個頁面,所以底下要在建立一個 [id] 資料夾。

// src/app/list/@modal/(..)list/[id]/page.tsx
"use client";
import DetailContent from "@/app/components/DetailContent";
import Modal from "@/app/components/Modal";
import { useParams, useRouter } from "next/navigation";

const DetailModal = () => {
  const router = useRouter();
  const params = useParams();

  return (
    <Modal
      modalTitle="Detail"
      onClose={() => {
        router.back();
      }}
    >
      <DetailContent id={Number(params.id)} />
    </Modal>
  );
};

export default DetailModal;

在 list 資料夾底下的 layout 要再加上 modal prop,modal 這個 Parallel Route 才有效果。

// src/app/list/layout.tsx

const ListLayout = ({
  children,
  modal,
}: {
  children: React.ReactNode;
  modal: React.ReactNode;
}) => {
  return (
    <div className="p-6">
      {children}
      {modal}
    </div>
  );
};

export default ListLayout;

最後就能達到我們期望的需求,查看詳細內容時,不影響原本的篩選值,且有每個詳細內容頁面都有獨立的網址。
https://i.imgur.com/CK4wV14.gif

完整的參考範例可以參考範例連結

總結

Intercepting Routes 能夠攔截原本完整的頁面導航行為,並改以彈窗或其他形式呈現頁面內容。在處理列表與詳細內容的需求時,這種方式能夠同時解決傳統作法的缺點:單純使用 Modal 雖然能保留篩選狀態,但無法提供獨立網址;而直接切換頁面雖然能產生獨立網址,卻會導致返回列表頁時篩選條件被清空。透過 Parallel Routes 結合 Intercepting Routes,我們不僅能保有詳細頁面的 URL 供使用者分享與直接訪問,又能讓返回列表時維持篩選內容不被重置,達成流暢且兼顧使用者體驗的實作方式。


今天認識什麼是 Parallel Routes 的部分就到這裡告一個段落,雖然以我目前實務上使用的習慣來說,還沒有用到這個進階的用法,因為大多數的實作需求,用一般的路由設定方式都可以辦得到,但是多學一種用法,對於之後遇到類似的情況時,也就能更快反應可以這樣設計路由。

關於路由的部分就到這裡告一個段落,明天將會繼續看 Next.js 的其他內容。

參考資料

官方文件 - Intercepting Routes


上一篇
【Day 23】App Router 的進階用法 2 - Parallel Routes
下一篇
【Day 25】Next.js 的圖片優化方式 - Image:你可能不需要額外使用它
系列文
從 React 學 Next.js:不只要會用,還要真的懂26
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言