延續前面提到的 Static 和 Dynamic,今天我們來看一下和 Static 和 Dynamic 有關聯的一個主題,那就是「Cache」。為什麼說是有關連的呢?是因為 Next.js 的 Cache 機制是影響到路由對應到的頁面是 Static 還是 Dynamic 的關鍵原因之一。
Next.js 為了提升網頁的效能,會進行很多不同層面的 Cache 機制。這些 Cache 機制雖然目的是為了優化網頁效能,但如果對這個機制不夠瞭解,那目的為優化效能的 Cache 機制就有可能變成了一個災難。
舉一個曾經發生在我身上的例子,我曾經在開發頁面需求時,遇到看起來像是資料被 Cache 住的問題。那個問題就是當我進入頁面時,首頁上的輪播圖片會有短暫的時間顯示舊圖片,下一秒才顯示新圖片的狀況。當時對於快取這個詞彙還停留在資料層面的我來說,一個下意識就是去看哪裡讓資料被快取住了。因為這個輪播圖的來源是 API,所以我就很直覺地從 API 的寫法開始查起,又加上我們使用的是 React Query,因此我一度懷疑是 React Query 的寫法讓資料被快取住了。實際排查下來,卻發現 API 有正常取回最新的資料,那感覺又不像是單純資料被 Cache 住。接著查到最後,才發現原來從 response 返回的 HTML 開始就已經是錯誤的圖片了,但是那時的我怎麼想都想不透為什麼會發生這麼奇怪的狀況,直到我進一步瞭解 Next.js 的快取機制才知道為什麼會有這個問題。
大家可以先稍微記得一下我這個踩雷的故事,等到把 Next.js 的 Cache 機制完全了解完之後,再回頭想想我這個踩雷的問題有可能是因為什麼而導致。
今天我們就先從了解什麼是 Cache 開始吧!
Cache 又被稱為「快取」,是一種將取得或計算很耗時或耗效能的結果存放在某一個地方的技術。Cache 可以讓使用者在第二次存取時,可以直接取用,不需要再重新進行計算的過程,以此達到優化效能的效果。
這個技術不只可用在圖片、整頁的 HTML、API 回應、資料庫查詢結果,甚至可以使用在應用層的計算結果。
接著就讓我們看看 Next.js 是怎麼將 Cache 這個技術應用於框架中,來優化整個應用程式的效能。
這張圖的來源是 Next.js 官網,這是 App Router 的 Cache 機制圖。接下來我們會以 App Router 的 Cache 機制為基準來了解 Next.js 是怎麼運作 Cache 的部分。
從圖片內容中可以先快速地了解到整個 Next.js 的 Cache 機制不單單只有很單純的 HTML Cache,或是 API response 回來的資料 Cache,而是層層把關,層層依照實際情況進行相對應的 Cache 動作。而且 Next.js 為了提高效能以及降低運算成本,除非有出現一些會讓 Next.js 退出的設定,預設都會使用 Cache 來處理。
如果以使用者視角下去看這個 Cache 的機制會經歷的流程,首先第一個階段會是 Full Route Cache → 接著會進入 Request Memoization 的部分 → 再來是 Data Cache 的部分 → 最後返回頁面及資料到 client 端,才進入了 Router Cache 的部分。
單看名稱就可以很清楚的知道 Next.js 的 Cache 不僅僅含蓋 API 資料面,還涵蓋著 HTML 頁面相關的內容。而且會分為兩個階段,分別是 Server 階段,及 Client 階段。
接著就讓我們花個兩天,來細細了解 Next.js 的 Cache 機制中很重要的這幾個部分:
當使用者對伺服器發送 HTTP 的請求後,Next.js 會去解析 request 的 path,來決定要準備哪一個頁面返回給使用者,所以會先進入到決定是否要使用 Full Route Cache 的流程。
在 HTTP request 的時候,會先去檢查有沒有跳脫 Full Route Cache 的設定,如果沒有,一率會觸發 Full Route Cache。
跳脫 Full Route Cache 的設定如下:
若沒有因為上述提到的條件而跳脫 Full Route Cache,就會繼續觸發 Full Route Cache。這裡需要強調一個部分,那就是並不是所有會觸發 Full Route Cache 的情境,都等於在 build 的階段就會產生 HTML 檔案。
舉例來說的話,如果是動態路由參數的頁面,預設就不會在 build 階段產生 HTML 檔案,如果想要在這樣的頁面中,以 ISR 模式渲染,讓使用者在第二次進入頁面時,可以觸發 Full Route Cache,除了加上 revalidate
外,還需要使用 generateStaticParams
傳回空陣列,或是加上 export const dynamic = 'force-static'
,才能在非第一次進入頁面時,觸發 Full Route Cache。
總結來說,在這個階段 Full Route Cache 的作用主要就是避免多餘的重複渲染,所以當進入沒有必要動態產生的頁面時,就會觸發 Full Route Cache。
這裡也透過兩個實際的例子來看 next.js Cache 的效果。
∙ 例子一:在 build 階段就產生出 HTML 靜態檔案的路由:/static-page
這裡設定一個內容非常單純的頁面,裡面放一個 new Date() 的時間。因為這是一個內容非常單純,沒有任何動態行為的頁面,所以在 build 的階段就有產生出對應的 HTML 檔案。
接下來大家可以思考一下,這個頁面的時間會怎麼顯示?是會隨著重整次數變動,還是不會隨著重整變動呢?
直接來看看結果!
可以發現到網址為 /static-page 的這個 static Route 的頁面,不管重整幾次它顯示的時間都沒有變動。這是因為這個 Route 有觸發 Full Route Cache,所以它顯示的時間,永遠都會是 build 的時候的時間。
這裡也可以觀察到 response header 有顯示 X-Nextjs-Cache HIT,這就代表著這個頁面有觸發 Full Route Cache。
∙ 例子二:雖然 build 階段沒有產生出 HTML,但是有使用 revalidate 搭配 export const dynamic = 'force-static' 的路由:/isr-page/1
這個情境很特殊,雖然沒有在 build 階段就產生相對應的 HTML,但是卻會在第二次進入這個頁面時,觸發 Full Route Cache。
這邊直接來看一下,進入頁面時的實際狀況是什麼?
這裡可以看到我第一次進入頁面顯示的時間,就是當下的時間,這是因為這個頁面並沒有在 build 的時候就預先被產生出來。但是重新整理了幾次後,時間卻又突然改變了,這是因為我有設定 export const revalidate = 5,所以過了五秒後,就會重新渲染。
進入頁面後,第二次重整,會發現 response header 也有顯示 HIT。
放了一陣子,超過我們設定的五秒後,再次重整可以看到變成顯示 STALE。
這代表 Cache 已經過期,並且會在這次 request 先返回舊的內容,同時在背景啟動重新驗證,以及重新產生一次頁面。
首先,要先補充一下,這裡的 Request Memoization 是 React 提供的功能,並不是 Next.js 專屬的功能,Next.js 只是利用這個功能來優化效能而已。
接著再來看一下,Request Memoization 會在哪個階段發生。
當確認到會跳過 Full Route Cache 這個步驟,或是有需要重新渲染畫面的需求後(例如:X-Nextjs-Cache 為 STALE 或 MISS 時),Next.js 會進入 React Server Render 階段來生成新的內容,在這個階段 React 會將同一次渲染時的多個相同的 fetch() 請求以 Cache 的方式處理,這個階段也就是 「Request Memoization」。
也就是說,如果在同一次渲染中,執行多個相同的 GET fetch 請求,React 會將第一個 fetch 請求的結果儲存於記憶體中,當有第二個相同的 Get Fetch 時,會直接使用第一次返回的結果,以避免重複執行相同的 Get Fetch,導致效能被影響。(這裡的相同 GET fetch 請求,不只需要相同的 URL,還包含要是相同的 method、header,以及fetch 的 Cache 設定等...)。
這邊一樣也透過實際的例子來看看當觸發 Request Memoization 時,會是怎樣的情況。
這裡準備了兩個呼叫相同 API 的元件,並且放在同一個 page 檔案內。
export default async function ChildA({ label }: { label: string }) {
const res = await fetch("http://localhost:3000/api/time?key=abc", {
cache: "no-store", // 讓每次請求都會進入 client 端渲染(不跨請求快取)
});
const data = await res.json();
return (
<p className="text-2xl">
<b>{label}</b>: {data.ts}
</p>
);
}
export default async function ChildB({ label }: { label: string }) {
const res = await fetch("http://localhost:3000/api/time?key=abc", {
cache: "no-store", // 讓每次請求都會進入 client 端渲染(不跨請求快取)
});
const data = await res.json();
return (
<p className="text-2xl">
<b>{label}</b>: {data.ts}
</p>
);
}
import ChildB from "./_components/ChildB";
import ChildA from "./_components/ChildA";
const Page = () => {
return (
<div className="w-full h-[300px] flex flex-col justify-center items-center">
<ChildA label="Child A" />
<ChildB label="Child B" />
</div>
);
};
export default Page;
雖然我們讓元件各自呼叫了一次 API,但因為是使用同一個 API,進行完全相同的 fetch 動作,所以實際上會因為 Request Memoization 的關係,只呼叫一次 API。
我們實際進入頁面,也就可以看到顯示的時間是相同的。
terminal 顯示的 console.log 也只會有一組。
雖然 Next.js 很貼心地幫大家利用了 React Request Memoization 這個功能來優化效能,但如果實務上還是有需要個別打多次相同 API 的情況時,還是可以透過使用 AbortController 的 signal 來跳過這個優化處理。
當我們把元件內呼叫 API 的部分改寫成以下這樣後,就會個別打一次 API。
const { signal } = new AbortController();
const res = await fetch("http://localhost:3000/api/time?key=abc", {
cache: "no-store",
signal,
});
這時候就可以看到畫面顯示的時間沒有完全相同。
terminal 上的 console.log 也印出了兩組。
到這裡為止就是 Next.js Cache 機制的其中兩個階段,明天我們再接著來看看剩下的兩個階段,個別在哪個流程中執行,以及實際上會發生什麼樣的事情。