Mike

7 Apr, 2026

升級 Nuxt 4 的必知陷阱 EP1 - 動態頁面 Key 機制全解析

從 Nuxt 3 升級到 Nuxt 4 後,原本正常運作的動態路由頁面突然噴出 Maximum call stack size exceeded?我將深入剖析 <NuxtPage> 的 key 機制變更,以及該如何因應...


一個真實的 Bug

目前的專案在升級 Nuxt 4 時,聊天室頁面 /message/[chatId] 出現了一個奇怪的 Bug

  • 進入 /message/123 → 正常
  • 聊天室列表切換到 /message/456 → 瀏覽器直接噴出

最後發現原因是 Nuxt 4 改變了 <NuxtPage> 的預設 key 行為


Vue 的 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(包含所有子元件)

所有的 refreactivecomputedwatch 都會重新建立,所有子元件也會遞迴經歷同樣的流程。

重複使用意味著什麼?

當 key 不變時,元件實例完全不動

  • 不觸發任何生命週期鉤子
  • 所有響應式狀態保留
  • DOM 節點不重建
  • 只有 props 或響應式資料的變化會觸發重新渲染

Nuxt 3 vs Nuxt 4:預設 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:不設定 key

在 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,不用動 」。

Nuxt 4:以 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 4 要改?

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 選擇了安全優先的設計,每次路徑變化都重新建立元件,確保不會有舊狀態殘留,這對大多數頁面來說是合理的。


案例分析:Nuxt 3 能跑但 Nuxt 4 會炸的寫法

案例一:聊天室切換

這是我們專案中的真實案例。

Nuxt 3 時代的寫法

// 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 後爆炸

同樣的程式碼在 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 不會出事?

因為 Nuxt 3 不設定 key,元件不會被銷毀重建,沒有 unmount / mount 的生命週期連鎖反應,只有 watch 回呼在受控的情況下更新資料。


案例二:商品頁切換

另一個常見場景:商品詳情頁。

Nuxt 3 的寫法

// 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 的行為

在 Nuxt 3 中,/message/123/message/settings/message/123 這樣的來回導航:

  • 每次都是正常的元件切換(不同路由元件)
  • 因為沒有 key,Vue Router 自然地卸載一個元件、掛載另一個元件
  • 不會有額外的 reactive 連鎖反應

Nuxt 4 的行為

在 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 的情境就容易超載。


固定 Key vs 動態 Key:優缺點完整比較

什麼是固定 Key?

definePageMeta({
  // 方式一:回傳路由名稱(同一路由的所有路徑共用 key)
  key: route => String(route.name),
});

// 方式二:回傳固定字串(甚至跨路由共用 key)
definePageMeta({
  key: () => 'message',
});

什麼是動態 Key?

// Nuxt 4 預設行為,等同於:
definePageMeta({
  key: route => route.fullPath,
});

// 或自訂動態邏輯:
definePageMeta({
  key: route => `${route.name}-${route.params.id}`,
});

固定 Key

優點

優點 說明
避免 stack overflow 元件不會銷毀重建,不會在同 tick 觸發大量 reactive 變更的連鎖反應
效能更好 不需要重建整棵元件樹,DOM diff 最小化
保留元件內部狀態 滾動位置、展開/摺疊狀態、輸入框內容等都自然保留
減少 API 呼叫 不會因為元件重建而重複觸發 onMounted 中的 API 請求
動畫/過場更平滑 元件不銷毀,可以做更細緻的過場動畫

 

缺點

缺點 說明
必須手動處理參數變化 需要用 watchrouter.afterEach 偵測參數變更並更新資料
舊狀態殘留風險 忘記清理的狀態會殘留到下一次參數變化,造成顯示錯誤
{ once: true } watcher 失效 因為元件不重建,once 選項的 watcher 只會觸發一次就永久失效
onMounted 只執行一次 原本放在 onMounted 的初始化邏輯不會在參數變化時重新執行
增加心智負擔 開發者需要明確管理「什麼該重置、什麼該保留」
可能遺漏子元件的重置 如果子元件有自己的內部狀態,父元件手動重置不一定覆蓋得到

 

固定 Key 搭配手動偵測的完整範例

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;
});

動態 Key(Nuxt 4 預設)

優點

優點 說明
零心智負擔 不需要思考狀態清理,每次都是全新的元件
不會有舊狀態殘留 所有 refreactivecomputed 都重新建立
onMounted 自然執行 初始化邏輯放在 onMounted 就好,框架幫你重新執行
子元件也自動重建 不需要擔心子元件的內部狀態清理
{ once: true } 正常運作 元件重建後 watcher 也重新註冊
更符合「每個路徑是獨立頁面」的思維模型 心智模型簡單,好理解

 

缺點

缺點 說明
效能損耗 每次切換都要銷毀 + 重建整棵元件樹,子元件越多越慢
可能觸發 stack overflow 元件樹龐大且有大量 reactive 狀態時,同一 tick 的連鎖反應可能導致 Vue scheduler 無限遞迴
失去元件內部狀態 滾動位置、使用者輸入、展開狀態等全部歸零
重複 API 呼叫 即使資料可以重用,也會因為 onMounted 重新執行而重複請求
閃爍感 從有內容 → 空白/loading → 重新載入,使用者可感知到頁面「閃一下」
不適合高頻切換場景 如聊天室列表、商品快速瀏覽等,每次切換的成本太高

實戰:如何選擇正確的 Key 策略

各場景推薦策略

場景 推薦策略 原因
部落格文章 /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

跨子路由的 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 自己掌控生命週期 )


參考資料