四個不會報錯的 Nuxt 靜默失效

typecheck 綠、lint 綠、測試綠、console 乾淨,功能卻整條失效。四個真實踩過的 Vue 與 Nuxt 靜默失效:被編譯器丟棄的 props、被遮蔽的具名插槽、解析錯位的 layer alias、烘焙成空值的 runtimeConfig,以及每一個該怎麼判定。

有一類 bug 特別貴,不是因為難修,而是因為它不告訴你它存在。

編譯不報錯、型別檢查全綠、測試全綠、瀏覽器 console 一片乾淨,畫面看起來也對——按鈕在、欄位在、版面在。只有一個地方不對:功能沒有作用。

這篇整理四個我們在 Nuxt 專案上真的踩過的靜默失效。四個的共同結構是一樣的:框架選了一條合法的降級路徑,而降級是無聲的。合法所以不報錯,無聲所以你以為沒事。

一、defineProps<T>() 會靜默丟掉它解析不動的 prop

症狀

一個表單元件的驗證完全不作用。必填欄位留空按送出:沒有 inline error、沒有 toast、沒有 console error,而且也沒有發出 API request。

「沒有 request」這件事誤導性極強——看起來就像驗證擋下來了。實際上驗證層從頭到尾沒有執行過。

根因

<script setup> 裡的 defineProps<T>() 是編譯期推導。SFC 編譯器要把型別 T 的成員轉成 runtime 的 props 宣告物件,而它只處理自己能靜態解析的型別。遇到解析不動的成員,它靜默略過該成員,並且連同其後的成員一起漏掉。

觸發的形狀長這樣(Vue 3.5.40 / Nuxt 4.5.1 / Zod 4.4.3):

import type { ZodType } from 'zod'

interface AppFormLayoutProps {
  title?: string
  description?: string
  loading?: boolean
  submitLabel?: string
  destructive?: boolean
  schema?: ZodType // ← 外部泛型 class,編譯器解析不動
  state?: Record<string, unknown> // ← 被連帶漏掉
}

const { schema, state /* … */ } = defineProps<AppFormLayoutProps>()
const useValidatedForm = computed(() => !!schema && !!state)

schema 與 state 從來沒有成為 prop。父層傳進來的值落進 fallthrough attrs,useValidatedForm 恆為 false,於是底下這個動態元件一路渲染成原生 form:

<component
  :is="useValidatedForm ? UForm : 'form'"
  v-bind="useValidatedForm ? { schema, state } : {}"
>

原生 <form> 的 submit handler 做的是 preventDefault() 加上 emit——所以既沒有 inline error,也沒有 request。兩個症狀同時出現,非常容易被讀成「驗證有跑而且擋下來了」。

這不是型別錯誤。schema?: ZodType 在 TypeScript 層面完全合法,vue-tsc 不會有任何意見。問題只發生在編譯器產生 runtime props 宣告的那一步。

為什麼四道關卡全綠

關卡為什麼沒攔到
typecheck型別標註合法,父層傳的值也型別相容
lint沒有語法或風格問題
unit testmount(Comp, { props }) 測的是「傳進去的值」,不是「prop 有沒有被宣告」
runtime降級到原生 <form> 是合法渲染路徑,不會 throw

怎麼判定

靜態 grep 只能粗篩候選,偽陽性很高——Vue 3.3 之後對多數外部型別都能正確解析,命中不等於中招。真正決定性的判定是比對「interface 宣告的成員」與「runtime 實際的 props」。開著 dev server,在瀏覽器 console:

const el = document.querySelector('form')
const inst = el.__vueParentComponent
console.log(inst.type?.__name, Object.keys(inst.props))
// 與 .vue 檔內 interface 的成員逐一比對,少了誰就是被靜默丟棄的

同一個 __vueParentComponent 往上走 parent 鏈,還能直接看出「該渲染的元件有沒有真的被渲染」:

let i = el.__vueParentComponent
const chain = []
while (i && chain.length < 6) {
  chain.push(i.type?.__name || i.type?.name || '(anon)')
  i = i.parent
}
console.log(chain.join(' < '))
// 壞:AppFormLayout < PageShell < …   ← 鏈上沒有 UForm
// 好:UForm < AppFormLayout < …

修法

把外部泛型型別換成編譯器解析得動的型別,並且在原處寫下為什麼——否則下一個人會順手把它改回「正確」的型別:

interface AppFormLayoutProps {
  /**
   * 型別寫 `object` 而非 `ZodType`:SFC 編譯器對 `defineProps<T>()` 只能靜態解析,
   * 遇到 zod 這種外部泛型 class 會靜默略過該 prop(連同其後的 `state` 一起),
   * 導致 props 根本沒宣告、驗證層從未生效。
   * 實際型別由 submit handler 端的 `FormSubmitEvent<z.output<...>>` 保證。
   */
  schema?: object
  state?: Record<string, unknown>
}

要保住 prop 的型別安全,另一條路是改用 runtime props 宣告搭配 PropType——犧牲一點簡潔換取編譯器不需要推導。

