在你第三次遇到正式環境快取事故之後,會有一種特別的感覺。

那不是恐慌。比恐慌更糟。那是一種安靜的領悟:你確實修好了上一個 bug,但你仍然完全不知道下一個 bug 躲在哪裡。

《7 個無聲快取 bug》 之後,留言區一直反覆出現同一個模式。每個人都懂哪裡壞了,但不知道正確的設定應該長什麼樣子。這篇就是那個答案。

不是理論。這是我現在實際使用的系統,是在被燙傷夠多次之後,才真正理解每個部分為什麼存在。

第一個問題:不同檔案裡憑記憶寫出的 tag 字串

Next.js 16 裡大多數無聲的快取 bug 都是從這裡開始。不是因為有人粗心,而是因為根本沒有機制阻止兩個人寫出兩個本來應該相同、卻不同的字串。

開發者 A 週一寫了資料函式:

async function getProducts() {
  'use cache'
  cacheTag('product-list')
  return db.query('SELECT * FROM products')
}

兩週後,開發者 B 在另一個檔案裡寫了更新動作:

export async function createProduct(data: ProductData) {
  await db.query('INSERT INTO products ...', [...])
  revalidateTag('products', 'max')
}

product-listproducts。兩個不同的字串。TypeScript 不會報錯,Next.js 也不會警告。新增商品之後,商品列表永遠不會刷新,直到有人把兩個檔案同時打開才知道原因。

這就是上一篇文章裡的 Bug 3。我即使已經知道它,還是在程式碼庫的不同地方一直踩到變形版本,因為「知道有這個問題」和「有東西能防止它」是兩回事。

解法是用一個檔案統一管理所有 tag 字串:

// lib/tags.ts
export const tags = {
  product: (id: string | number) => `product-${id}`,
  user: (id: string | number) => `user-${id}`,
  productList: 'products',
  userList: 'users',
  navigation: 'navigation',
} as const

現在兩個檔案都從 tags 引入。拼錯會直接變成 TypeScript 編譯錯誤。字串不一致的 bug 根本不會發生。團隊裡每個人都能用自動完成,不用靠肌肉記憶。

// 資料函式
cacheTag(tags.productList)

// mutation — 同一個 import、同一個字串,保證一致
revalidateTag(tags.productList, 'max')

這是到目前為止,從我的程式碼庫裡移除最多 bug 的單一改動。新專案一開始就先把它設好,再寫任何快取函式。

第二個問題:有三個不同地方可以讓快取失效,卻對應三種不同正確 API

這是我看到最多混淆的地方,包括我自己早期的程式也一樣。你要用哪個 API,完全取決於你是從哪裡呼叫,以及使用者需要看到什麼。用錯了,不是 runtime 直接丟錯,就是默默讓人看到過期資料。

我現在會這樣理解:

在 Server Action 裡,而且剛剛做完變更的使用者需要立刻看到結果時:

'use server'
import { updateTag, revalidateTag } from 'next/cache'

export async function updateProductPrice(id: string, newPrice: number) {
  await db.query('UPDATE products SET price = $1 WHERE id = $2', [newPrice, id])

  updateTag(tags.product(id))               // 操作的使用者立刻看到新資料
  revalidateTag(tags.product(id), 'max')    // 其他人則走 SWR 更新
  revalidateTag(tags.productList, 'max')    // 商品列表也一起更新
}

這裡順序很重要。updateTag 要先執行。這可以避免管理員按下儲存、再返回商品頁時看到舊價格。那看起來就像儲存失敗,會讓人再按一次儲存。updateTag 就是用來解決這件事的。

updateTag 只能在 Server Action 裡使用。在其他地方呼叫,runtime 會直接丟錯。

在 Route Handler 裡(webhook、外部服務):

// app/api/webhooks/stripe/route.ts
import { revalidateTag } from 'next/cache'

