Vue3 教學:使用 Teleport 與 Portals 實作 Modal / Tooltip
簡介
在單頁應用程式 (SPA) 中,Modal 與 Tooltip 常被用來呈現臨時資訊、表單或操作確認。這類 UI 元件通常需要「脫離」原本的 DOM 結構,掛載在較高層級(例如 body)上,以避免被父層的 CSS(overflow:hidden、z-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防止文字被選取,並在全局mousemove、mouseup中處理拖曳,確保滑鼠離開 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 都
teleport到body,就不會產生層疊或遮蔽問題。
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) |
使用 onMounted 或 process.client 判斷,或在 teleport 的 to 使用 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 |
最佳實踐
- 統一入口:在根 HTML 只放一個
#modal-root、#tooltip-root,所有相關元素都從這裡渲染。 - 可存取性 (a11y):Modal 開啟時自動聚焦第一個可互動元素,關閉後回到觸發點;使用
role="dialog"、aria-modal="true"。 - 關閉機制:提供 Esc 鍵、點擊背景、明確的關閉按鈕三種方式,提升使用者體驗。
- 避免滾動穿透:Modal 開啟時於
body加overflow: hidden,或在 backdrop 上使用touch-action: none。 - 組件化:把 Teleport、動畫、事件抽離成可重用的基礎組件(如
BaseModal、BaseTooltip),再在各個業務需求上擴展。
實際應用場景
| 場景 | 為何適合使用 Teleport | 範例說明 |
|---|---|---|
| 表單驗證失敗的彈出提示 | 需要在最上層顯示錯誤訊息,避免被表單容器裁切 | 使用 SimpleModal 包裹錯誤訊息,teleport 到 #modal-root |
| 圖片畫廊的全螢幕預覽 | 全螢幕需要覆蓋所有 UI,且不受父層 overflow 限制 |
建立 FullscreenViewer,teleport 到 body,加上 position: fixed |
| 即時說明的 Tooltip | 多個 UI 元件同時顯示說明文字,必須層級最高 | 使用 Tooltip 組件,將每個 tooltip teleport 到 body,避免相互遮蔽 |
| 多步驟的導覽 (Wizard) Modal | 每一步都可能是獨立的子組件,需要共用同一個 backdrop | 在 ModalManager 中動態推入不同的步驟組件,集中渲染 |
| 動態載入的第三方插件 (如圖表、地圖) | 插件內部可能自行在 body 新增 DOM,若父層有 overflow:hidden 會失效 |
先在根層 teleport 插件容器,確保外部樣式不干擾 |
總結
Vue 3 的 Teleport 為 UI 彈出層(Modal、Tooltip、Popover 等)提供了最直觀且效能友善的解法。透過 Portal 的概念,我們可以:
- 脫離父層限制,保證彈出層不被
overflow、z-index阻擋。 - 保持響應式與事件傳遞,不必額外搬移資料或手動觸發事件。
- 集中管理(如 Modal Manager),讓大型專案的彈窗邏輯更清晰、可測試。
本文示範了 五個實用範例(基礎 Tooltip、簡易 Modal、可拖曳 Modal、多層 Tooltip、全局 Modal Manager),並列出常見陷阱與最佳實踐,幫助你在開發過程中避免踩雷、寫出可維護的彈出層組件。
只要掌握 Teleport 的使用時機、目標容器的規劃,以及 可存取性、層級管理、關閉機制 等細節,你就能在 Vue 3 專案中輕鬆打造 流暢、可靠、易維護 的 Modal 與 Tooltip,提升整體使用者體驗。祝開發順利!