# BTQ Explorer - Migration with NextJS v13 (App Router) ## Header + Footer ![Header](https://hackmd.io/_uploads/S1M6_xeep.png) ![Footer](https://hackmd.io/_uploads/BJeo7Zgep.png) 1. 全頁共用一個 `<Layout />` 的 file 放在 `app/layout.tsx`。(layout 只能使用 `/.(js|jsx|tsx)/`) 2. 不同畫面可以透過 generateMetadata 的 API 來生成 head 的內容。 3. 使用 Server Component 做 static rendering > [官方文件](https://nextjs.org/docs/app/building-your-application/routing/pages-and-layouts#layouts):A layout is UI that is **shared between multiple pages**. On navigation, layouts preserve state, remain interactive, and **do not** re-render. ### 使用方式 & 名詞 1. `Root Layout` 所有頁面「共用的 Common Layout」或是「每頁都有自己的 Layout」,Root Layout 一定要有 html 和 body。**適合目前的 Explorer** ,因為只有一種 layout。 ```bash ┌ app ├── block-list ├ ├── page.tsx # /block-list (URL) ├── tx-list ├ ├── page.tsx # /tx-list (URL) ├── block ├ ├── [hash] ├ │ ├──page.tsx # /block/{hash} (URL) ├── tx ├ ├── [hash] ├ │ ├──page.tsx # /tx/{hash} (URL) ├── layout.tsx # Root Layout ├── page.tsx # home page ``` 2. `Router Group` 可以用來表達一個 group 但不影響 URL 生成,以`(<groupName>)` 作為 convention。以 Router Group 來表達分頁,同時示範如何製作 Multiple Root Layout,這樣**特定 group 可以共用同一個 Layout**,而不是整個 App 都是同一個 layout;同時,**Router Group 的 dir 不會影響 URL**,所以不會有 /list 和 /detail,能做到和上面一樣的 routing path。 ```bash ┌ app ├ (list) ├ │ ├── layout.tsx # List Group's Root Layout ├ │ block-list ├ │ ├── page.tsx # /block-list (URL) ├ │ tx-list ├ │ ├── page.tsx # /tx-list (URL) (detail) ├ │ ├── layout.tsx # Detail Group's Root Layout ├ │ block ├ │ ├── [hash] ├ │ │ ├──page.tsx # /block/{hash} (URL) ├ │ tx ├ │ ├── [hash] ├ │ │ ├──page.tsx # /tx/{hash} (URL) ├── layout.tsx # Home Page's Root Layout ├── page.tsx # home page #... ``` ## Dashboard 在 v13.3 推出 Parallel Route 的 feature,讓一個 page 的不同 content 去處理自己 fetching data 週期,或搭配不同頻率來更新畫面,它適合處理一個 layout 有非常多 dynamic view。 對於 Explorer 的 dashboard 來說,像是 Network Stats 的更新頻率應該會跟 Latest Txs 相同,而 Latest Blocks 會是相對長一點才需要更新。透過 Parallel Route,可以保留三者之間的擴充性,同時去分開處理它們的互動性與資料。 ![Network Stats](https://hackmd.io/_uploads/SJ9kjWlea.png) ![Latest Blocks](https://hackmd.io/_uploads/rkoejWgga.png) ![Latest Txs](https://hackmd.io/_uploads/HJWzi-elp.png) ### 使用方式 & 名詞 1. `Parallel Route` 讓同一個 URL 底下的畫面能更好切割處理與呈現,從切割檔案、載入、Error Handle,都能以 page 的角度去使用。naming convention 是以 `@routeName`,該命名方式又稱為 slot,它同樣是不會影響 URL 的 router segment。 > [官方文件](https://nextjs.org/docs/app/building-your-application/routing/parallel-routes):Parallel Routes **allow you to show more than one page in the same view**, like with complex dashboards or modals. With Parallel Routes, you can simultaneously render one or more pages in the same view that can be navigated independently. ![Parallel Route](https://hackmd.io/_uploads/By0VHbgg6.png) 1. 對於 Layout 來說,children 是自動匯集沒有指定名稱的 slot 總和 (`app/(dashboard)/@children/page.tsx` 即是 `app/page.tsx`)、其他有使用 Parallel Route 的 slot 則需要特別說明。 ```bash ┌ app ├── (dashboard) # 這個 dashboard Group 底下的內容都是以 `/` 作為 URL ├ ├── (@networkStat) ├ │ ├──page.tsx ├ ├── (@latestBlocks) ├ │ ├──page.tsx ├ ├── (@latestTxs) ├ │ ├──page.tsx ├── block-list ├ ├── page.tsx ├── tx-list ├ ├── page.tsx ├── block ├ ├── [hash] ├ │ ├──page.tsx ├── tx ├ ├── [hash] ├ │ ├──page.tsx ├── layout.tsx ├── page.tsx # home page:可以保留或移除 ``` 按照上面的結構,則在 `app/layout.tsx` 可以讀取的 props 會變成這樣 ```ts export default async function Layout(props:{ children: React.ReactNode, networkStat: React.ReactNode, latestBlocks: React.ReactNode, latestTxs: React.ReactNode }) { return ( <> <Header /> {/* app/(dashboard)/@networkStat */} {props.networkStat} {/* app/(dashboard)/@latestBlocks */} {props.latestBlocks} {/* app/(dashboard)/@latestTxs */} {props.latestTxs} {/* app/page.tsx 和其他共用此 layout 的 page*/} {props.children} <Footer /> </> ); } ``` 2. 如果要使用 parallel route,需要要注意如何定義 layout。 **在 「獨立 URL 的頁面」 與「Router Group」都要新增 layout.tsx**(parallel route 不用),讓 Segment 和 Group 變成 Root Layout 的 nested layout。如此一來能達成: > - 共用 Layout,提升維護性 - 不會 re-render,也不會重新向 Server 發請求 - 可以 parallel 處理與載入同一個 URL 的內容 如果不是這樣設定,雖然可能不會造成 crash,但會有 parallel router 沒有顯示、styling 丟失、Console Warning 等問題產生。 - `獨立 URL page` - 多寫 Root Layout:`<html>` 會被當作 `<body>` 的 node 1. Warning: validateDOMNesting(...): `<html>` cannot appear as a child of `<body>` 2. Warning: You are mounting a new html component when a previous one has not first unmounted - layout.tsx 丟失 styling: 如果是使用 Tailwind CSS,直接在該 Segment reload 後不會有 styling,但 navigate 回來又顯示,導致行為不一致。 - `Router Group`:不會去載入 parallel 的內容 ```bash ┌ app ├── (dashboard) # 這個 dashboard Group 底下的內容都是以 `/` 作為 URL ├ ├── (@networkStat) ├ │ ├──page.tsx ├ ├── (@latestBlocks) ├ │ ├──page.tsx ├ ├── (@latestTxs) ├ │ ├──page.tsx ├ ├── layout.tsx # Router Group 下的 parallel ├ ├── page.tsx ├── block-list ├ ├── layout.tsx # 其他每個 segment 也都要有 layout.tsx ├ ├── page.tsx ├── tx-list ├ ├── layout.tsx ├ ├── page.tsx ├── block ├ ├── [hash] ├ │ ├──layout.tsx ├ │ ├──page.tsx ├── tx ├ ├── [hash] ├ │ ├──layout.tsx ├ │ ├──page.tsx ├── layout.tsx # Root Layout ├── page.tsx # home page:可以保留或移除 ``` 3. parallel 底下的 router 如果不一致,缺失的頁面會依序找尋 default.tsx (等於可以再客製這頁的 fallback 內容) > Custom Error Page > NextJS Error Page。 ## List & Detail 在 App Router 中,如果一個檔案沒有 `use client` 的描述,預設會使用 Server Component。Server Component 可以撰寫成 async,所以可以直接在同一個 page, layout, 或 component 中去寫 async fetcher。 根據需求,List 相關的 page 適合採用 static path + revalidate 的方式,在設定的秒數讓資料過期,並再下一次訪問時,請 Server 再 prerendered 一份新的 HTML 並存做快取,在 Next12 稱為 ISG;而 Detail 是 dynamic path,但 HTML 的內容在有資料後都是固定,所以會採用的方式,會是請 Server 在第一次請求時 prerendered 一份新的 HTML 並存做快取,之後訪問都使用這份快取來生成 HTML ### 使用方式 & 名詞 在 Next13 後,表達 CSR, SSR, SSG, ISG 的方式變得更簡潔。在 [Route Segment 中可以設定的變數](https://nextjs.org/docs/app/api-reference/file-conventions/route-segment-config#dynamic) 有很多,以下條列比較通用且簡易設定可完成的部分。 若以單純的 static path 來說,是透過`設定 fetch 的不同參數`: - CSR:直接在 layout 或 page 加上 `use client` - SSR:`fetch(url, {cache: 'no-store'})` - SSG:`fetch(url)` (預設 `{cache: 'force-cache'}`) - ISG:`fetch(url, { next: {revalidate: 5} })` (以秒為單位) 如此一來能做到不同的 cache data,像是 block-list 和 tx-list 是根據使用者進入頁面的時間點,去決定要餵多新的資料,很適合採用 ISG 的方式,在特定期間內都使用同一份 prerendered 的 HTML,到了 revalidate 設定的時間結束後,再請 Server prerendered 一份新的 HTML 並存做 cache。 --- 不過若是 dynamic path 下想做到 SSG 或 ISG,則需要搭配 generateStaticParams。 ```ts // app/block/[hash]/page.tsx export const dynamicParams = true; export async function generateStaticParams() { // 必須是以 Array<Params> 的形式 (Params 是 Object),且值必須是 string,不然會報錯 // ❌Error: A required parameter (id) was not provided as a string received <type> in generateStaticParams for <path_name> return [{hash: '...'}, {hash: '...'}, ...] } async function getBlockDetail(params) { ... } export default async function BlockDetail({ params }) { const blockDetail = await getBlockDetail(params); return ... } ``` 然這樣根據設定了多少 hash,即可在 build time 得到 Array 數量的 prerendered HTML。不過這樣的使用情境,會產生過多 prerendered HTML,可能造成 build time 逾時或過長。 --- 然而,雖然 Detail page 的內容不會改變,但是會有海量的 blockHash 和 txHash,所以不適合完全預先將所有 dynamic params 去生成 path;但是無論是 block 或是 tx 的 detail 都是在產生之後會固定,所以也不適合採用 SSR 去讓 server 在每次請求都 prerender HTML。 看到這則 [Abilitty to skipping static pages generating at build time](https://github.com/vercel/next.js/issues/45383),認為最好的 rendering pattern 可能是希望達成像 [Page Router 的 fallback='blocking'](https://nextjs.org/docs/pages/api-reference/functions/get-static-paths#fallback-blocking) 的設定,讓 dynamic path 在第一次被請求時以 SSR 產生 HTML 並產生 cache,後續再訪問都時拿 cache 的內容。 App Router 有提到 [fallback 的參數變改成 global var 的 dynamicParams (Boolean)](https://nextjs.org/docs/app/building-your-application/upgrading/app-router-migration#replacing-fallback),也就是該 dynamic routing 在沒有內容時,是回傳 404 還是 SSR 產生 HTML 並快取起來。該 Features 跟 Page Router 的 getStaticPath 很像,不過稍微不同的是,我們能控制並撰寫 getStaticPath 內實作的邏輯,不確定單純設定 dynamicParams 會不會失去彈性,且不確定能不能有同樣的效果。 > 官方文件: `dynamicParams` controls how params outside of `generateStaticParams` are handled. This replaces the fallback: true | false | 'blocking' option of getStaticPaths in the pages directory. The fallback: 'blocking' option is not included in dynamicParams because **the difference between 'blocking' and true is negligible with streaming**. ```ts // app/block/[hash]/page.tsx export const dynamicParams = true; // 補上這行等於 fallback=true export async function generateStaticParams() { return [{hash: '...'}, {hash: '...'}, ...] } async function getBlockDetail(params) { ... } export default async function BlockDetail({ params }) { const blockDetail = await getBlockDetail(params); return ... } ``` > ??? 需要實驗:如果沒有使用 page 沒有使用 `generateStaticParams()` 來產生 static params,整個內容都會變成 SSR 嗎? 還是只要條列幾個,之後都會是如同 `fallback='blocking'` 的行為? > ??? 需要實驗:如果該頁沒有資料,使用 dynamicParams 的設定是所有 id 都會進行 SSR 嗎?如果之後又有資料的話,會重新生成嗎? > ??? 需要實驗:dynamicParams 的設定會 cache 什麼資料?是 HTML + RSC Payload 嗎? ## Server Component 的使用 page 都會預設是 static rendering,所以無論是 Server Component 或 Client Component 的初始內容都會被放到 initial HTML 上。所以**雖然 Server Component 和 SSR 的差別是初始 HTML 的不同,但實際上兩者幾乎能做到一樣的效果**。 RSC 可以以 async 讀取 fetch 的資料,到了 NextJS v13 的 App Router 後,單純使用 fetch 撰寫 async function,並在 RSC 呼叫,就可以表達出資料是以 getStaticProps 還是 getServerSideProps 取得。 同理,如果是 Client component,仍然需要搭配 useEffect 來 render API data 在畫面,這些在 hook 才完成的 data,也同樣不會包含在 initial HTML,不過若是有撰寫 loading state,是可以在 hook 完成之前就讀到的 Component 預設值,會被包含在 initial HTML。 ??? 什麼是 RSC ??? RSC 建構的整個流程 ([NoScript](https://chrome.google.com/webstore/detail/noscript/doojmbjmlfjjnbmnoijecmcbfeoakpjm):可以用來逐一關掉 JS,用來協助檢測) ### Render 出 React Server Component Tree 有了 Server Component 後,render 的工作會根據 router 和 suspense 切分成 chunk 來載入,而切割 chunk 會進行兩個步驟: 1. `取得 React Server Component Payload (RSC Payload)`:React 先 render Server Component 成 RSC Payload 2. `在 Server prerender HTML`:Next 再根據 RSC Payload,將 Client Component 需要 hydration (JavaScript chunk) 以外的內容在 server 進行 render,轉成初始的 HTML ### RSC Payload 以 .rsc 作為檔名,是類似 JSON 的內容。說明 rendered 的 React Server Components tree,會包含: > 1. Server Components 的 rendered result > 2. 紀錄哪裡會有 Client Component 會需要額外向 Server 請求 streaming chunk > 3. Server Components 要傳給 Client Component 的 props > 4. Server Component 只能包含可序列化的資料,所以不能包含 function ### client 端 (接收內容的 browser) 在瀏覽器這邊,則和之前的步驟很相似,主要差別是改以 RSC Payload 來協助 DOM 建構,並分拆了 hydration 的過程。 1. `解讀 initial HTML`:產生 router 的初始畫面 (initial page load only)。 2. `Reconcile Component Tree`:有 RSC Payload 後,React 能 reconcile Server component 與 Client component 來建構 Component tree。在此之後操作 DOM 完成更新。再接著根據 RSC Payload 對於不同 source tag 的紀錄,向 Server 發 request 來取得 Client component 要做互動性與 hook 更新的 hydration chunk。 3. `Paralled Streaming 得到 Client component chunk`:進行 hydrate 來更新 Client component 的互動性到 DOM,讓 Application 能完整運作。 > [React source code:Component tree 裡不同 tag 的內容是如何被判斷要進行什麼處理](https://github.com/facebook/react/blob/9d17b562ba427adb1bced98f4231406ec573c422/packages/react-client/src/ReactFlightClientStream.js#L44-L60) ## Netlify:Lgger & Error - 測試網站:https://test-next-app-router-feats.netlify.app/ ### 本地開發 & 佈署報錯 1. 如果在 layout 使用 Component,只能使用單一個,且該 Component 不能使用其他 Component,不然會讀取不到 ```ts // ❌ 會失敗,但不會有 Error import "../globals.css"; import { Inter } from "next/font/google"; const inter = Inter({ subsets: ["latin"] }); const Container = ({children}) => { return <main className="container mx-auto max-w-xl pt-8 min-h-screen"> {children} </main> } // app/components/RootLayout.js:可以讀取到 main 和 children export default function RootLayout({ children }) { return ( <html lang="en"> <body className={inter.className}> <Container> {children} </Container> </body> </html> ); } ``` ```ts // ✅可以讀取到 import "../globals.css"; import { Inter } from "next/font/google"; const inter = Inter({ subsets: ["latin"] }); // app/components/RootLayout.js:可以讀取到 main 和 children export default function RootLayout({ children }) { return ( <html lang="en"> <body className={inter.className}> <main className="container mx-auto max-w-xl pt-8 min-h-screen"> {children} </main> </body> </html> ); } // app/layout.js import RootLayout from './components/RootLayout' export default layout({children}) { return <RootLayout>{children}</RootLayout> } ``` 2. 如果畫面有 loading.js,則 inital HTML 只會有 loading content,不會有其他載入資料寫在初始 HTML 上。可能需要在 navigate 的 link 加上 click event 來使用全頻遮罩。 3. 在 Netlify 上,如果是 dynamic path,<Link /> 的 prefetch 打 request 會 502,而且進入 dynamic path 後,會直接 crash。測試 Vercel 去 deploy 的運作情況,確定是 Netlify 還要找方式處理。 ![](https://hackmd.io/_uploads/Skn6w8Zxa.png) ![](https://hackmd.io/_uploads/ryH7dIZga.png) 1. `解決 502 Error`: @netlify/plugin-nextjs 必須在 4.36.1、next 必須在 13.4.x ([Error 討論](https://answers.netlify.com/t/runtime-importmoduleerror-error-cannot-find-module-styled-jsx-nextjs-nextauth/95281/2)) 2. `解決 500 Error`: 目前社群看到的做法是額外寫一個 prebuild.js ([Error 討論](https://answers.netlify.com/t/server-edge-not-defined-error-on-nextjs-ssr-functions-cause-site-to-return-500-errors/91793/35)) --- 在[這篇文章](https://dev.to/alvesjessica/my-experience-deploying-a-nextjs-application-on-netlify-using-next-app-router-3k1b) 條列了幾乎目前遇到的問題,像是降版後仍然有 500,是因為 **\_\_NEXT_PRIVATE_PREBUNDLED_REACT 的環境變數** 造成讀取不到特定 module (next@13.4.9 已經不會被影響),還有可能是 **Netlify 如何處理 Next13 <Link /> 對於 getStaticParams 的 prefetch**。 目前穩定版本是 **@netlify/plugin-nextjs@4.36.1 + next@13.4.9**,如此可以順利在 Netlify 上使用目前提到的所有 features。 * 順利讓 RSC Payload 也變成 cache data ![/pokemon-list](https://hackmd.io/_uploads/HyryhPbxT.png) ### Logger - [Function Log: ISG](https://app.netlify.com/sites/classy-zuccutto-f45b6b/logs/functions/___netlify-odb-handler) 主要是處理 dynamic path (/pokeman/id) - [Function log: Server](https://app.netlify.com/sites/classy-zuccutto-f45b6b/logs/functions/___netlify-handler) 則是處理其他沒有使用 cache 而需要 Server 重新產生的資源(像是 website icon 會在進入 /pokeman/[id] 重複請求該頁,雖然資源相同,但因為是不同頁面,所以會在第一次進入時被重新 request,但 data 已經有快取過,所以不用擔心重新請求資源) ## Reference > 1. [Migrate to App Router](https://nextjs.org/docs/app/building-your-application/upgrading/app-router-migration)