沉默的 500:一次 Nuxt 3.21 中文 URL prerender 的除錯紀錄
前一篇結尾預告了這次改版唯一一個讓我卡住超過幾小時的 bug。獨立寫一篇,因為它的「形狀」比細節更值得記。
現象
把 Nuxt 從 3.9 升到 3.21、Nitro 也跟著上 2.13 之後,pnpm generate 跑下去,4 個中文 tag 頁全部 prerender 500:
[500] /tag/隨筆
[500] /tag/前端開發
[500] /tag/台股
[500] /tag/技術筆記
奇怪的點來了:
pnpm dev打同樣 URL ── 200 OK,畫面正常- 英文 tag 頁 prerender ── 全部 200
- Nitro 完全沒吐出任何錯誤訊息。Body 空、沒有 stack trace、沒有 throw 進 error hook
- 只有那行
[500]
換句話說:Nitro 知道 render 失敗了,但拒絕告訴我為什麼。 這是這個 bug 最反直覺的地方 ── 在「沒有錯誤訊息」的前提下,要憑現象反推根因。
第一個彎路:懷疑 <VApp>
直覺反應:「中文 + Vuetify ── 大概是 <VApp> 包 SSR 的時候對 unicode route 處理有 bug 吧。」
當時 Phase A 還沒拔 Vuetify。我跑去把 app.vue 的 <VApp> 拿掉,再跑一次 generate。
結果:4 個 500 變成 1 個 500。
「啊有效!剩 1 個應該是另一個 bug!」── 這就是這次除錯最大的陷阱。
致命的假希望:crawler 短路
「4 個變 1 個」是真的,但不是「修好了 3 個」。
事實是:拿掉 <VApp> 之後,/ 首頁也跟著掛掉了(Vuetify 的 component 散落在 layout 跟 component 裡,沒了 <VApp> 直接 render error)。
而 Nitro 的 prerender 是 crawler-based ── 從 entry route 開始,跟著頁面裡的 <a href> 爬下去。首頁掛掉之後:
/ → ❌ 500(render error,crawler 拿不到 HTML)
↓
沒有 link 可以爬
↓
/tag/隨筆 / 前端開發 / 台股 / 技術筆記 → 根本沒被嘗試
↓
唯一還在 prerender queue 的:之前手動加進 routes 的某個英文 tag
↓
那個也 500
↓
最終 log 看起來:「只剩 1 個 500」
我以為 4 變 1 是「拿掉 <VApp> 修好了 3 個」,實際上是「拿掉 <VApp> 之後 3 個中文頁根本沒被測試」。
這個假希望讓我繼續往「Vuetify 是兇手」的方向追了一陣 ── 改 Vuetify 的 SSR 設定、試各種 plugin 順序,全部白工。
第一個帶走的東西:當「修了某件事之後問題變少」的時候,要先確認問題真的被測到了,而不是被跳過了。Crawler-based 的 prerender 特別容易製造這種錯覺。
兩個更短的彎路
回到「中文 URL」這個現象本身,後面又連續走了兩個彎路:
彎路二:useAsyncData 的 key 含中文。 Tag 頁的 query 是 useAsyncData(\tag-${tag}`, ...),key 直接帶中文進去。懷疑 Nuxt 對 cache key 的 serialization 對 unicode 有 bug。把 key 改成 useAsyncData(encodeURIComponent(tag), ...)` ── 沒救。
彎路三:router.push 的 redirect 迴圈。 pages/tag/[tag]/index.vue 裡有一段 watch({ immediate: true }),會 router.push(tagLink(tag.value)) ── 我懷疑這是「推到自己」的迴圈把 prerender 卡死。把 watch 拿掉 ── 還是 500。
(這段 redirect-to-self 後來證實是另一個 pre-existing bug。index.vue 永遠 no-op,原本就該刪。一併清掉了,但跟這次的 500 無關。)
到這裡為止,所有「站在現象上面看」的假設都試完了。三個彎路、零進展,連一個有用的錯誤訊息都拿不到。
換工具:Nitro 的 stderr 是黑洞
走到這個地步,我意識到一件事 ── 我從頭到尾沒看過任何錯誤訊息,是因為 Nitro 在 prerender 階段把 stderr 過濾掉了。
console.error 在 prerender phase 不會出現在 build log。我在 page setup 裡塞了一堆 console.error('HERE 1') / console.error('HERE 2'),build 跑完一行都沒看到。這就是為什麼 Nitro 只給我一行 [500] ── 不是它沒拋錯,是它的 stderr 不會冒出來。
換工具:直接寫檔。
// pages/tag/[tag]/index.vue
import { appendFileSync } from 'node:fs'
const log = (msg: string) => {
if (import.meta.server) {
appendFileSync('/tmp/prerender-debug.log', `${new Date().toISOString()} ${msg}\n`)
}
}
log(`tag page setup start, tag=${route.params.tag}`)
const { data } = await useAsyncData(...)
log(`tag page setup done, count=${data.value?.length}`)
tail -f /tmp/prerender-debug.log 開著、跑 generate ── 終於看到 setup 跑完了、data 也拿到了,500 是發生在 setup 之後的某個階段。問題不在 page render,是在 page render 之後 Nitro 想做的事。
第二個帶走的東西:Nitro prerender 階段,
fs.appendFileSync比console.error可靠。stderr 是黑洞,檔案系統不是。Debug prerender bug 的時候直接寫檔。
真兇:payloadExtraction
「page render 之後 Nitro 想做的事」是什麼?翻 Nuxt 的 experimental options1,第一個進視線的就是:
// 預設值
experimental: {
payloadExtraction: true
}
這個 option 開啟的時候,Nuxt 會把每個 prerender route 的 data 額外輸出成一個 _payload.json 檔,存在 /route/_payload.json 路徑下,給 client-side navigation 抓 data 用2。
也就是說,prerender 一個 route 實際上會產出兩個檔案:
/tag/隨筆/index.html/tag/隨筆/_payload.json
而 Nuxt 3.21 + Nitro 2.13 在處理「中文 URL 的 payload 寫入」這一步的時候 crash。Crash 沒被 catch、沒被 log、直接讓整條 render 流程回 500。Page setup 已經跑完了,data 也拿到了,但寫 _payload.json 的時候掛了,最終報出來的是 page 的 500,不是 payload 的 500。
解法一行:
// nuxt.config.ts
export default defineNuxtConfig({
experimental: {
payloadExtraction: false
}
})
跑 generate ── 4 個中文 tag 頁全綠。
Trade-off
關掉 payloadExtraction 的代價是:client-side 換頁的時候,Nuxt 會抓整個目標 HTML 而不是只抓一個 JSON payload。
對純靜態 blog 而言:
- 我的頁面平均 HTML 大小本來就不大(沒有大量 SSR data)
- 文章內容是 MDC 編譯後的 HTML,pages 之間不會共用一個 JSON payload
- Pagefind / RSS / sitemap 的產出完全不依賴 payload extraction
所以這個 trade-off 對這個 blog 來說近乎零成本。payloadExtraction 真正會發揮價值的是「動態列表、SSR-heavy、client navigation 頻繁」的場景,跟 content-first blog 不在同一個象限。
未來 Phase B 升 Nuxt 4 的時候會回來重測 ── 行為可能已經改了,能恢復預設就恢復。
兩個帶走的東西
把這次踩坑學到的兩件事整理出來,未來踩到同類型 bug 可以拿來用:
1. 沉默的失敗最貴。
當錯誤訊息被框架吞掉、log 只剩一行 [500] 的時候,第一件事不是猜根因,是換工具拿到訊號。console.error 不行就 appendFileSync、appendFileSync 不行就在每一步插 explicit checkpoint。沒有訊號就沒有偵錯,先把這條補上再開始推論。
2. 「修了某件事之後問題變少」未必是修對。
Crawler-based、retry-based、queue-based 的系統特別容易製造「症狀減少」的錯覺。確認你的修改沒有把待測項目跳過,再相信減少是真的減少。這次是 <VApp> 拿掉之後 crawler 從首頁短路,把中文 tag 頁全部跳過 ── 看起來「4 變 1」其實是「3 個沒被測」。
回呼一下前兩篇 ── 設計篇在找詞彙、工程篇在找分界線、這篇在找訊號。三個改版過程裡實際發生的「卡住」,本質都是「我手上的工具不足以表達或測量這個問題」。詞彙、分界線、訊號 ── 拿到工具,問題才開始能被解。
環境資訊
- Nuxt 3.21.2
- Nitro 2.13.3
- 部署:
nuxt generate→ GitHub Pages(SSG) - Phase B 升 Nuxt 4 / Content v3 時會回來重測
payloadExtraction是否能恢復預設
Reference
- Nuxt
experimental.payloadExtraction設定說明:https://nuxt.com/docs/api/nuxt-config#experimental ↩ - Nitro prerender 機制官方文件:https://nitro.build/guide/cache#prerender ↩