Vue Teleport
<Teleport> 是 Vue 3 的內建元件,它可以將元件的一部分模板「傳送」到 DOM 中的其他位置,即使這個位置在元件的 DOM 結構之外。這對於 Modal、Toast、Tooltip 等需要脫離父元件 DOM 層級的 UI 元素特別有用。
為什麼需要 Teleport?
假設你在一個深層巢狀的元件中建立一個 Modal:
<template>
<div class="outer">
<div class="inner">
<div class="deeply-nested">
<!-- Modal 在這裡 -->
<div class="modal">
<!-- Modal 內容 -->
</div>
</div>
</div>
</div>
</template>
問題:
- CSS 定位問題:Modal 使用
position: fixed時,如果祖先元素有transform、perspective或filter,定位會相對於該祖先而非視窗 - z-index 問題:Modal 的 z-index 可能被父元素的 stacking context 限制
- overflow 問題:父元素的
overflow: hidden可能裁切 Modal
使用 <Teleport> 可以將 Modal 的 DOM 移動到 <body> 下,解決這些問題。
基本用法
<script setup>
import { ref } from 'vue'
const isOpen = ref(false)
</script>
<template>
<button @click="isOpen = true">開啟 Modal</button>
<!-- 將內容傳送到 body -->
<Teleport to="body">
<div v-if="isOpen" class="modal-overlay">
<div class="modal">
<h2>這是一個 Modal</h2>
<p>這個 Modal 被傳送到了 body 下</p>
<button @click="isOpen = false">關閉</button>
</div>
</div>
</Teleport>
</template>
<style scoped>
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
justify-content: center;
align-items: center;
z-index: 1000;
}
.modal {
background: white;
padding: 20px;
border-radius: 8px;
min-width: 300px;
}
</style>
to 屬性
to 屬性指定目標容器,可以是:
- CSS 選擇器
<template>
<!-- 傳送到 body -->
<Teleport to="body">...</Teleport>
<!-- 傳送到特定 ID 的元素 -->
<Teleport to="#modal-container">...</Teleport>
<!-- 傳送到特定 class 的元素 -->
<Teleport to=".popup-container">...</Teleport>
</template>
- DOM 元素(動態綁定)
<script setup>
import { ref, onMounted } from 'vue'
const container = ref(null)
</script>
<template>
<div ref="container"></div>
<Teleport :to="container" v-if="container">
<p>傳送到上面的 div</p>
</Teleport>
</template>
目標元素必須在
<Teleport> 掛載前就存在於 DOM 中。disabled 屬性
disabled 屬性可以禁用 Teleport,讓內容保留在原處:
<script setup>
import { ref } from 'vue'
const isMobile = ref(window.innerWidth < 768)
</script>
<template>
<!-- 在手機上不傳送,保留在原處 -->
<Teleport to="body" :disabled="isMobile">
<div class="modal">...</div>
</Teleport>
</template>
這在響應式設計中很有用,例如在桌面版使用 Modal,在手機版使用內嵌顯示。
多個 Teleport 到同一目標
多個 <Teleport> 可以傳送到同一個目標元素,它們會按照順序追加:
<template>
<Teleport to="#modals">
<div>第一個 Modal</div>
</Teleport>
<Teleport to="#modals">
<div>第二個 Modal</div>
</Teleport>
</template>
渲染結果:
<div id="modals">
<div>第一個 Modal</div>
<div>第二個 Modal</div>
</div>
延遲 Teleport(Vue 3.5+)
Vue 3.5 新增了 defer 屬性,可以延遲 Teleport 直到目標元素存在:
<template>
<Teleport defer to="#late-container">
<div>會等到目標存在才傳送</div>
</Teleport>
<!-- 這個元素稍後才會渲染 -->
<div id="late-container"></div>
</template>
實際範例
通用 Modal 元件
<!-- Modal.vue -->
<script setup>
import { watch } from 'vue'
const props = defineProps({
visible: Boolean,
title: {
type: String,
default: ''
},
closeOnOverlay: {
type: Boolean,
default: true
}
})
const emit = defineEmits(['close'])
// 防止背景滾動
watch(() => props.visible, (visible) => {
if (visible) {
document.body.style.overflow = 'hidden'
} else {
document.body.style.overflow = ''
}
})
function handleOverlayClick() {
if (props.closeOnOverlay) {
emit('close')
}
}
</script>
<template>
<Teleport to="body">
<Transition name="modal">
<div v-if="visible" class="modal-overlay" @click.self="handleOverlayClick">
<div class="modal">
<header class="modal-header" v-if="title || $slots.header">
<slot name="header">
<h2>{{ title }}</h2>
</slot>
<button class="close-btn" @click="emit('close')">×</button>
</header>
<main class="modal-body">
<slot></slot>
</main>
<footer class="modal-footer" v-if="$slots.footer">
<slot name="footer"></slot>
</footer>
</div>
</div>
</Transition>
</Teleport>
</template>
<style scoped>
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
justify-content: center;
align-items: center;
z-index: 1000;
}
.modal {
background: white;
border-radius: 8px;
min-width: 400px;
max-width: 90vw;
max-height: 90vh;
overflow: auto;
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px;
border-bottom: 1px solid #eee;
}
.modal-header h2 {
margin: 0;
}
.close-btn {
background: none;
border: none;
font-size: 24px;
cursor: pointer;
padding: 0;
line-height: 1;
}
.modal-body {
padding: 16px;
}
.modal-footer {
padding: 16px;
border-top: 1px solid #eee;
display: flex;
justify-content: flex-end;
gap: 8px;
}
/* 過渡動畫 */
.modal-enter-active,
.modal-leave-active {
transition: opacity 0.3s ease;
}
.modal-enter-active .modal,
.modal-leave-active .modal {
transition: transform 0.3s ease;
}
.modal-enter-from,
.modal-leave-to {
opacity: 0;
}
.modal-enter-from .modal,
.modal-leave-to .modal {
transform: scale(0.9);
}
</style>
使用:
<script setup>
import { ref } from 'vue'
import Modal from './Modal.vue'
const showModal = ref(false)
</script>
<template>
<button @click="showModal = true">開啟 Modal</button>
<Modal :visible="showModal" title="確認刪除" @close="showModal = false">
<p>確定要刪除這個項目嗎?</p>
<template #footer>
<button @click="showModal = false">取消</button>
<button class="danger" @click="handleDelete">刪除</button>
</template>
</Modal>
</template>
Toast 通知
<!-- ToastContainer.vue -->
<script setup>
import { ref, provide } from 'vue'
const toasts = ref([])
let id = 0
function add(message, type = 'info', duration = 3000) {
const toast = { id: id++, message, type }
toasts.value.push(toast)
if (duration > 0) {
setTimeout(() => remove(toast.id), duration)
}
return toast.id
}
function remove(id) {
toasts.value = toasts.value.filter(t => t.id !== id)
}
// 提供給子元件使用
provide('toast', {
success: (msg) => add(msg, 'success'),
error: (msg) => add(msg, 'error'),
warning: (msg) => add(msg, 'warning'),
info: (msg) => add(msg, 'info')
})
</script>
<template>
<slot></slot>
<Teleport to="body">
<div class="toast-container">
<TransitionGroup name="toast">
<div
v-for="toast in toasts"
:key="toast.id"
:class="['toast', toast.type]"
@click="remove(toast.id)"
>
{{ toast.message }}
</div>
</TransitionGroup>
</div>
</Teleport>
</template>
<style scoped>
.toast-container {
position: fixed;
top: 20px;
right: 20px;
z-index: 2000;
display: flex;
flex-direction: column;
gap: 10px;
}
.toast {
padding: 12px 20px;
border-radius: 4px;
cursor: pointer;
min-width: 200px;
}
.toast.success { background: #d4edda; color: #155724; }
.toast.error { background: #f8d7da; color: #721c24; }
.toast.warning { background: #fff3cd; color: #856404; }
.toast.info { background: #d1ecf1; color: #0c5460; }
.toast-enter-active,
.toast-leave-active {
transition: all 0.3s ease;
}
.toast-enter-from {
opacity: 0;
transform: translateX(100%);
}
.toast-leave-to {
opacity: 0;
transform: translateX(100%);
}
</style>
全螢幕載入
<!-- FullscreenLoading.vue -->
<script setup>
defineProps({
visible: Boolean,
text: {
type: String,
default: '載入中...'
}
})
</script>
<template>
<Teleport to="body">
<Transition name="fade">
<div v-if="visible" class="fullscreen-loading">
<div class="spinner"></div>
<p>{{ text }}</p>
</div>
</Transition>
</Teleport>
</template>
<style scoped>
.fullscreen-loading {
position: fixed;
inset: 0;
background: rgba(255, 255, 255, 0.9);
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
z-index: 9999;
}
.spinner {
width: 50px;
height: 50px;
border: 4px solid #f3f3f3;
border-top: 4px solid #42b883;
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.3s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
</style>
注意事項
保持元件邏輯關係:雖然 DOM 被移動了,但元件的邏輯關係(props、events、provide/inject)保持不變
樣式問題:
scoped樣式仍然有效,但要注意 CSS 繼承可能會改變多根節點:
<Teleport>可以傳送多個根節點SSR 考量:在伺服器端渲染時,Teleport 會被渲染為註釋,在客戶端 hydration 時才會傳送