Next.js 16 cacheComponents:從應用路由快取遷移 91,000 個頁面
我們在 Next.js 14 的 App Router 上運行了一個大型電子商務目錄約十八個月,當 Next.js 16 發佈時,我們有 91,247 個頁面。產品列表、分類樹、編輯內容、跨 14 個市場的本地化變體。舊的快取模式——Server Components 預設被快取——已經成為了過時數據 bug 和 revalidateTag 義大利麵的地雷。當 Next.js 團隊宣佈 cacheComponents 以及 Next.js 15 中改為預設不快取的轉變(在 v16 中延續並改進)時,我們知道該行動了。這是那次遷移的故事:什麼有效、什麼無效,以及另一邊的效能數據。
目錄
- 我們實際面臨的快取問題
- Next.js 15 和 16 的變化
- 理解 cacheComponents
- 91,000 個頁面的遷移策略
- 實施:逐步進行
- 效能結果和基準測試
- 陷阱和坑
- 何時應該和不應該使用 cacheComponents
- 常見問題

我們實際面臨的快取問題
讓我描述一下情況。在 Next.js 14 的 App Router 中,Server Components 中的 fetch 請求預設被快取。數據快取在部署間持續存在。完整路由快取在建構時存儲渲染的 HTML 和 RSC 負載。客戶端的 Router 快取保持預取的段…嗯,比預期的時間更長。
對於擁有 91,000 個頁面的網站,這種預設快取所有內容的方法造成了兩類問題:
到處都是過時的數據。 產品價格在我們的無頭 CMS(在我們的案例中是 Sanity)中更新,但快取的 fetch 結果仍然保留。我們在 47 個不同的服務器操作中分散了 revalidateTag 呼叫。漏掉一個標籤?客戶看到昨天的價格。我們真的有一個 Slack 頻道叫 #cache-crimes,內容團隊在那裡報告過時的頁面。
地獄般的建構時間。 完整靜態生成 91,000 個頁面花了超過 3 小時。我們已經轉向使用 revalidate: 3600 的 ISR 來處理大多數頁面,但 ISR、數據快取和按需重驗證之間的互動非常難以推理。團隊中的新開發者會花他們前兩週的時間來理解快取層。
心智模型的認知成本
這是我認為人們低估的東西:隱含快取的認知成本。當快取是預設的且你選擇退出時,每個新元件都要求你問"這應該被快取嗎?"然後記得在答案是否定的情況下添加正確的指令。當不快取是預設的且你選擇加入時,你只在主動想要快取時才思考快取。這是一個根本上不同且更好的心智模型。
Next.js 15 和 16 的變化
Next.js 15 是一個大的哲學轉變。團隊翻轉了預設值:
| 行為 | Next.js 14 | Next.js 15 | Next.js 16 |
|---|---|---|---|
Server Components 中的 fetch() |
預設快取 | 預設不快取 | 預設不快取 |
| Route Handlers (GET) | 預設快取 | 預設不快取 | 預設不快取 |
| Client Router 快取 | 30 秒(動態)/ 5 分鐘(靜態) | 0 秒用於頁面段 | 0 秒預設,可配置 |
| 完整路由快取 | 為靜態路由啟用 | 相同 | 相同,具有 cacheLife 改進 |
| 元件級快取 | unstable_cache |
use cache 指令(實驗性) |
cacheComponents API(穩定) |
Next.js 15 推出了 use cache 指令作為標誌後的實驗性功能。Next.js 16 於 2025 年初發佈,將其穩定為 cacheComponents 配置選項和相關的 "use cache" 指令,以及 cacheLife 用於定義自訂快取設定檔和 cacheTag 用於目標失效。
關鍵洞察:快取從隱含的框架行為轉變為元件級的明確開發者選擇。 這對大型網站來說是一個巨大的變化。
理解 cacheComponents
next.config.js 中的 cacheComponents 功能通過 "use cache" 指令啟用元件級快取。以下是基本設置:
// next.config.js (Next.js 16)
const nextConfig = {
experimental: {
cacheComponents: true,
},
};
module.exports = nextConfig;
啟用後,你可以在任何非同步 Server Component、服務器操作甚至佈局檔案的頂部添加 "use cache":
// app/products/[slug]/page.tsx
"use cache";
import { cacheLife, cacheTag } from 'next/cache';
export default async function ProductPage({ params }: { params: { slug: string } }) {
cacheLife('products'); // 自訂快取設定檔
cacheTag(`product-${params.slug}`);
const product = await fetchProduct(params.slug);
return (
<div>
<h1>{product.name}</h1>
<ProductDetails product={product} />
<DynamicPricing productId={product.id} /> {/* 此元件不被快取 */}
</div>
);
}
cacheLife 設定檔
這對於大型網站變得有趣。你在 next.config.js 中定義命名的快取設定檔:
const nextConfig = {
experimental: {
cacheComponents: true,
cacheLife: {
products: {
stale: 300, // 提供過時快取 5 分鐘
revalidate: 3600, // 1 小時後重驗證
expire: 86400, // 24 小時後硬過期
},
editorial: {
stale: 3600,
revalidate: 86400,
expire: 604800, // 7 天
},
navigation: {
stale: 86400,
revalidate: 604800,
expire: 2592000, // 30 天
},
},
},
};
三層模型(stale、revalidate、expire)很好地映射到過時期間重新驗證語義。在 stale 窗口期間,快取內容立即被提供。在 stale 之後但在 expire 之前,後台重驗證啟動。在 expire 之後,快取項被刪除。
cacheTag 用於失效
cacheTag 函數將舊的 revalidateTag 模式替換為更可組合的東西:
import { revalidateTag } from 'next/cache';
// 在 webhook 處理器或服務器操作中:
export async function handleProductUpdate(productSlug: string) {
revalidateTag(`product-${productSlug}`);
revalidateTag('product-listing'); // 也使列表頁面失效
}
這部分從 Next.js 15 沒有太大改變,但它與 cacheComponents 一起工作得更好,因為你是標記特定的快取元件,而不是試圖使不透明的框架級快取失效。

