關於 app router 的 RSC (React Server Component)
緣起:CSR/SSR/SSG?
Next.js 是一個基於 React 所延伸出來的框架,其主要的特色在於可以透過 app/pages router 來處理複雜的路由機制以及比較容易的實作、處理 pre-rendering 的部分,那麼什麼是 pre-rendering 呢?如果對 React 較為熟悉的話應該會有印象,因為在 React 裡面會透過 jsx/tsx 中所設計好的元件來對網頁進行渲染來呈現出網頁裡面的內容,所以實際上只需要在初始的 index.html 中掛上一個 .root,便可以以此將底下的所有內容給展開來。
在本文中不會詳細深入的探討關於 CSR/SSR/SSG 各自的差異和特色,在網路上也有許多更豐富且詳細的文章是關於這三種渲染方式的討論和比較、實踐,本文想要關注的部分和起因是在於研究這個部落格的過程中,不免俗的在處理文章頁面呈現的時候接觸到了 CSR/SSR/SSG 的問題,在閱讀官方文件的時候赫然發現到一個有趣的點:在 Next.js 的官方文件中將 Docs 分成了 app 和 pages router 兩部分,而在 app router 的相關項目裡面,是沒有關於 CSR/SSR/SSG 明確的三種 rendering 方式的詳細說明,反而是在 pages router 的文件底下還能找到各自的章節頁面,再仔細研究一番之後發現到在較新版的 app router 當中,官方應是有意的將這幾個名詞概念抽換掉,並以更簡化的方式來處理這個議題。
Server & Client Component
從文件中的 Server and Client Components 章節中將 Next.js 裡面的元件分成了兩大主要類別,分別就是 Server Component 和 Client Component。筆者認為採用這樣的分法而捨棄掉以往的分類方式的原因也確實是以更容易理解為出發點,若參考舊版的文件或其它的技術文章,會發現到在過去要處理 SSR/SSG 的時候會需要判斷自己當前的頁面比較適用哪一種類型,再相對應的呼叫 getServerSideProps 或 getStaticProps 來處理資料的抓取以接續到後面的渲染,相對來說較為複雜,並且在一個網頁當中也並非所有的元素都是屬於同一種處理的方法。因此在新版的分類規劃當中,Server Component 主要就是負責處理以下的工作:
-
從資料庫或透過 API 獲取資料
-
處理 API keys、token 或其它這種不能洩露到 client 端的操作
-
減少傳送到瀏覽器的 JS 數量
-
優化網頁首次呈現上的體驗
至於 Client Component 則是應對到網頁上的 state 和 event,像是在 React 中常用到的 useState、useEffect,或是處理各種和網頁互動的行為,以及像是 localStorage 或 window 這種只有在瀏覽器環境中才能調用的功能,都必須在 Client Component 這邊來處理。透過這樣的區分,我們就可以並將清楚的知道應該在處理哪些部分的時候在 Server Component,哪些則交給 Client Component。
互動
在 Server Component 裡面抓取資料時,相較於舊的方法來說,不再需要判斷網頁的類型而使用不同的方法,而是透過統一的 async 方法來定義,例如在 page.tsx 中可以這樣設計:
interface MainPageProps { params: Promise<{ id: string }> } export default async function MainPage({ params }: MainPageProps) { const { id } = await params const data = await fetchData(id) return <MainDisplay detail={data.detail} /> }
後續在底下的元件裡面再把獲取到的資料分配給對應的元件即可交由後續的 Client Component 進行渲染處理,而 Client Component 在使用上最大的重點就是經由在檔案的開頭加上 "use client" 來和 Server Component 進行區隔。另外需要注意的是 page.tsx 在 Next.js 中是作為路由的頁面接口,因此這邊 export 出來的 function 裡面可以帶的參數是有限定的,通常會是像上面那樣帶入 params 來獲取動態路由的值,如果隨意放入了其它的參數,在 build 的時候就有可能會導致建構失敗。
SSR/SSG
瞭解到 app router 的 Server 和 Client Component 後,會產生一個問題是:那麼要怎麼利用 Server Component 來實作出 SSR 和 SSG 呢?如果是使用 Next.js 預設所提供的 fetch 函式來呼叫 API 的話,可以透過在呼叫時設定 cache 來達成,例如:
const fetchData = (id: number): Promise<TData> { try{ const data = await fetch("https://...", { cache: "force-cache" | "no-store" }) return data } catch (error) { // handle error } }
其中 "force-cache" 就是強制的執行快取,類似於 SSG 的做法,至於 "no-store" 則是相反的完全不做任何快取,較接近於 SSR 的處理方式。在官方文件有提到 Next.js 的預設會是採用 auto no cache 來處理,也就是在開發模式下不快取,但經過 build 之後便會將靜態的部分進行快取,不過如果遇到動態的 API 請求時 (例如設定了 cookies、headers 或是結合 searchParams 的請求) 還是會在每一次呼叫時重新向 server 發出請求。另外,如果想要做到定時過期刷新的話,也可以利用 revalidate 的方式來設定更新的頻率。
const data = await fetch("https://...", { next: { revalidate: 60 } // 60 秒過期,下次請求就會重新打一次 API })
如果不使用 fetch 來抓取資料的話 (例如使用 axios),則需要利用 Route Segment Config 中的 dynamic 常數來配合,具體來說是在 page/layout 或 route 裡面將參數 export 出來,另外 revalidate 常數則類似於上面定時刷新的方法。
// 預設是 auto,會盡可能的處理快取,force-dynamic 是接近 SSR 的做法, // error 和 force-static 則接近 SSG,但對 Dynamic API 的處理方式不同 export const dynamic = "auto" | "force-dynamic" | "error" | "force-static" export const revalidate = 60 // 定期刷新頻率
手動刷新
上面提到的 revalidate 常數可以用來設定定期刷新快取的頻率,那麼就會產生一個有趣的情況是,revalidate = 0 又會表現出什麼樣的特性呢?雖然直觀上看起來好像是:既然被設為 0 了那應該就是代表隨時刷新,類似 SSR 這樣的概念吧?不過其實這樣的設定方式會牽涉到另一項手動更新的機制,也就是說頁面還是會以 SSG 的模式來運作,在初次 build 完成之後就不會再自動進行快取的刷新,但是可以透過手動觸發的方式來讓頁面重新抓 API 來更新快取,對於像是部落格這種平時不太需要更新資料,但又不是完全靜態的應用來說就蠻適用的。
而在 Next.js 裡面,我們可以透過 revalidatePath 或是 revalidateTag 來手動觸發更新,不過需要注意的是這兩個方法沒辦法直接被 Client Component 給呼叫,會在 build 的時候就出現錯誤。
具體上的使用方式,在官方文件裡除了在標注 "use server" 的檔案裡面撰寫外,也建議可以另外在 app 目錄底下開設 api 目錄來存放這些方法,例如:
// app/api/revalidate/route.ts import { revalidatePath } from 'next/cache' import type { NextRequest } from 'next/server' export async function GET(request: NextRequest) { const path = request.nextUrl.searchParams.get("path") if (path) { revalidatePath(path) return Response.json({ revalidated: true, now: Date.now() }) } return Response.json({ revalidated: false, now: Date.now(), message: "Missing path to revalidate", }) }
revalidatePath 這種方法總共可以放入兩個參數,分別是 path 和 type,path 就是欲刷新的路徑,可以是固定路徑或是帶有動態參數的路徑 (例如 /products 和 /products/[id]),type 則可以放入 "page" | "layout" 兩種來指定要刷新的類型,如果是固定路徑的話這個欄位就不一定要填寫,而帶有動態參數的路徑就必須放入 type。
而另一個可以更精確管理控制想要刷新的快取方式是使用 revalidateTag,可以參考下面的例子,在使用 fetch 獲取資料時帶入 tags 參數來為這個儲存資料的變數貼上一個標籤:
// Page A: /products const products = await fetch("https://.../products", { next: { tags: ["products"] }, }) // Page B: /home const hotSales = await fetch('https://.../hotSales", { next: { tags: ["products"] }, })
那麼後續就可以利用 revalidateTag 來同時觸發這兩項的快取更新,另外在官方文件中也推薦設定第二個參數 profile=max,讓伺服器在重新獲取到新的內容之前先呈現出過期的快取內容。
// app/api/revalidate/route.ts import { revalidatePath } from 'next/cache' import type { NextRequest } from 'next/server' export async function GET(request: NextRequest) { const path = request.nextUrl.searchParams.get("tag") if (tag) { revalidateTag(tag, "max") return Response.json({ revalidated: true, now: Date.now() }) } return Response.json({ revalidated: false, now: Date.now(), message: "Missing tag to revalidate", }) }
Metadata
另一個常會遇到的是動態的 Metadata 的處理,也就是關於網站的 title 以及一些關於 head meta tag 和 SEO 有關的設定,通常也會需要動態的依照獲取的資料內容來填入,而在 Next.js 中會透過 generateMetadata 這個 async 方法來實作,不過這樣會遇到一個問題,像是在下面呈現出來的樣子,那就是在 MainPage 和 generateMetadata 裡都各自呼叫了一次 API,似乎在效能上來說就顯得有點多餘。
interface MainPageProps { params: Promise<{ id: string }> } export default async function MainPage({ params }: MainPageProps) { const { id } = await params const data = await fetchData(id) return <MainDisplay detail={data.detail} /> } export async function generateMetadata({ params }: MainPageProps) { const { id } = await params const data = await fetchData(id) return { title: data.title, description: data.description, icons: { icon: "/favicon.ico", }, // ... }
這個問題的解法除了前述提到的配合 fetch 來進行快取之外,也可以透過 React 當中的 cache 方法來處理,這個方法可以讓相同函式且帶有相同參數的方法呼叫被快取起來,也就是雖然在程式裡面進行了多次相同呼叫,但實際上只進行了最初一次的呼叫,其餘的則使用快取來供應內容,像是:
import { cache } from "react" interface MainPageProps { params: Promise<{ id: string }> } const getData = cache((id: number): Promise<TData> { try{ const data = await fetchData(id) return data } catch (error) { // handle error } }) export default async function MainPage({ params }: MainPageProps) { const { id } = await params const data = await getData(id) return <MainDisplay detail={data.detail} /> } export async function generateMetadata({ params }: MainPageProps) { const { id } = await params const data = await getData(id) return { title: data.title, description: data.description, icons: { icon: "/favicon.ico", }, // ... }
另外在 Next.js v16 之後的版本裡面,基於 Server Component 延伸出了另一個 Cache Component 來專門處理這種需要快取的元件,不過由於筆者底下的專案還沒有升級到最新版本的 Next.js,因此並沒有直接支援這樣的使用方式,希望後續有機會可以再來好好鑽研一下~