講到這裡你可能會想,那乾脆全站都別用型別式 defineProps?那會為了一個窄形狀付全站的代價。 會踩到的只有「成員型別是編譯器靜態解析不動的外部泛型」這一種,日常的 string / boolean / 自家 interface 都不受影響。真正該建立的是判定習慣:接上外部函式庫的型別當 prop 時, 去 runtime 看一眼 Object.keys(props)——那一眼比全站改寫便宜太多。

二、具名插槽是 default slot 的 fallback,混用就整組消失

症狀

頁面打開全空。layout 在、sidebar 在,content 區完全空白——沒有 header、沒有 skeleton、沒有 error state、console 零錯誤、typecheck 與 lint 全綠。

DOM 長這樣:

<main class="flex flex-1 flex-col">
  <div data-slot="root" class="...">
    <!--v-if--><!--teleport start--><!--teleport end-->
  </div>
  <!--v-if-->
</main>

最誤導的地方:頁面的 setup 照常執行、資料查詢照常發出、API 回 200。從伺服器端的結構化事件日誌看,這一頁「完全正常」。我們有一頁在這個狀態下維持了 18 天才被回報。

根因

某些容器元件把具名插槽實作成 default slot 的 fallback content:

<slot>
  <!-- default slot -->
  <slot name="header" />
  <div data-slot="body"><slot name="body" /></div>
  <slot name="footer" />
</slot>

Vue 的編譯規則是:component 的 children 混用 <template #named> 與非 template 元素時,非 template 元素會被編譯成 default slot。於是 slots.default 存在,fallback 整組跳過——包含全部具名插槽。

我們踩到的兩種形狀,都是同一次重構引入的:

  • 一個 slideover 元件留在 #body template 之外。它自己在關閉狀態下渲染為空,於是 default slot「有內容但看不見」,整頁空白。
  • 主內容的 <div> 是 stray 子元素。主內容看起來正常,但 #header 裡的 navbar 被靜默吃掉。

元件庫的文件其實寫清楚了這兩種寫法是二擇一:使用 header / body / footer 插槽來自訂面板,或者使用 default slot。「或者」這個字承載了全部的資訊量,而它太容易被讀過去。

為什麼監控看不到

  • typecheck 與 lint 都不驗 slot 結構,Vue 對此不發任何 warning——這是合法語法
  • 頁面 setup 與資料查詢照常執行,API 層監控全綠,是貨真價實的 200
  • 對照組頁面(純具名插槽、或純 default slot)都正常,只有「混用」壞。局部的爆炸半徑讓人工瀏覽很難撞到

怎麼判定

單行 grep 抓不到,因為需要配對 template 深度。可以用一段 AST-lite 的掃描:抓出目標容器元件的直接子元素,如果同時存在「帶 # 的 template」與「非 template 節點」,就是混用。

關鍵在「直接子元素」——巢狀更深的 template 不算,所以掃描時要自己維護一個 depth counter,只在 depth 為 0 時收集。

一個重要的邊界:這條偵測只適用於「具名插槽等於 default fallback」結構的元件。Modal 與 Slideover 這類元件的 default slot 語意是 trigger,混用是完全合法的,不能套同一條禁令。

修法

讓容器的直接子元素只剩 <template #...> 節點:

  • overlay 類(slideover / modal)移進 <template #body> 的尾端。overlay 自己會 teleport,位置不影響行為
  • 主內容區用 <template #body> 包起來

或者反向——整頁都不用具名插槽,全部走 default slot。兩種擇一,不要混用。

三、Layer 裡的 ~ alias 解析到 consumer 的 srcDir

症狀

一個以 Nuxt Layers 組成的專案,所有來自 layer 的元件全部 Failed to resolve component。頁面看起來載入了(layout 與 sidebar 在,因為那些多半來自 UI 套件),但內容區整片空白。

沒有 SSR 500、沒有 Nuxt error overlay、window.__NUXT__.error 是 null。在 ssr: false 的 SPA 模式下尤其難查——SSR 只吐 shell,內容全在 client 端渲染,連伺服器 log 都沒有線索。

根因

Layer 的 nuxt.config.ts 這樣宣告自己的元件:

export default defineNuxtConfig({
  components: [{ path: '~/components', pathPrefix: false }],
})

當這個 layer 被 extends 引入時,components[].path 裡的 ~ / @ alias 解析到的是最終 app 的 srcDir,不是 layer 自己的 srcDir。最終 app 的 app/components 通常是空的,於是這個 layer 註冊了 0 個元件。

不對稱的地方在於:imports.dirs 這類 bare 相對目錄多半仍然正常,因為 Nuxt 對 layer 的 imports.dirs 有 per-layer 解析。只有 components[].path 的 alias 不會 per-layer rebind。

為什麼無聲

未解析的 component 在 Vue 是 warn 不是 error。它不觸發 error overlay、不中斷 hydration、nuxtError 保持 null。而 layout 照常渲染,所以頁面「看起來正常」只是內容空——非常容易誤判成路由守衛、middleware redirect 或資料問題。

