Skip to content

Teleport 與 Suspense

重新認識 Vue.js: 008 天絕對看不完的 Vue.js 3.x 指南

這一節的兩個內建元件,處理的都是「元件樹的形狀」跟「畫面的需求」對不上的情況:一個讓 DOM 跳出元件樹的位置限制,一個讓非同步的內容有地方等。

元件在裡面,畫面要在外面

想做一個購買確認的 modal。就元件設計而言,它屬於 BookCard:由卡片的按鈕觸發、跟卡片的資料相關,程式碼放一起最合理。但就畫面而言,modal 應該覆蓋整個頁面,如果它的 DOM 待在卡片內部,祖先元素的 overflow: hiddentransformz-index 堆疊環境,隨時可能把它剪裁或壓在下面。

程式碼想放在裡面,DOM 需要在外面。<Teleport> 就是解這個矛盾的:元件結構不動,把渲染出來的 DOM 傳送到指定位置

<Teleport> 基本用法

src/components/PurchaseModal.vue

vue
<script setup>
defineProps({
  title: { type: String, required: true }
})

const emit = defineEmits(['close'])
</script>

<template>
  <Teleport to="body">
    <div class="overlay">
      <div class="modal">
        <p>確定要購買《{{ title }}》嗎?</p>
        <slot />
        <button type="button" @click="emit('close')">
          關閉
        </button>
      </div>
    </div>
  </Teleport>
</template>

<style scoped>
.overlay {
  position: fixed;
  z-index: 1000;
  inset: 0;
  display: grid;
  place-items: center;
  background: rgb(15 23 42 / 0.5);
}

.modal {
  background: #fff;
  color: #0f172a;
  border-radius: 8px;
  padding: 1.5rem;
  display: grid;
  gap: 0.75rem;
}

.modal button {
  border: 1px solid #0f766e;
  border-radius: 8px;
  background: #0f766e;
  color: #fff;
  cursor: pointer;
  font: inherit;
  font-weight: 600;
  padding: 0.5rem 1rem;
}

.modal button:focus-visible {
  outline: 2px solid #14b8a6;
  outline-offset: 2px;
}
</style>

src/App.vue

vue
<script setup>
import { ref } from 'vue'
import PurchaseModal from './components/PurchaseModal.vue'

const showModal = ref(false)   // 開關狀態由父元件持有,關閉由子元件 emit
</script>

<template>
  <main class="app">
    <button type="button" @click="showModal = true">
      購買
    </button>

    <PurchaseModal
      v-if="showModal"
      title="重新認識 Vue.js"
      @close="showModal = false"
    >
      <p>結帳金額 NT$620。</p>
    </PurchaseModal>
  </main>
</template>

<style scoped>
.app {
  padding: 2rem;
}
</style>
試一試

to="body" 接受 CSS 選擇器或 DOM 元素。打開 modal 後用開發者工具檢查:.overlay 這棵 DOM 真的長在 <body> 的直接子層,不在 .app 裡面。但元件關係完全沒變:props 照傳、emit('close') 照收、slot 照分發,甚至上一節的渲染作用域規則也照舊。被傳送的只有 DOM,不是元件。

順帶注意這個範例的分工:開關狀態 showModal 由父元件持有,modal 只負責發出 close 事件,單向資料流在 Teleport 之下依然成立。

常見錯誤:Teleport 的目標不存在

<Teleport> 掛載時,to 指向的目標必須已經存在於 DOM,否則元件渲染失敗、console 報錯。在本章這種 client-only 的 SPA 裡,to="body" 一定找得到目標;日後若接觸伺服器端渲染 (SSR),慣例則是改用專用容器。若指向自訂容器(如 to="#modal-root"),就要確保那個元素在 Teleport 掛載前已經就位,例如寫死在 index.html 裡。

也提醒一句:這個範例是 Teleport 的教學最小版。真正上線的 modal 還需要焦點管理與 ARIA 屬性等可存取性處理,本章最後的「AI 協作」一頁會列出完整的檢查清單與修正範例。

Deferred Teleport (Vue 3.5 新增)

「目標必須先存在」有一個惱人的邊角:如果目標容器也是同一個畫面裡由 Vue 渲染的,而且在元件樹裡的位置比 Teleport 晚掛載呢?照規則就是失敗,即使目標「馬上就要出現了」。

Vue 3.5 的 defer 屬性處理的正是這個情況:

vue
<template>
  <Teleport defer to="#late-target">
    <p>傳送的內容</p>
  </Teleport>

  <!-- 目標在 Teleport 之後才渲染,加了 defer 才等得到它 -->
  <div id="late-target"></div>
</template>

加上 defer,Teleport 會把「尋找目標」延後到同一輪掛載/更新的其他內容都掛載完之後再進行。要畫清楚它的界線:defer 允許的是目標位於同一輪掛載中較晚建立的 Vue DOM;如果目標是下一個 tick、甚至一秒後才出現(例如非同步載入才渲染的容器),defer 也等不到,照樣報錯。它解決的是「同輪順序」問題,不是「任意時間點的晚到」。

<Suspense>:等非同步內容就緒

第二個主角處理「等」。有些元件天生是非同步的:<script setup> 裡有頂層 await,資料沒回來之前,元件沒有東西可渲染。

src/components/BookSummary.vue

vue
<script setup>
// 頂層 await:這個元件成為 async 元件,渲染會等資料就緒
const summary = await new Promise((resolve) => {
  setTimeout(() => resolve('一本 008 天絕對看不完的 Vue 指南。'), 1500)
})
</script>

<template>
  <p>{{ summary }}</p>
</template>

這種元件直接放進模板,等待期間那個位置就是一片空白。<Suspense> 讓等待有畫面:#default 放非同步內容,#fallback 放等待中的替代畫面:

src/App.vue

vue
<script setup>
import BookSummary from './components/BookSummary.vue'
</script>

<template>
  <main class="app">
    <Suspense>
      <BookSummary />

      <template #fallback>
        <p>書籍簡介載入中⋯</p>
      </template>
    </Suspense>
  </main>
</template>

<style scoped>
.app {
  padding: 2rem;
}
</style>

頁面先顯示「載入中」,1.5 秒後換成簡介內容。<Suspense> 會等它底下所有非同步依賴(含深層子元件)都就緒,才一次切換到正式內容,避免畫面東一塊西一塊地陸續浮現。

兩件事要先說清楚:

  • <Suspense> 是實驗性 API。官方文件明標 experimental,行為可能在未來版本調整;理解概念沒問題,大量投入生產環境前請先確認當時的官方狀態。
  • 它不處理錯誤await 的 Promise 若 rejected,<Suspense> 不會幫忙顯示錯誤畫面;錯誤會往上冒,要靠上一章點過名的 onErrorCaptured() 在祖先元件接住。

跳出與等待都會了。本章還剩最後一塊:元件切換的瞬間目前都是「硬切」,下一節用 <Transition> 讓進場與退場有動畫可言。