Appearance
Teleport 與 Suspense
這一節的兩個內建元件,處理的都是「元件樹的形狀」跟「畫面的需求」對不上的情況:一個讓 DOM 跳出元件樹的位置限制,一個讓非同步的內容有地方等。
元件在裡面,畫面要在外面
想做一個購買確認的 modal。就元件設計而言,它屬於 BookCard:由卡片的按鈕觸發、跟卡片的資料相關,程式碼放一起最合理。但就畫面而言,modal 應該覆蓋整個頁面,如果它的 DOM 待在卡片內部,祖先元素的 overflow: hidden、transform 或 z-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> 讓進場與退場有動畫可言。
