
從 Nuxt 3 升級到 Nuxt 4 後,原本正常運作的動態路由頁面突然噴出 Maximum call stack size exceeded?我將深入剖析 <NuxtPage> 的 key 機制變更,以及該如何因應...
目前的專案在升級 Nuxt 4 時,聊天室頁面 /message/[chatId] 出現了一個奇怪的 Bug
/message/123 → 正常/message/456 → 瀏覽器直接噴出
最後發現原因是 Nuxt 4 改變了 <NuxtPage> 的預設 key 行為。
在深入 Nuxt 之前,先回顧 Vue 的 key 是什麼?
key 是 Vue 用來識別 VNode 身份的特殊屬性,它告訴 Vue:「這兩個節點是不是同一個東西?」
<!-- key 相同:Vue 重複使用同一個元件實例 -->
<component :is="PageComponent" key="same-key" />
<!-- key 不同:Vue 銷毀舊元件,建立全新的元件 -->
<component :is="PageComponent" :key="differentKey" />
當 key 改變時,Vue 會對舊元件執行完整的卸載流程:
舊元件:onBeforeUnmount → onUnmounted(包含所有子元件)
新元件:setup → onBeforeMount → onMounted(包含所有子元件)
所有的 ref、reactive、computed、watch 都會重新建立,所有子元件也會遞迴經歷同樣的流程。
當 key 不變時,元件實例完全不動:
這是升級時最關鍵、也最容易忽略的破壞性變更。
先不談原理,用最簡單的例子感受一下
// pages/demo/[id].vue
const route = useRoute();
const count = ref(0);
<template>
<div>
<p>目前頁面:/demo/{{ route.params.id }}</p>
<p>計數器:{{ count }}</p>
<button @click="count++">+1</button>
<NuxtLink to="/demo/A">去 A</NuxtLink>
<NuxtLink to="/demo/B">去 B</NuxtLink>
</div>
</template>
操作步驟:進入 /demo/A,按 +1 三次讓計數器變成 3,然後點「去 B」。
| Nuxt 3 | Nuxt 4 | |
|---|---|---|
| 點擊「去 B」後計數器顯示 | 3(保留) | 0(重置) |
| 原因 | 同一個元件實例,count 沒被重建 |
元件銷毀重建,count 是全新的 ref(0) |
| 再點「去 A」回來 | 3(還在) | 0(又是新的) |
Nuxt 3 把 /demo/A 和 /demo/B 當成「 同一個元件 」,元件實體不變,只是參數變了。
Nuxt 4 把它們當成「 兩個不同的元件 」,舊的銷毀,創建新的。
理解了這個差異後,我們來看背後的原理。
在 Nuxt 3 中,<NuxtPage> 渲染頁面元件時不帶 key
<!-- 父層 layout 或 app.vue -->
<template>
<!-- Nuxt 3:內部不設定 key -->
<NuxtPage />
</template>
等同於你手動寫了一個不帶 key 的元件
<template>
<!-- 沒有 :key,Vue 看到同一個元件就會重複使用實例 -->
<PageComponent />
</template>
不設定 key 意味著 Vue Router 在切換同一個路由元件的不同參數時,會重複使用同一個元件實例。
從 /user/alice 切到 /user/bob,Vue 認為「 還是同一個 [userId].vue,不用動 」。
route.fullPath 作為 key在 Nuxt 4 中,<NuxtPage> 自動幫頁面元件加上 :key="route.fullPath":
等同於你手動寫了
<template>
<!-- key 隨路徑變化,路徑不同就銷毀重建 -->
<NuxtPage :key="route.fullPath" />
</template>
比較一下來理解差異
<template>
<!--
Nuxt 3:不帶 key 元件實例不變,只是 route.params.userId 改了
-->
<NuxtPage />
<!--
Nuxt 4:帶 fullPath 作為 key,Vue 判定為不同元件 (銷毀舊的 → 建立新的)
-->
<NuxtPage :key="route.fullPath" />
</template>
以完整路徑作為 key,表示只要路徑不同,即使是同一個頁面元件,Vue 也會將其視為完全不同的實例。
| Nuxt 3 | Nuxt 4 | |
|---|---|---|
| 預設 key | undefined(不設定) |
route.fullPath |
/page/1 → /page/2 |
重複使用元件實例 | 銷毀舊元件 + 建立新元件 |
| 生命週期觸發 | 不觸發 mount / unmount | 完整 unmount → mount 流程 |
| 資料更新方式 | 開發者用 watch 監聽參數 |
自動透過 setup / onMounted |
| 舊狀態殘留風險 | 有,需手動清理 | 無,元件全新建立 |
| 效能 | 較好(不重建元件樹) | 較差(每次都重建) |
| 心智負擔 | 較高(需處理狀態清理) | 較低(框架自動處理) |
Nuxt 3 的行為雖然效能好,但容易產生狀態殘留 Bug,例如...
// Nuxt 3:/user/alice → /user/bob
const route = useRoute();
const userData = ref(null);
// 忘記 watch 參數變化 → userData 永遠是 alice 的資料!
onMounted(async () => {
userData.value = await fetchUser(route.params.userId);
});
在 Nuxt 3 中,如果開發者忘記 watch 路由參數,頁面顯示的內容就不會更新,這是一個非常常見的 Bug。
Nuxt 4 選擇了安全優先的設計,每次路徑變化都重新建立元件,確保不會有舊狀態殘留,這對大多數頁面來說是合理的。
這是我們專案中的真實案例。
// pages/message/[chatId].vue
const route = useRoute();
const messageStore = useMessageStore();
const { chatId } = storeToRefs(messageStore);
const { initChatroom, resetChatroom } = messageStore;
// Nuxt 3:元件不會重建,用 watch 偵測參數變化
watch(() => route.params.chatId, (newId, oldId) => {
if (newId !== oldId) {
resetChatroom();
chatId.value = Number(newId);
initChatroom();
}
});
onMounted(() => {
chatId.value = Number(route.params.chatId);
initChatroom();
});
<!-- pages/message/[chatId].vue -->
<template>
<Chatroom />
</template>
在 Nuxt 3 中完美運作:
/message/123 → /message/456 元件重複使用,watch 偵測到變化,優雅地重置再初始化chatId 相關的資料更新,不需要重建整棵元件樹同樣的程式碼在 Nuxt 4 中:

