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>

問題:

  1. CSS 定位問題:Modal 使用 position: fixed 時,如果祖先元素有 transformperspectivefilter,定位會相對於該祖先而非視窗
  2. z-index 問題:Modal 的 z-index 可能被父元素的 stacking context 限制
  3. 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 屬性指定目標容器,可以是:

  1. CSS 選擇器
<template>
  <!-- 傳送到 body -->
  <Teleport to="body">...</Teleport>

  <!-- 傳送到特定 ID 的元素 -->
  <Teleport to="#modal-container">...</Teleport>

  <!-- 傳送到特定 class 的元素 -->
  <Teleport to=".popup-container">...</Teleport>
</template>
  1. 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>

注意事項

  1. 保持元件邏輯關係:雖然 DOM 被移動了,但元件的邏輯關係(props、events、provide/inject)保持不變

  2. 樣式問題scoped 樣式仍然有效,但要注意 CSS 繼承可能會改變

  3. 多根節點<Teleport> 可以傳送多個根節點

  4. SSR 考量:在伺服器端渲染時,Teleport 會被渲染為註釋,在客戶端 hydration 時才會傳送