我們在 Next.js 14 的 App Router 上運行了一個大型電子商務目錄約十八個月,當 Next.js 16 發佈時,我們有 91,247 個頁面。產品列表、分類樹、編輯內容、跨 14 個市場的本地化變體。舊的快取模式——Server Components 預設被快取——已經成為了過時數據 bug 和 revalidateTag 義大利麵的地雷。當 Next.js 團隊宣佈 cacheComponents 以及 Next.js 15 中改為預設不快取的轉變(在 v16 中延續並改進)時,我們知道該行動了。這是那次遷移的故事:什麼有效、什麼無效,以及另一邊的效能數據。

目錄

Next.js 16 cacheComponents:將 91,000 個頁面從 App Router 快取遷移

我們實際面臨的快取問題

讓我描述一下情況。在 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 天
      },
    },
  },
};

三層模型(stalerevalidateexpire)很好地映射到過時期間重新驗證語義。在 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 一起工作得更好,因為你是標記特定的快取元件,而不是試圖使不透明的框架級快取失效。

Next.js 16 cacheComponents:將 91,000 個頁面從 App Router 快取遷移 - 架構

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 週內完成。實際的代碼更改很簡單——時間用於審計快取需求、測試失效流程和在部署後監控快取命中率。如果你寧願不自己進行,與我們聯繫——我們已經這樣做過幾次了。