export async function POST(req: Request) {
  const event = await parseStripeWebhook(req)
  if (event.type === 'price.updated') {
    revalidateTag(tags.productList, { expire: 0 })
  }
  return new Response('ok', { status: 200 })
}

updateTag 在 Route Handler 裡不可用。這裡若要立即失效,等價做法是 { expire: 0 }。當第三方系統剛告訴你資料變了,這就是你要的行為。

背景更新,而且可以接受短暫的過期窗口時:

revalidateTag(tags.productList, 'max')

stale-while-revalidate。使用者會先拿到快速的快取回應,新的資料則在背景更新。對大多數內容來說,這正是正確做法。像管理員發布新文章,讀者可能短暫看到舊列表,通常是可接受的。

整個判斷可以整理成這個表:

情境 使用方式
使用者編輯自己的資料,且需要立刻看到變更 updateTag,再 revalidateTag
webhook 觸發,第三方服務需要立即一致 revalidateTag(tag, { expire: 0 })
背景刷新,可接受短暫過期窗口 revalidateTag(tag, 'max')

把這個寫在團隊看得到的地方。可以省下很多「為什麼使用者儲存後還看到舊資料」的討論。

第三個問題:PPR 的切分預設是看不見的

cacheComponents: true 下,Next.js 會使用 Partial Prerendering。你的頁面會有一個靜態外殼,從快取立即渲染;也會有動態洞,之後再串流進來。效能提升是真的。但問題是,哪些內容會進外殼、哪些會變成動態洞,在出問題之前都不明顯。

一個用了 cacheLife('seconds') 的元件,會默默被排除在靜態外殼之外。快取範圍裡的 cookies() 呼叫會在 build 時拋出「Uncached data was accessed outside of Suspense」,但不會告訴你元件名稱、檔案路徑,沒有任何有用資訊。沒加上 Suspense 邊界的動態元件,會把頁面的一部分推到外殼之外。

我後來停止猜測的方法,是在元件層級直接記錄意圖:

// components/UserCart.tsx
export const boundary = {
  name: 'UserCart',
  isDynamic: true,
  reason: '讀取使用者 session cookie — 每個使用者不同',
}

接著在使用它的頁面裡,明確引用這個意圖:

export default async function ProductPage({
  params,
}: {
  params: Promise<{ id: string }>
}) {
  const { id } = await params

  // UserCart 是動態的 — 必須放在 Suspense 裡,否則會破壞靜態外殼
  return (
    <div>
      <ProductDetails id={id} />       {/* 快取內容,屬於靜態外殼 */}
      <RelatedProducts id={id} />      {/* 快取內容,屬於靜態外殼 */}
      <Suspense fallback={<CartSkeleton />}>
        <UserCart productId={id} />    {/* 動態內容,之後串流載入 */}
      </Suspense>
    </div>
  )
}

快取元件會像這樣:

async function ProductDetails({ id }: { id: string }) {
  'use cache'
  cacheLife('hours')
  cacheTag(tags.product(id))

  const product = await db.query(
    'SELECT * FROM products WHERE id = $1', [id]
  )
  return <article>...</article>
}

動態元件則完全不寫 'use cache'

async function UserCart({ productId }: { productId: string }) {
  const cookieStore = await cookies()
  const userId = cookieStore.get('user-id')?.value
  const cartItem = await db.query(
    'SELECT * FROM cart WHERE user_id = $1 AND product_id = $2',
    [userId, productId]
  )
  return cartItem ? <InCartButton /> : <AddToCartButton />
}

靜態外殼會立刻顯示給使用者。購物車則在之後串流進來。這個切分是有意圖、有文件的,而不是靠演算法勉強撐下來的結果。

這裡還有一件事要注意:不要在 'use cache' 範圍內呼叫 cookies()headers()draftMode()。要在外面先讀出來,再把值當作 props 傳進去。這些值會自動成為快取 key 的一部分——不同使用者會產生不同的快取專案,不需要你額外做任何事。

