有一類 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 test | mount(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 元件留在
#bodytemplate 之外。它自己在關閉狀態下渲染為空,於是 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 就決定了,而錯誤發生在那之後。
修法
nuxt.config.ts的 server-only runtime config 改用 literal 安全預設值,不要讀process.env:runtimeConfig: { ai: { provider: 'gemini', geminiApiKey: '', }, }- 部署環境的變數名改成與 object path 完全對應:
NUXT_AI_PROVIDER=gemini NUXT_AI_GEMINI_API_KEY=... - 不要把所有
process.env.*一律機械改名。真正只在 build 期使用的變數(例如 source map 上傳 token)維持原樣是對的。 - 補一條契約測試,同時斷言 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 的實際值。原始碼看起來都是對的——那正是它們能活這麼久的原因。