怎麼判定

不要看 dev server 起不起得來,要看產生出來的 component manifest:

# layer config 是否用 ~ alias 註冊 components(命中即高風險)
grep -rn "path: '~/components'" packages/*/nuxt.config.*

# 生成的 manifest 是否真的註冊到 layer 元件(0 就是中招)
grep -cE "packages/[^/]+/app/components" .nuxt/components.d.ts

修法

改用 layer-relative 的絕對路徑,讓 ~ 完全不參與解析:

import { fileURLToPath } from 'node:url'

export default defineNuxtConfig({
  components: [
    {
      path: fileURLToPath(new URL('./app/components', import.meta.url)),
      pathPrefix: false,
    },
  ],
})

每個 layer 註冊自己的 app/components,與誰 extend 它無關。我們這次修完,.nuxt/components.d.ts 裡的 layer 元件從 0 個變成 62 個。

驗收不能只看 dev server 起得來——要驗 manifest 有元件,而且至少開一個真的用到 layer 元件的頁面確認有渲染。

四、巢狀 runtimeConfig 讀一般環境變數,正式環境烘焙成空值

症狀

正式環境的 API 回 HTTP 200 並成功開啟 SSE 串流,但串流隨即結束:

data: {"type":"start",...}
data: {"type":"error","errorText":"服務暫時無法使用,請稍後再試"}
data: [DONE]

正式環境的 env 檔明明有那把 key,Nuxt server 裡的 runtimeConfig.ai.geminiApiKey 卻是空字串。沒有啟動錯誤,typecheck 也全綠,只有真正呼叫外部 provider 的那一刻才失敗。

根因

Nuxt runtime config 的 runtime override 契約是依 object path 映射 NUXT_* 環境變數。runtimeConfig.ai.geminiApiKey 對應的是 NUXT_AI_GEMINI_API_KEY,不是 AI_GEMINI_API_KEY。

如果 nuxt.config.ts 直接寫:

runtimeConfig: {
  ai: {
    geminiApiKey: process.env.AI_GEMINI_API_KEY,
  },
}

這個一般環境變數只在 build 時被讀成預設值。CI build 沒帶該值,空值就進了 artifact;之後 process 啟動時即使環境裡有 AI_GEMINI_API_KEY,Nuxt 也不會把它映射回巢狀欄位。結果是「env 檔看起來完全正確、runtime config 實際為空」。

為什麼測試抓不到

  • TypeScript 只驗欄位型別,驗不了 production process 的環境變數映射
  • unit test 用 mock 的 runtime config 注入 key,沒有驗證「build artifact 加上部署環境變數」這個組合
  • deploy workflow 只寫了舊的一般名稱,沒有與 runtimeConfig 的 object path 做契約測試
  • API 走 SSE,provider 初始化錯誤發生在串流開始之後,所以外層 HTTP status 仍然是 200

最後一點值得單獨強調:串流 API 的 HTTP status 不是健康度指標。狀態碼在第一個 byte 就決定了,而錯誤發生在那之後。

修法

  1. nuxt.config.ts 的 server-only runtime config 改用 literal 安全預設值,不要讀 process.env:
    runtimeConfig: {
      ai: {
        provider: 'gemini',
        geminiApiKey: '',
      },
    }
    
  2. 部署環境的變數名改成與 object path 完全對應:
    NUXT_AI_PROVIDER=gemini
    NUXT_AI_GEMINI_API_KEY=...
    
  3. 不要把所有 process.env.* 一律機械改名。真正只在 build 期使用的變數(例如 source map 上傳 token)維持原樣是對的。
  4. 補一條契約測試,同時斷言 object path 與環境變數名的對應關係。這是唯一能在 CI 攔下這類 drift 的地方。

這四個坑的共同結構

回頭看,四個坑都是同一個形狀:

合法的降級路徑於是你看到
一prop 沒宣告 → 值落進 fallthrough attrs表單長得一樣,但送出後什麼都沒發生
二有 default slot → 具名插槽 fallback 整組跳過頁面全空,但 API 全部 200
三元件解析不到 → Vue 發 warn 不發 error內容區空白,nuxtError 是 null
四環境變數映射不上 → 欄位保持預設空值HTTP 200,錯誤藏在串流裡

每一條降級單獨看都是合理的設計——框架不應該因為一個 prop 解析不動就整個 build 失敗。問題出在降級沒有留下可觀察的痕跡。

實務上的收穫有兩條。

第一,「全綠」不是一種證據,它只是「沒有人在看」的另一種說法。typecheck 驗型別、lint 驗風格、unit test 驗你寫的斷言——四道關卡全綠而功能整條失效,是因為沒有任何一道在驗「這個東西真的被接上了嗎」。

第二,判定要打在編譯產物與 runtime 狀態上,不要打在原始碼上。上面四個坑的決定性判準分別是:runtime 的 props key 清單、DOM 的實際節點、.nuxt/components.d.ts 的註冊筆數、process 內 runtime config 的實際值。原始碼看起來都是對的——那正是它們能活這麼久的原因。