第四個問題:每次部署後,第一個訪客都會被冷啟動拖慢

這一點跟 bug 不完全一樣,但和同一個目標有關。你的快取設定是對的。你部署了。第一個訪客打開頁面時,所有快取函式都會因為快取是空的而從頭依序執行。

PPR 在快取熱起來之後很快,但部署後的第一個請求不是。

解法是用 React 的 cache() 做 request 層級去重。在頁面最上方、任何元件需要之前,就先把所有資料抓取平行啟動:

import { cache } from 'react'
import { getProductById, getRelatedProducts } from '@/lib/data'

const prefetch = {
  product: cache(getProductById),
  related: cache(getRelatedProducts),
}

export default async function ProductPage({
  params,
}: {
  params: Promise<{ id: string }>
}) {
  const { id } = await params

  void prefetch.product(id)
  void prefetch.related(id)

  return (
    <div>
      <ProductDetails id={id} />
      <RelatedProducts id={id} />
      <Suspense fallback={<CartSkeleton />}>
        <UserCart productId={id} />
      </Suspense>
    </div>
  )
}

兩個抓取會立刻平行執行。子元件若呼叫相同函式,會從 React 的 cache() 拿到去重後的結果。如果預抓失敗,也只會默默失敗。它只是效能優化,不是必要條件。子元件裡真正的抓取流程仍然會正常運作。

值得知道的差別是:React 的 cache() 只會在單一請求內去重;'use cache' 則會跨請求持久化。這兩個都需要,因為它們解決的是不同問題。

整個系統長什麼樣子

一個 tags 檔案。所有人都從這裡匯入。拼錯是編譯錯誤,不是正式環境事故。

失效上下文有明確決策:Server Action 裡、而且有使用者在等結果時,先用 updateTag,再用 revalidateTag。Route Handler 用帶 { expire: 0 }revalidateTag。背景廣播則用 revalidateTag 搭配 'max'

動態元件有文件,並且一定包在 Suspense 裡。靜態外殼是明確的,不是碰運氣的。

在重量級頁面最上方平行預抓,這樣部署後的第一個訪客不會成為冷啟動成本的買單者。

一旦把這些寫下來,其實都不複雜。難的是要意識到自己需要全部這些東西,而這花了我足夠多的正式環境 bug 才看出模式。

這個系列的前幾篇文章,記錄了我是怎麼走到這裡的。我打造了一個除錯器,因為開發階段像個黑盒子。那 7 個 bug 會編譯通過,卻在正式環境無聲壞掉。升級後的那些 build 不會提醒你的破壞性變更

如果你想看完整的遷移參考,我把它寫在 shubhra.dev/tutorials/nextjs-16-cache-components

我一直反覆踩到這些邊界案例,最後乾脆把整套系統收斂成一個工具。Cache Pro Kit 就是這篇文章所有內容的正式版。型別安全的 tag 註冊表、能在編譯期阻擋單一參數呼叫的 safeRevalidate、強制正確順序的 serverActionInvalidate、讓 Route Handler 裡的 updateTag 變成不可能的 routeHandlerInvalidate。一個檔案,直接丟進 lib/ 就能用。

你現在的快取架構長什麼樣子?你自己專案裡有遇過這些問題嗎?


原文出處:https://dev.to/shubhradev/after-7-nextjs-16-caching-bugs-i-stopped-guessing-and-built-a-system-4ijp


精選技術文章翻譯,幫助開發者持續吸收新知。

共有 0 則留言


精選技術文章翻譯,幫助開發者持續吸收新知。
🏆 本月排行榜
🥇
站長阿川
📝6   💬2  
337
🥈
我愛JS
💬1  
3
🥉
alicec
1
評分標準:發文×10 + 留言×3 + 獲讚×5 + 點讚×1 + 瀏覽數÷10
本數據每小時更新一次
📢 贊助商廣告 · 我要刊登