91,000 個頁面的遷移策略
我們沒有一次性完成。有 91,000 個頁面跨 14 個地區,大爆炸遷移會很魯莽。以下是我們如何分解的:
第 1 階段:升級到 Next.js 16,不進行快取更改(第 1-2 週)
我們從 Next.js 14.2 升級到 16.0,但沒有啟用 cacheComponents。這本身改變了行為,因為 fetch 請求不再預設被快取。我們預期 TTFB 會下降,我們確實看到了:
- 產品頁面的平均 TTFB 從 180 毫秒上升到 340 毫秒
- 原始伺服器負載增加約 60%(我們的 Sanity CDN 表現很好,但我們的自訂 API 端點沒有)
- ISR 重驗證實際上變快了因為需要管理的快取狀態更少
這確認了我們的懷疑:我們一直在依賴隱含快取,許多頁面確實需要快取——只是明確、有意的快取。
第 2 階段:審計和分類頁面(第 3 週)
我們將應用中的每個路由分類:
| 頁面類型 | 計數 | 快取策略 | cacheLife 設定檔 |
|---|---|---|---|
| 產品詳情頁面 | 42,000 | 使用產品標籤快取 | products(5 分鐘過時 / 1 小時重驗證) |
| 分類列表頁面 | 3,200 | 使用分類標籤快取 | products(5 分鐘過時 / 1 小時重驗證) |
| 編輯/部落格頁面 | 8,400 | 積極快取 | editorial(1 小時過時 / 24 小時重驗證) |
| 本地化變體 | 31,647 | 與基礎頁面相同 | 從基礎繼承 |
| 帳戶/動態頁面 | 6,000 | 不快取 | N/A |
第 3 階段:啟用 cacheComponents,添加指令(第 4-6 週)
我們啟用了該標誌並開始添加 "use cache" 指令。關鍵決定:我們為大多數路由在頁面級別快取,但為具有混合靜態/動態內容的頁面在元件級別快取。
對於產品頁面,產品信息和圖像被快取,但定價元件和庫存狀態保持不快取:
// components/ProductInfo.tsx
"use cache";
import { cacheLife, cacheTag } from 'next/cache';
export async function ProductInfo({ slug }: { slug: string }) {
cacheLife('products');
cacheTag(`product-${slug}`, 'product-info');
const product = await getProduct(slug);
return (
<section>
<h1>{product.name}</h1>
<p>{product.description}</p>
<ProductImages images={product.images} />
</section>
);
}
// components/DynamicPricing.tsx
// 沒有 "use cache" 指令 -- 始終新鮮
export async function DynamicPricing({ productId }: { productId: string }) {
const pricing = await getPricing(productId); // 每次請求都命中定價 API
return (
<div className="pricing">
<span className="price">${pricing.current}</span>
{pricing.onSale && <span className="was-price">${pricing.original}</span>}
</div>
);
}
第 4 階段:Webhook 整合(第 7 週)
我們重新連接了 Sanity webhook 來調用具有正確標籤的 revalidateTag。這實際上比我們的舊設置更簡單,因為標籤現在在代碼中是明確的,而不是分散在 fetch 選項中。
// app/api/revalidate/route.ts
import { revalidateTag } from 'next/cache';
import { NextRequest } from 'next/server';
export async function POST(request: NextRequest) {
const body = await request.json();
const secret = request.headers.get('x-webhook-secret');
if (secret !== process.env.REVALIDATION_SECRET) {
return new Response('Unauthorized', { status: 401 });
}
switch (body._type) {
case 'product':
revalidateTag(`product-${body.slug.current}`);
revalidateTag('product-listing');
break;
case 'category':
revalidateTag(`category-${body.slug.current}`);
revalidateTag('navigation');
break;
case 'article':
revalidateTag(`article-${body.slug.current}`);
break;
}
return new Response('OK', { status: 200 });
}
實施:逐步進行
如果你進行類似的遷移,這裡是我們建議的實踐手冊(以及我們現在在 Social Animal 用於 Next.js 開發項目的方法):
步驟 1:啟用標誌
// next.config.js
module.exports = {
experimental: {
cacheComponents: true,
cacheLife: {
// 從合理的預設開始
default: {
stale: 60,
revalidate: 900,
expire: 86400,
},
},
},
};
步驟 2:找到你的熱路徑
使用你的分析來識別獲得最多流量且 TTFB 很重要的頁面。對我們來說,這是分類頁面(高流量,內容相對穩定)和產品頁面(高流量,內容適度動態)。
步驟 3:由上而下添加 `"use cache"`
從佈局開始。如果你的根佈局獲取導航數據,首先快取那個——這是最高影響、最低風險的改變:
// app/layout.tsx
// 注意:"use cache" 在佈局上快取佈局外殼
// 子頁面仍然獨立渲染
import { Navigation } from '@/components/Navigation';
export default async function RootLayout({ children }) {
return (
<html>
<body>
<Navigation /> {/* 此元件有自己的 "use cache" */}
{children}
</body>
</html>
);
}
步驟 4:設置監控
我們使用 Vercel 的內置分析加上自訂日誌來跟踪快取命中率。在啟用 cacheComponents 的第一週,我們的快取命中率僅為 34%。調整 stale 持續時間後,它上升到 78%。
效能結果和基準測試
以下是在 Vercel Pro 計畫上測量 30 天期間後完整遷移的真實數據:
| 指標 | 前(Next.js 14) | 後第 1 階段(v16,不快取) | 完整遷移後 |
|---|---|---|---|
| 平均 TTFB(產品頁面) | 180 毫秒 | 340 毫秒 | 95 毫秒 |
| 平均 TTFB(分類頁面) | 220 毫秒 | 410 毫秒 | 72 毫秒 |
| 平均 TTFB(編輯頁面) | 150 毫秒 | 280 毫秒 | 45 毫秒 |
| P99 TTFB(所有頁面) | 1,200 毫秒 | 2,100 毫秒 | 380 毫秒 |
| 構建時間(完整) | 3 小時 12 分鐘 | 2 小時 48 分鐘 | 48 分鐘 |
| Vercel 函數調用次數/天 | 2.4M | 3.8M | 1.1M |
| 月度 Vercel 帳單 | ~$840 | ~$1,200 | ~$520 |
| 快取命中率 | 未知(隱含) | N/A | 78% |
| 過時內容事件(#cache-crimes) | 8-12/週 | 0 | 1-2/月 |
構建時間的改進值得解釋。有了 cacheComponents,我們從在構建時生成所有 91,000 個頁面移開。相反,我們靜態生成了前 5,000 個頁面(按流量),讓其他頁面按需生成並快取。cacheComponents 指令意味著那些按需頁面在首次訪問後被快取,cacheLife 控制過時性。
Vercel 帳單下降很重要。更少的函數調用(因為明確的元件快取)加上更短的構建時間意味著真實的成本節省。~$320/月的減少可以自我支付。
陷阱和坑
序列化邊界
"use cache" 指令創建了一個序列化邊界。作為 props 傳遞到快取元件的所有內容都必須可序列化。我們有幾個接收回調函數或 React 元素作為 props 的元件——那些立即破裂了。修復是重新調整為使用組合模式:
// ❌ 這在 "use cache" 中破裂
"use cache";
export async function ProductCard({ product, onAddToCart }) {
// onAddToCart 是一個函數 -- 不可序列化!
}
// ✅ 這有效
"use cache";
export async function ProductCard({ product }) {
return (
<div>
<h2>{product.name}</h2>
{/* AddToCart 是一個 Client Component,不被快取 */}
<AddToCartButton productId={product.id} />
</div>
);
}
動態參數和快取鍵空間爆炸
有 91,000 個頁面,每個都有唯一的參數,快取鍵空間是巨大的。我們在第一週撞到了 Vercel 邊緣快取限制,必須對哪些頁面獲得長 expire 值更有策略。低流量長尾頁面獲得更短的快取持續時間。
`Date.now()` 陷阱
任何使用 "use cache" 的元件,在快取函數內調用 Date.now() 或 new Date() 將快取該時間戳。我們在"最後更新"顯示中找到了這個,它顯示相同的時間數小時。修復:將時間敏感的邏輯移動到 Client Component 或未快取的 Server Component。
嵌套快取邊界
當你在其他快取元件內嵌套快取元件時,內部快取有其自己的生命週期。這很強大但令人困惑。我們建立了一個團隊慣例:在頁面級別或元件級別快取,不兩者,除非有明確的原因。
何時應該和不應該使用 cacheComponents
在以下情況使用它:
- 你有超過幾百個頁面,ISR 構建時間令人痛苦
- 你的內容有明確的新鮮度要求,因部分而異
- 你需要對什麼被快取與始終新鮮進行細粒度控制
- 你在 Vercel 或支持 Next.js 快取層的平台上運行
- 你想減少高流量網站的基礎設施成本
不要在以下情況使用它:
- 你的網站很小,完整 SSG 效果很好
- 每個頁面都是完全動態的(到處都是用戶特定的內容)
- 你不在支持 Next.js 快取基礎設施的託管平台上
- 你的團隊對 Next.js 不熟悉——先學習基礎知識
如果你在評估你的項目是否需要這種級別的快取控制,或者 Astro 等不同框架對你的內容豐富的網站可能是更好的選擇,在提交遷移之前值得思考。
對於來自多個無頭 CMS 來源的內容的項目,Next.js 16 中的 cacheTag 系統與 無頭 CMS 架構配合得很好——每個內容類型獲得自己的失效通道。
常見問題
Next.js 16 中的 cacheComponents 是什麼?
cacheComponents 是 Next.js 16 中的實驗性配置選項,啟用 Server Components 的 "use cache" 指令。它讓你明確標記哪些元件應該被快取並使用 cacheLife 定義自訂快取設定檔。它是 Next.js 15 中實驗性 use cache 指令的穩定演進。
cacheComponents 與 ISR(增量靜態再生)有什麼不同?
ISR 快取整個頁面並按時間表重驗證它們。cacheComponents 讓你快取頁面內的個別元件,每個都有不同的快取生命時間。單個頁面可以有標題快取 24 小時、產品信息快取 1 小時和從不快取的定價。ISR 做不到——在頁面級別全有或全無。
我需要在 Vercel 上才能使用 cacheComponents 嗎?
不需要,但在 Vercel 上的體驗最好,因為快取基礎設施緊密整合。自託管的 Next.js 部署可以使用 cacheComponents 與檔案系統快取適配器,但你不會獲得邊緣分佈優勢。Netlify 和 Cloudflare 等平台正在添加支持,但截至 2025 年中期,Vercel 仍然是最完整的實現。
我如何在 Next.js 16 中使已快取的元件失效?
你使用 cacheTag() 在快取元件內分配標籤,然後從服務器操作、路由處理器或 webhook 端點調用 revalidateTag('tag-name')。這使所有帶有該標籤的快取元件失效。這是來自 Next.js 15 的相同 API,但它現在更有用,因為你是標記明確的快取元件而不是隱含的框架快取。
cacheComponents 會降低我的 Vercel 帳單嗎? 它可以顯著降低成本。在我們的案例中,函數調用下降了 54%,因為快取的元件響應是從快取層提供而不是調用無伺服器函數。構建時間減少也節省構建分鐘。你的成果將根據流量模式和快取命中率而異——使用你目前的使用情況檢查 Vercel 的定價計算器。
如果我將 "use cache" 添加到接收不可序列化 props 的元件會發生什麼?
你會收到一個構建錯誤。"use cache" 指令創建了一個序列化邊界,所以所有 props 都必須可序列化(字符串、數字、普通對象、陣列)。函數、React 元素、類實例和其他不可序列化的值將導致構建失敗。重新調整你的元件以僅接受數據 props 並在子 Client Components 中處理互動。
我可以將 cacheComponents 與其他框架的 React Server Components 一起使用嗎?
不可以。cacheComponents 是在 React 的 Server Components 之上構建的 Next.js 特定功能。雖然 "use cache" 指令語法最終可能成為 React 標準,但 cacheLife 設定檔和 cacheTag 系統是 Next.js API。如果你使用 Remix 或自訂 RSC 設置之類的框架,你需要不同的快取策略。
將大型 Next.js 網站遷移到 cacheComponents 需要多長時間? 對於我們有 4 名開發者的 91,000 頁網站,包括測試和監控在內的完整遷移耗時 7 週。較小的網站(少於 10,000 個頁面)具有更簡單的數據模型,可能可以在 1-2 週內完成。實際的代碼更改很簡單——時間用於審計快取需求、測試失效流程和在部署後監控快取命中率。如果你寧願不自己進行,與我們聯繫——我們已經這樣做過幾次了。