SSG, ISR 與 分頁
在 Next.js 使用 SSG (Static Site Generation, 靜態頁面生成) 和 ISR (Incremental Static Regeneration, 增量靜態生成) 時, 伺服器端生成的靜態分頁頁面很容易出現這樣的問題 (以博客系統為例):
例如當你訪問 /blog/page/1
時 (假設在上次內容更新後沒有人到訪過網站),它將首先顯示 Cache 中的舊內容。如果距離上一次頁面重生成的時間超出了設定的 revalidate 時間(舉例, 5 分鐘)則進行重新驗證。而當導航到 /blog/page/2
時,也是一樣的過程。
這樣會導致當你在一個分頁頁面上看到新文章時,另一個分頁頁面卻顯示舊的內容。具體的場景可能是這樣的, 一般第一頁會有最多人訪問, 第二頁次之, 當有幾個人看過第一頁後觸發了第一頁的重新驗證, 但當第二頁迎來第一個訪客時看到的卻是過時的內容。當我新增一篇文章, 最新的文章被推到第一頁頂部, 一頁最多 5 篇文章, 原來的第 5 篇文章會被推到第二頁頂部, 但由於第二頁是過時的內容, 如果第二頁的第一個訪客是從第一頁導航而來的話, 理應在剛才推到第二頁頂部的文章會消失不見!
這種情況在多個頁面之間造成了不一致性。例如,當有新的文章添加到第一頁時,第二頁的內容可能沒有即時更新,導致用戶在不同頁面之間看到的文章順序和內容不一致。若用戶從第一頁導航到第二頁,卻看到過時的文章,尤其是當文章本應在第二頁顯示時,這會造成混淆。
對於這種問題, 比較好的方案應該是利用博客 CMS 系統的 Webhook 功能 (例如 Strapi 就有 Webhook 功能) 來手動觸發更新所有靜態分頁頁面(必須是所有! 因為最新的文章被推到第一頁頂部時, 其他所有頁面應該顯示的文章都會向後移)
Webhook 是一種用於應用程式之間實時通信的方式。當某個事件發生(例如在 CMS 中新增文章),Webhook 可以自動發送 HTTP 請求到指定的 URL。
實現步驟如下:
- 設定 Webhook:在你的 CMS(如 Strapi)中,設定當有文章新增、更新或刪除時觸發 Webhook。
- 處理請求:在你的 Next.js 應用中,設置一個 API 路由來接收 Webhook 請求。
- 觸發重新生成:當收到 Webhook 請求後,使用 Next.js 的
res.revalidate()
方法,手動觸發所有相關分頁的重新生成。(可以在伺服器端發送請求查詢 CMS 的分頁數)
本博客用的即是這種方法, 代碼如下:
import { NextApiRequest, NextApiResponse } from "next"; import client from "@/lib/apollo-client"; import { gql } from "@apollo/client"; export default async function handler( req: NextApiRequest, res: NextApiResponse ) { // Check for secret to confirm this is a valid request if (req.query.secret !== process.env.MY_SECRET_TOKEN) { return res.status(401).json({ message: "Invalid token" }); } const postEventChangePage = [ "entry.delete", "entry.publish", "entry.unpublish", ]; try { if (req.body.model === "post") { if (req.body.event === "entry.update") { await res.revalidate(`/blog/post/${req.body.entry.id}`); return res.json({ revalidated: true, type: "post" }); } else if (postEventChangePage.includes(req.body.event)) { const postsPerPage = 5; const pagesCountQuery = gql` query getPageCount($pageSize: Int!) { getTotalPages: posts(pagination: { pageSize: $pageSize }) { meta { pagination { pageCount } } } } `; const { data: mainCatPageData } = await client.query({ query: pagesCountQuery, variables: { pageSize: postsPerPage, }, }); const pagesCount = +mainCatPageData.getTotalPages.meta.pagination.pageCount; if (pagesCount) { for (let page = 1; page <= pagesCount; page++) { await res.revalidate(`/blog/page/${page}`); } } return res.json({ revalidated: true, type: "page", }); } } else { return res.json({ revalidated: false, type: req.body.model || "Unknown", }); } } catch (err) { // If there was an error, Next.js will continue // to show the last successfully generated page return res .status(500) .json({ message: "Error revalidating", id: req.body.entry.id }); } }
順帶一提,如果用 Webhook 觸發手動重建的話,設定 Next.js 的 revalidate 時間似乎沒有意義了。當你在 CMS 中創建、更新或刪除內容時,Webhook 可以立即通知 Next.js 重新生成相關頁面,用戶可以看到最新的內容,無需等待 ISR 的後台重新生成過程。如果你依賴 revalidate,即使沒有內容更新,Next.js 也會在每個 revalidate 間隔後去檢查頁面是否需要重新生成。而 Webhook 方式只會在內容實際更新時觸發重建,減少了不必要的計算和資源消耗。
因此可以考慮將 revalidate 設置為一個非常大的值,甚至可以忽略它(把它設置為 false 或不設置 revalidate)。這樣頁面只會在 Webhook 手動觸發時重新生成,而不會每隔固定時間(同時有人訪問網站觸發)進行檢查。
例如這樣:
export async function getStaticProps() { const posts = await getPostsFromStrapi(); return { props: { posts, }, revalidate: 43200, // 12 小時 = 43200 秒 }; }