/message/123 → /message/456
1. key 從 "/message/123" 變成 "/message/456" → 元件銷毀重建
2. 舊元件 unmount → onBeforeUnmount 清理狀態(大量 reactive 變更)
3. 新元件 mount → setup + onMounted 初始化(大量 reactive 變更)
4. watch 觸發 → 參數變了,watcher 回呼也在跑(又一批 reactive 變更)
→ 三件事在同一個 tick 同時執行
→ Vue scheduler 的 flushJobs 不斷排入新工作,形成無限遞迴
→ Maximum call stack size exceeded
因為 Nuxt 3 不設定 key,元件不會被銷毀重建,沒有 unmount / mount 的生命週期連鎖反應,只有 watch 回呼在受控的情況下更新資料。
另一個常見場景:商品詳情頁。
// pages/product/[productId].vue
const route = useRoute();
const product = ref(null);
const reviews = ref([]);
const isLoading = ref(true);
const fetchProduct = async (id) => {
isLoading.value = true;
const [productData, reviewData] = await Promise.all([
$fetch(`/api/products/${id}`),
$fetch(`/api/products/${id}/reviews`),
]);
product.value = productData;
reviews.value = reviewData;
isLoading.value = false;
};
// Nuxt 3 - 靠 watch 處理切換
watch(() => route.params.productId, (newId) => {
fetchProduct(newId);
});
onMounted(() => {
fetchProduct(route.params.productId);
});
<template>
<div>
<ProductGallery :images="product?.images" />
<ProductInfo :product="product" />
<ReviewList :reviews="reviews" />
<!-- 假設這裡有很多子元件... -->
</div>
</template>
Nuxt 3:切換商品只是重新拉資料,所有子元件的 DOM 和內部狀態(如圖片輪播位置、展開的摺疊面板)都保留。
Nuxt 4:每次切換商品,整個頁面包含所有子元件全部銷毀重建,如果 ProductGallery 內部有複雜的輪播邏輯、ReviewList 有虛擬滾動,這些初始化成本在每次切換時都要重新付出。
這個案例更隱蔽 ! 不是同一個動態路由的參數切換,而是不同子路由之間的導航
pages/
message/
[chatId].vue ← route name: "message-chatId"
settings.vue ← route name: "message-settings"
在 Nuxt 3 中,/message/123 → /message/settings → /message/123 這樣的來回導航:
在 Nuxt 4 中,即使你已經對 [chatId].vue 設定了固定 key
// [chatId].vue
definePageMeta({
key: route => String(route.name), // "message-chatId"
});
從 /message/settings 回到 /message/123 時,因為是不同的路由元件,[chatId].vue 仍然會被全新建立。如果此時 store 中的 reactive 狀態很多,fresh mount 過程中的狀態變更就可能導致 stack overflow。
關鍵差異:Nuxt 3 的 fresh mount 不容易出問題,因為初始 reactive 狀態的「量」通常不大,但 Nuxt 4 由於預設 key 行為,會讓開發者養成了「 反正每次都是新的 」的思維,累積了大量在 mount 時同步觸發的狀態變更,一旦遇到 fresh mount 的情境就容易超載。
definePageMeta({
// 方式一:回傳路由名稱(同一路由的所有路徑共用 key)
key: route => String(route.name),
});
// 方式二:回傳固定字串(甚至跨路由共用 key)
definePageMeta({
key: () => 'message',
});
// Nuxt 4 預設行為,等同於:
definePageMeta({
key: route => route.fullPath,
});
// 或自訂動態邏輯:
definePageMeta({
key: route => `${route.name}-${route.params.id}`,
});
| 優點 | 說明 |
|---|---|
| 避免 stack overflow | 元件不會銷毀重建,不會在同 tick 觸發大量 reactive 變更的連鎖反應 |
| 效能更好 | 不需要重建整棵元件樹,DOM diff 最小化 |
| 保留元件內部狀態 | 滾動位置、展開/摺疊狀態、輸入框內容等都自然保留 |
| 減少 API 呼叫 | 不會因為元件重建而重複觸發 onMounted 中的 API 請求 |
| 動畫/過場更平滑 | 元件不銷毀,可以做更細緻的過場動畫 |
| 缺點 | 說明 |
|---|---|
| 必須手動處理參數變化 | 需要用 watch 或 router.afterEach 偵測參數變更並更新資料 |
| 舊狀態殘留風險 | 忘記清理的狀態會殘留到下一次參數變化,造成顯示錯誤 |
{ once: true } watcher 失效 |
因為元件不重建,once 選項的 watcher 只會觸發一次就永久失效 |
onMounted 只執行一次 |
原本放在 onMounted 的初始化邏輯不會在參數變化時重新執行 |
| 增加心智負擔 | 開發者需要明確管理「什麼該重置、什麼該保留」 |
| 可能遺漏子元件的重置 | 如果子元件有自己的內部狀態,父元件手動重置不一定覆蓋得到 |
definePageMeta({
key: route => String(route.name),
});
const route = useRoute();
const router = useRouter();
const store = useMyStore();
const { resetState, initPage } = store;
let prevId = route.params.id;
// 手動偵測參數變更
const removeHook = router.afterEach((to) => {
if (to.name === route.name && String(to.params.id) !== String(prevId)) {
prevId = to.params.id;
resetState(); // 1. 先清除舊資料
initPage(to.params.id); // 2. 再初始化新資料
}
});
// 首次載入
onBeforeMount(() => {
initPage(route.params.id);
});
// 清除導航鉤子
onUnmounted(() => {
removeHook();
});
// ❌ 這樣寫在固定 key 下會出問題
watch(
() => someCondition.value,
(val) => { doSomething(val); },
{ once: true } // 元件不重建,once 只會觸發一次就永遠失效
);
// ✅ 改成手動控制
let hasTriggered = false;
watch(
() => someCondition.value,
(val) => {
if (!hasTriggered) {
hasTriggered = true;
doSomething(val);
}
},
);
// 在參數變更時手動重置
router.afterEach(() => {
hasTriggered = false;
});
| 優點 | 說明 |
|---|---|
| 零心智負擔 | 不需要思考狀態清理,每次都是全新的元件 |
| 不會有舊狀態殘留 | 所有 ref、reactive、computed 都重新建立 |
onMounted 自然執行 |
初始化邏輯放在 onMounted 就好,框架幫你重新執行 |
| 子元件也自動重建 | 不需要擔心子元件的內部狀態清理 |
{ once: true } 正常運作 |
元件重建後 watcher 也重新註冊 |
| 更符合「每個路徑是獨立頁面」的思維模型 | 心智模型簡單,好理解 |
| 缺點 | 說明 |
|---|---|
| 效能損耗 | 每次切換都要銷毀 + 重建整棵元件樹,子元件越多越慢 |
| 可能觸發 stack overflow | 元件樹龐大且有大量 reactive 狀態時,同一 tick 的連鎖反應可能導致 Vue scheduler 無限遞迴 |
| 失去元件內部狀態 | 滾動位置、使用者輸入、展開狀態等全部歸零 |
| 重複 API 呼叫 | 即使資料可以重用,也會因為 onMounted 重新執行而重複請求 |
| 閃爍感 | 從有內容 → 空白/loading → 重新載入,使用者可感知到頁面「閃一下」 |
| 不適合高頻切換場景 | 如聊天室列表、商品快速瀏覽等,每次切換的成本太高 |

