本文 AI 產出,尚未審核

Vue3 教學:使用 Teleport 與 Portals 實作 Modal / Tooltip


簡介

在單頁應用程式 (SPA) 中,ModalTooltip 常被用來呈現臨時資訊、表單或操作確認。這類 UI 元件通常需要「脫離」原本的 DOM 結構,掛載在較高層級(例如 body)上,以避免被父層的 CSS(overflow:hiddenz-index 等)所限制。

Vue 3 提供了 Teleport(傳送)這個內建的功能,讓開發者可以輕鬆將子樹搬移到任意的目標節點。結合 Vue 的組件化特性,我們甚至可以把 Teleport 視為 Portal(入口)來使用,建立可重用、可測試的 Modal 與 Tooltip。

本篇文章將從概念說明、實作範例、常見陷阱與最佳實踐,帶你一步步掌握在 Vue 3 中使用 Teleport 建構彈出層的技巧,適合 初學者 了解原理,也能讓 中階開發者 快速套用於實際專案。


核心概念

1. Teleport 是什麼?

  • Teleport 是 Vue 3 新增的內建組件 <teleport>,它會把它的子節點「傳送」到指定的目標 DOM 節點(to 屬性),而不是保留在原本的父層。
  • 目標節點可以是 CSS selector(如 #modal-root)或是實體的 DOM 元素。
  • Teleport 仍然保持 響應式,子組件的狀態、事件、插槽等都會正常運作,只是渲染位置不同。
<!-- 原始位置 -->
<div id="app">
  <my-modal v-if="show" @close="show = false" />
</div>

<!-- Teleport 目標 -->
<div id="modal-root"></div>
<!-- MyModal.vue -->
<template>
  <teleport to="#modal-root">
    <div class="modal-backdrop" @click.self="$emit('close')">
      <div class="modal-content">
        <slot />
        <button @click="$emit('close')">關閉</button>
      </div>
    </div>
  </teleport>
</template>

<script setup>
defineProps(['show'])
</script>

<style scoped>
.modal-backdrop { /* ... */ }
.modal-content   { /* ... */ }
</style>

重點@click.self 只在點擊 backdrop 本身時觸發,避免點擊內容區域時誤關閉。

2. 為什麼要用 Teleport?

情境 若不使用 Teleport 使用 Teleport 的好處
父層有 overflow: hidden Modal 會被裁切 脫離父層,完整顯示
父層設定 z-index Tooltip 被遮蔽 可直接掛在 body,最上層
多層嵌套的局部狀態 需要在每層傳遞事件 Teleport 保持事件流,無需額外傳遞
測試/SSR 難以定位 Teleport 在伺服器端仍會渲染到目標容器

3. Portal 的概念延伸

在 React 中常見「Portals」的概念,Vue 3 的 Teleport 正是對等實作。你可以把 Portal 想成「一個專門負責搬移子樹的容器」,而 Teleport 已經幫你封裝好所有細節。

小技巧:如果你想在多個地方共用同一個 Teleport 目標(例如多個 Modal),只需要在根頁面一次性建立 <div id="modal-root"></div>,所有 Teleport 都會自動掛載到同一個容器。

4. 實作範例

以下提供 五個 常見且實用的範例,從最簡單的 Tooltip 到可拖曳的 Modal,示範如何結合 Teleport、Composition API 與 CSS。

4.1 基本 Tooltip

<!-- Tooltip.vue -->
<template>
  <teleport to="body">
    <div
      v-show="visible"
      class="tooltip"
      :style="{ top: `${y}px`, left: `${x}px` }"
    >
      <slot />
    </div>
  </teleport>
</template>

<script setup>
import { ref, onMounted, onBeforeUnmount } from 'vue'

const props = defineProps({
  target: { type: HTMLElement, required: true },
  offsetX: { type: Number, default: 8 },
  offsetY: { type: Number, default: 8 }
})

const visible = ref(false)
const x = ref(0)
const y = ref(0)

function show() {
  visible.value = true
}
function hide() {
  visible.value = false
}
function updatePos(e) {
  x.value = e.clientX + props.offsetX
  y.value = e.clientY + props.offsetY
}

onMounted(() => {
  props.target.addEventListener('mouseenter', show)
  props.target.addEventListener('mouseleave', hide)
  props.target.addEventListener('mousemove', updatePos)
})
onBeforeUnmount(() => {
  props.target.removeEventListener('mouseenter', show)
  props.target.removeEventListener('mouseleave', hide)
  props.target.removeEventListener('mousemove', updatePos)
})
</script>

<style scoped>
.tooltip {
  position: fixed;
  background: #333;
  color: #fff;
  padding: 4px 8px;
  border-radius: 4px;
  font-size: 0.85rem;
  pointer-events: none;
  white-space: nowrap;
  z-index: 9999;
}
</style>

使用方式

<template>
  <button ref="btn">Hover me</button>
  <Tooltip :target="btn">這是提示文字</Tooltip>
</template>

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

const btn = ref(null)
</script>

說明:Tooltip 透過 teleport to="body" 脫離父層,使用 position: fixed 確保不受父容器的限制。

4.2 簡易 Modal

<!-- SimpleModal.vue -->
<template>
  <teleport to="#modal-root">
    <transition name="fade">
      <div v-if="modelValue" class="modal-backdrop" @click.self="close">
        <div class="modal-window">
          <slot />
          <button class="close-btn" @click="close">✕</button>
        </div>
      </div>
    </transition>
  </teleport>
</template>

<script setup>
import { defineEmits, defineProps } from 'vue'

const props = defineProps({
  modelValue: { type: Boolean, required: true }
})
const emit = defineEmits(['update:modelValue'])

function close() {
  emit('update:modelValue', false)
}
</script>

<style scoped>
.modal-backdrop {
  position: fixed;
  inset: 0;
  background: rgba(0, 0, 0, 0.4);
  display: flex;
  align-items: center;
  justify-content: center;
  z-index: 1000;
}
.modal-window {
  background: #fff;
  padding: 1.5rem;
  border-radius: 8px;
  min-width: 300px;
  position: relative;
}
.close-btn {
  position: absolute;
  top: 8px;
  right: 8px;
  background: transparent;
  border: none;
  font-size: 1.2rem;
  cursor: pointer;
}
.fade-enter-active,
.fade-leave-active { transition: opacity .3s; }
.fade-enter-from,
.fade-leave-to { opacity: 0; }
</style>

使用方式

<template>
  <button @click="show = true">開啟 Modal</button>
  <SimpleModal v-model="show">
    <h3>標題</h3>
    <p>這裡放內容。</p>
  </SimpleModal>
</template>

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

const show = ref(false)
</script>

技巧:使用 v-model 讓父層直接控制顯隱,內部只負責 close 事件。

4.3 可拖曳的 Modal

<!-- DraggableModal.vue -->
<template>
  <teleport to="#modal-root">
    <transition name="scale">
      <div v-if="visible" class="backdrop" @click.self="close">
        <div
          class="dialog"
          :style="{ top: `${pos.y}px`, left: `${pos.x}px` }"
          @mousedown.prevent="startDrag"
        >
          <header class="header">
            <slot name="header">Dialog</slot>
            <button @click="close">✕</button>
          </header>
          <section class="body">
            <slot />
          </section>
        </div>
      </div>
    </transition>
  </teleport>
</template>

<script setup>
import { ref, reactive, onMounted, onBeforeUnmount } from 'vue'

const props = defineProps({ modelValue: Boolean })
const emit = defineEmits(['update:modelValue'])

const visible = computed(() => props.modelValue)
function close() { emit('update:modelValue', false) }

const pos = reactive({ x: window.innerWidth / 2 - 200, y: window.innerHeight / 2 - 150 })
let dragging = false
let offset = { x: 0, y: 0 }

function startDrag(e) {
  dragging = true
  offset.x = e.clientX - pos.x
  offset.y = e.clientY - pos.y
  window.addEventListener('mousemove', onMove)
  window.addEventListener('mouseup', stopDrag)
}
function onMove(e) {
  if (!dragging) return
  pos.x = e.clientX - offset.x
  pos.y = e.clientY - offset.y
}
function stopDrag() {
  dragging = false
  window.removeEventListener('mousemove', onMove)
  window.removeEventListener('mouseup', stopDrag)
}

onBeforeUnmount(() => {
  window.removeEventListener('mousemove', onMove)
  window.removeEventListener('mouseup', stopDrag)
})
</script>

<style scoped>
.backdrop {
  position: fixed;
  inset: 0;
  background: rgba(0,0,0,.2);
  display: flex;
  align-items: flex-start;
  justify-content: center;
  padding-top: 2rem;
  z-index: 1100;
}
.dialog {
  position: absolute;
  width: 400px;
  background: #fff;
  border-radius: 6px;
  box-shadow: 0 4px 12px rgba(0,0,0,.15);
}
.header {
  cursor: move;
  padding: .8rem 1rem;
  background: #f5f5f5;
  display: flex;
  justify-content: space-between;
  align-items: center;
}
.body { padding: 1rem; }
.scale-enter-active,
.scale-leave-active { transition: transform .2s ease; }
.scale-enter-from,
.scale-leave-to { transform: scale(0.8); opacity: 0; }
</style>

使用方式

<template>
  <button @click="open = true">開啟可拖曳 Modal</button>
  <DraggableModal v-model="open">
    <template #header>自訂標題</template>
    <p>這是一個可拖曳的對話框。</p>
  </DraggableModal>
</template>

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

const open = ref(false)
</script>

重點@mousedown.prevent 防止文字被選取,並在全局 mousemovemouseup 中處理拖曳,確保滑鼠離開 Dialog 仍可持續拖動。

4.4 多層嵌套的 Tooltip(Portal 組合)

<!-- NestedTooltip.vue -->
<template>
  <teleport to="body">
    <div
      v-show="visible"
      class="nested-tooltip"
      :style="{ top: `${pos.y}px`, left: `${pos.x}px` }"
    >
      <slot />
    </div>
  </teleport>
</template>

<script setup>
import { ref, onMounted, onBeforeUnmount } from 'vue'

const props = defineProps({
  target: { type: HTMLElement, required: true },
  offset: { type: Number, default: 10 }
})

const visible = ref(false)
const pos = ref({ x: 0, y: 0 })

function show() { visible.value = true }
function hide() { visible.value = false }
function move(e) {
  pos.value = {
    x: e.clientX + props.offset,
    y: e.clientY + props.offset
  }
}

onMounted(() => {
  props.target.addEventListener('mouseenter', show)
  props.target.addEventListener('mouseleave', hide)
  props.target.addEventListener('mousemove', move)
})
onBeforeUnmount(() => {
  props.target.removeEventListener('mouseenter', show)
  props.target.removeEventListener('mouseleave', hide)
  props.target.removeEventListener('mousemove', move)
})
</script>

<style scoped>
.nested-tooltip {
  position: fixed;
  background: #222;
  color: #fff;
  padding: 6px 10px;
  border-radius: 4px;
  font-size: 0.8rem;
  z-index: 2000;
}
</style>

父層組件

<template>
  <div class="card" ref="card">
    <h4>卡片標題</h4>
    <p>內容說明</p>
    <button ref="btn">說明</button>

    <!-- 第一層 Tooltip -->
    <NestedTooltip :target="card">卡片資訊</NestedTooltip>

    <!-- 第二層 Tooltip(在 button 上) -->
    <NestedTooltip :target="btn">按鈕說明</NestedTooltip>
  </div>
</template>

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

const card = ref(null)
const btn  = ref(null)
</script>

<style scoped>
.card { position: relative; padding: 1rem; border: 1px solid #ddd; }
</style>

說明:即使 Tooltip 被多層嵌套,只要每個 Tooltip 都 teleportbody,就不會產生層疊或遮蔽問題。

4.5 動態插入的全局 Modal 管理器

在大型專案中,常會有多個不同的 Modal 需要同時管理。下面示範一個 Modal Manager,使用 provide/inject 與 Teleport,讓任何子組件都能透過 useModal() 呼叫全局的 open() 方法。

// modalManager.js
import { reactive, readonly, provide, inject, h, createApp } from 'vue'

const MODAL_KEY = Symbol('ModalManager')

export function createModalManager() {
  const state = reactive({
    stack: []   // 每個項目 { component, props, resolve }
  })

  function open(component, props = {}) {
    return new Promise(resolve => {
      state.stack.push({ component, props, resolve })
    })
  }

  function close(result) {
    const item = state.stack.pop()
    if (item) item.resolve(result)
  }

  provide(MODAL_KEY, { open, close, stack: readonly(state.stack) })
}

// 在根組件 App.vue
<script setup>
import { createModalManager } from './modalManager.js'
createModalManager()
</script>

<template>
  <router-view />
  <ModalContainer />
</template>
<!-- ModalContainer.vue -->
<template>
  <teleport to="#modal-root">
    <transition-group name="modal-fade" tag="div">
      <component
        v-for="(m, idx) in stack"
        :is="m.component"
        v-bind="m.props"
        :key="idx"
        @close="close"
      />
    </transition-group>
  </teleport>
</template>

<script setup>
import { inject } from 'vue'
const { stack, close } = inject(MODAL_KEY)
</script>

<style scoped>
.modal-fade-enter-active,
.modal-fade-leave-active { transition: opacity .3s; }
.modal-fade-enter-from,
.modal-fade-leave-to { opacity: 0; }
</style>

子組件使用

<script setup>
import { inject } from 'vue'
import ConfirmDialog from './ConfirmDialog.vue'

const { open } = inject(MODAL_KEY)

async function deleteItem() {
  const ok = await open(ConfirmDialog, { message: '確定刪除?' })
  if (ok) {
    // 執行刪除
  }
}
</script>

<template>
  <button @click="deleteItem">刪除</button>
</template>

優點:所有 Modal 都集中渲染在 #modal-root,不會散落在各個父層,且透過 Promise 讓呼叫端可以同步等待使用者回應。


常見陷阱與最佳實踐

陷阱 可能的結果 解決方案 / 最佳實踐
忘記在 HTML 中建立目標容器 (#modal-root) Teleport 無法掛載,會直接渲染到原位 index.html 加入 <div id="modal-root"></div>,或使用 document.body
在 SSR 時使用 document 伺服器渲染錯誤 (ReferenceError: document is not defined) 使用 onMountedprocess.client 判斷,或在 teleportto 使用 CSS selector(SSR 仍會渲染)
z-index 沒有統一管理 多個 Modal/Tooltip 重疊混亂 建議在專案的 設計系統 中定義層級變數,如 --z-modal: 1000; --z-tooltip: 1100;
Backdrop 點擊事件穿透 點擊背景卻觸發底層按鈕 使用 @click.self 或在 backdrop 加 pointer-events: auto,子層 pointer-events: none
不必要的全局渲染 每個 Modal 都掛在 body,造成大量 DOM 若 Modal 僅在局部使用,可 teleport 到局部容器,減少全局負擔
動畫與 Teleport 結合不順 進出動畫卡頓或不觸發 使用 transition / transition-group 包裹 Teleport,確保 key 正確,或在 appear 加入 appear-active

最佳實踐

  1. 統一入口:在根 HTML 只放一個 #modal-root#tooltip-root,所有相關元素都從這裡渲染。
  2. 可存取性 (a11y):Modal 開啟時自動聚焦第一個可互動元素,關閉後回到觸發點;使用 role="dialog"aria-modal="true"
  3. 關閉機制:提供 Esc 鍵、點擊背景、明確的關閉按鈕三種方式,提升使用者體驗。
  4. 避免滾動穿透:Modal 開啟時於 bodyoverflow: hidden,或在 backdrop 上使用 touch-action: none
  5. 組件化:把 Teleport、動畫、事件抽離成可重用的基礎組件(如 BaseModalBaseTooltip),再在各個業務需求上擴展。

實際應用場景

場景 為何適合使用 Teleport 範例說明
表單驗證失敗的彈出提示 需要在最上層顯示錯誤訊息,避免被表單容器裁切 使用 SimpleModal 包裹錯誤訊息,teleport#modal-root
圖片畫廊的全螢幕預覽 全螢幕需要覆蓋所有 UI,且不受父層 overflow 限制 建立 FullscreenViewerteleportbody,加上 position: fixed
即時說明的 Tooltip 多個 UI 元件同時顯示說明文字,必須層級最高 使用 Tooltip 組件,將每個 tooltip teleportbody,避免相互遮蔽
多步驟的導覽 (Wizard) Modal 每一步都可能是獨立的子組件,需要共用同一個 backdrop ModalManager 中動態推入不同的步驟組件,集中渲染
動態載入的第三方插件 (如圖表、地圖) 插件內部可能自行在 body 新增 DOM,若父層有 overflow:hidden 會失效 先在根層 teleport 插件容器,確保外部樣式不干擾

總結

Vue 3 的 Teleport 為 UI 彈出層(Modal、Tooltip、Popover 等)提供了最直觀且效能友善的解法。透過 Portal 的概念,我們可以:

  • 脫離父層限制,保證彈出層不被 overflowz-index 阻擋。
  • 保持響應式與事件傳遞,不必額外搬移資料或手動觸發事件。
  • 集中管理(如 Modal Manager),讓大型專案的彈窗邏輯更清晰、可測試。

本文示範了 五個實用範例(基礎 Tooltip、簡易 Modal、可拖曳 Modal、多層 Tooltip、全局 Modal Manager),並列出常見陷阱與最佳實踐,幫助你在開發過程中避免踩雷、寫出可維護的彈出層組件。

只要掌握 Teleport 的使用時機、目標容器的規劃,以及 可存取性層級管理關閉機制 等細節,你就能在 Vue 3 專案中輕鬆打造 流暢、可靠、易維護 的 Modal 與 Tooltip,提升整體使用者體驗。祝開發順利!