假如我們今天要做一個 dashboard,頁面上包含全域共用的 Header,以及 數據分析
和 團隊介紹
兩個區塊,大致 layout 如下:
很直覺地,可能會想到建 <Team>
和 <Analytics>
兩個 component,再 import 進 app/dashboard/page.tsx
中:
/* app/dashboard/page.tsx */
import Analytics from './components/Analytics';
import Team from './components/Team';
export default function Page() {
return (
<>
<Analytics />
<Team />
</>
);
}
假設兩個 components 都是 Server Components,其中一個 component <Team>
處理 data fetching 要 5 秒,就有可能讓使用者進到頁面後,要乾等 5 秒才能看得到畫面。
這時候可能會聯想到 Day 15 分享的內容,透過 <Suspense>
component 來實現 streaming,讓頁面其他部分先渲染並顯示,還在處理 data fetching 的部分可以先顯示 Loading...:
/* app/dashboard/page.tsx */
import { Suspense } from 'react';
import Analytics from './components/Analytics';
import Loading from './components/Loading';
import Team from './components/Team';
export default function Page() {
return (
<>
<Analytics />
<Suspense fallback={<Loading />}>
<Team />
</Suspense>
</>
);
}
這樣當 <Team>
還在渲染時,layout 和 <Analytics>
可以渲然完會先顯示,<Team>
則先顯示 Loading...,渲染完後再替換為 <Team>
的 UI。
處理完 Streaming,你可能會接著想到 Day 16 的內容,透過 error boundaries,當其中一個 component 渲染發生問題時,讓其他部分依然可以正常顯示:
import { Suspense } from 'react';
import Analytics from './components/Analytics';
import ErrorBoundary from './components/ErrorBoundary';
import Loading from './components/Loading';
import Team from './components/Team';
export default function Page() {
return (
<>
<ErrorBoundary>
<Analytics />
</ErrorBoundary>
<ErrorBoundary>
<Suspense fallback={<Loading />}>
<Team />
</Suspense>
</ErrorBoundary>
</>
);
}
寫到這邊,突然覺得很熟悉。Day 15 和 Day 16 有提到兩個特殊檔案:loading.tsx
和 error.tsx
,可以讓我們以 route segment 為單位,很簡單地做 streaming 和加 error boundaries。於是我就突發奇想:有辦法讓 <Analytics>
和 <Team>
變成兩個類似 route segment 的東西,讓他們可以使用 layout、loading、error 這些特殊檔案,但又不會影響到 URL 嗎?
還真的有,我們可以使用一個特殊的路由設定:Parallel Routing。
Parallel Routing 簡單來說,能在不影響 URL 的背景下,讓多個 Page components,render 在同一個 layout 中。
要怎麼使用呢?
在資料夾名稱前綴加上 @
,告訴 Next 這是一個 parallel route,並一樣在資料夾中新增 page.tsx
定義這個 route 的 UI。以上述例子,我們可以在 app/dashboard/@analytics
和 app/dashboard/@team
各加入一個 page.tsx
,定義兩者 UI。
這時 /dashboard 的 layout 中的 props 就會多兩個 key-value - analytics 和 team,分別代表app/dashboard/@analytics/page.tsx
和 app/dashboard/@team/page.tsx
定義的 UI:
/* app/dashboard/layout.tsx */
export default function Layout(props: {
children: React.ReactNode
analytics: React.ReactNode
team: React.ReactNode
}) {
return (
<>
{props.children}
{props.team}
{props.analytics}
</>
)
}
( 圖片來源: https://nextjs.org/docs/app/building-your-application/routing/parallel-routes )
/dashboard/page.tsx
中定義的 UI,顯示在 /dashbaord 中,@team 和 @analytics 不會影響到 URL。同理,理論上 parallel route 裡也可以使用 loading.tsx
和 error.tsx
來定義個別的 suspense fallback UI 和 error boundaries:
( 圖片來源: https://nextjs.org/docs/app/building-your-application/routing/parallel-routes )
但為什麼說理論上呢?因為目前 parallel route 的 loading.tsx
和 error.tsx
似乎有些 bugs,所以吃不到 parallel route 資料夾中兩者的效果。
今年五月有人在 Next 官方 repo 發 issue 反應這個問題,但官方似乎還沒提供解釋,這個 bug 到目前也還沒修復。
針對這個 bug,目前有幾種解法:
一樣使用 <Suspense>
和自己寫 error boudary components。
把 loading.tsx
和 error.tsx
丟進一個 route group 中,比方說 @analytics/(routeGroup)/loading.tsx
。
這是 Next 共同創辦人之一 Tim Neutkens 提供的方法,我目前實測的確有效,但我看到蠻多國外網友反應這個方法不可行,他也沒有解釋原理,大家可能要自行嘗試看看。
接著來看另一個情境:我們希望某個 route 的排版內容,會依據使用者從哪個 route 過來,而有所不同。
以 Unsplash 為例,我們到 Unsplash 首頁點選其中一張圖片,並觀察彈跳視窗的網址,和重新整理後會發生什麼事:
可以發現,點選圖片後,URL 會切換到 /photos/[圖片id]
,並跳出一個圖片資訊的彈跳視窗。重新整理後,URL 仍然是 /photos/[圖片id]
,但頁面的內容還排版和彈跳視窗的內容排版不同。
假如想製造這樣的效果,就可以使用 intercepting routes。
那要如何使用 intercepting routes 呢?我們可以在 /app
中的資料夾名稱開頭加入 (..)
,來告訴 Next 這是一個 intercepting routes。括號中的 .. 是對應 intercepting routes 之於 route segment 資料夾的相對路徑。
文字有點抽象,來看個範例。比方說我的檔案結構長這樣:
├── feed
│ ├── (..)photo
│ │ └── page.tsx
│ └── page.tsx
└── photo
└── page.tsx
因為 intercepting routes 是要對應到和 /feed 同一層的 /photo,所以我要在 /feed 的子路由中攔截 /photo,就要使用 (..)
。
相對路徑的表示方法可以參考官方說明:
( 圖片來源:https://nextjs.org/docs/app/building-your-application/routing/intercepting-routes#convention )
以上述結構,我們來設計一個簡單的實驗:
feed/(..photo)/page.tsx
加入「攔截版本圖片」文字;photo/page.tsx
加入「一般版本圖片」文字當點擊「查看圖片」後,URL 會改為 /photo,頁面中央文字是「攔截版本圖片」。
這時重新整理,會發現 URL 一樣為 /photo,但頁面中央文字改為「一般版本圖片」。
所以,當我們希望從 /feed 切換到 /photo 時,/photo 可以「被攔截」,有一個不同於原本的 UI,就可以使用 intercepting routes。
提醒一下,intercepting routes 只有用 soft navigation 才會有效果。以 App Router 來說,你可以使用前面提到的 <Link>
或 useRouter()
來切換路由,但假如用 window.location.href
或使用者直接輸入網址,intercepting routes 就不會有效果。
以往在控制彈窗出現時機,可能會用一個 state 來控制:
function App() {
const [isModalVisible, seIsModalVisible] = useState(false);
return(
...
{isModalVisible && <Modal />}
...
);
}
了解 intercepting 的概念後,我們可以改用「路由切換」的思維來實作一個類似 Unsplash 的簡單彈窗!路由切換對應彈窗功能的邏輯大致是這樣:
依照這個邏輯來實作彈窗:
<Modal>
,包含<Mask>
<ExitButton>
,點擊後會拜訪 /feed ( 關閉彈窗 )/* Modal.tsx */
'use client';
import ExitButton from './ExitButton';
import Mask from './Mask';
export default function Modal({ children }: { children: React.ReactNode }) {
return (
<>
<Mask />
<div className='...'>
{children}
<ExitButton />
</div>
</>
);
}
feed/(..photo)/page.tsx
import Modal 並在 chilren props 帶入我們要的文字:/* feed/(..)photo/page.tsx */
import Modal from '@/app/components/Modal';
export default function Page() {
return <Modal>攔截版本圖片</Modal>;
}
<Link>
,讓使用者點擊 /feed 中的「查看圖片」後,會拜訪 /photo,等同打開彈窗:/* feed/page.tsx */
'use client';
import Link from 'next/link';
export default function Page() {
return (
<div className='...'>
<Link href='/photo'>
<button className='...'>
查看圖片
</button>
</Link>
</div>
);
}
photo/page.tsx
加入我們要的文字:/* photo/page.tsx */
export default function Page() {
return (
<div className='...'>
一般版本圖片
</div>
);
}
這樣就完成一個簡單的彈窗啦:
官方也有提供一份使用 intercepting routes + parallel routes 來實作彈窗的 sample code,有興趣的讀者可參考連結。
最後做個總結:
layout.tsx
、loading.tsx
和 error.tsx
,可以透過 parallel routes。<Suspense>
、<ErrorBoundary>
等 React components 。以上就是今天的內容!App Router 的路由介紹也到這邊告一段落。學會了 App Router 中怎麼使用 Client Components 和 Server Components,以及幾種路由設定、切換的方式,接下來想帶大家再往後端走一點點,探索幾個能讓 Server 再多幫我們一點的忙。
謝謝大家耐心的閱讀,我們明天見!