| 場景 | 推薦策略 | 原因 |
|---|---|---|
部落格文章 /blog/[slug] |
動態 key(預設) | 文章之間獨立,不常快速切換 |
使用者個人頁 /user/[id] |
動態 key(預設) | 低頻切換,狀態獨立 |
聊天室 /message/[chatId] |
固定 key | 高頻切換,元件樹龐大,大量 reactive 狀態 |
商品頁 /product/[id] |
固定 key | 使用者可能快速瀏覽多個商品,保留滾動位置 |
播放清單 /playlist/[id] |
固定 key | 播放器不應被銷毀重建 |
搜尋結果 /search?q=xxx |
動態 key(預設) | query 變化時應完整重新載入 |
後台編輯 /admin/[id]/edit |
視情況 | 若有未儲存的表單狀態需要提醒,考慮固定 key |
如果你的頁面結構像這樣
pages/
message/
[chatId].vue ← 聊天室
settings.vue ← 設定頁
當使用者在 /message/123 和 /message/settings 之間來回切換時,即使 [chatId].vue 使用固定 key,因為是不同的路由元件,[chatId].vue 仍然會被銷毀重建。
解決方案一:統一 key
讓兩個頁面使用相同的固定 key 值
// pages/message/[chatId].vue
definePageMeta({ key: () => 'message' });
// pages/message/settings.vue
definePageMeta({ key: () => 'message' });
注意:使用 route => String(route.name) 不夠,因為兩個頁面的 route.name 不同(message-chatId vs message-settings),必須用固定字串。
解決方案二:將共用元件提升到父層
<!-- pages/message.vue(父層) -->
<template>
<div>
<Contacts />
<!-- Chatroom 放在父層,不隨子路由切換而銷毀 -->
<Chatroom v-show="hasChatId" />
<NuxtPage />
</div>
</template>
這樣 Chatroom 元件完全不受子路由切換影響。
Nuxt 4 將 <NuxtPage> 的預設 key 從 undefined 改為 route.fullPath,是一個安全優先的設計決策,對大多數頁面來說,這消除了舊狀態殘留的風險,降低了開發者的心智負擔。
但對於元件階層數多、reactive 狀態複雜、使用者會高頻切換的動態路由頁面,這個改變可能帶來效能問題甚至是 stack overflow。
核心原則:了解你的元件樹( 如果它很輕量,讓 Nuxt 4 預設幫你處理。如果它很龐大,用固定 key 自己掌控生命週期 )