本文 AI 產出,尚未審核
Vue3 教學:Teleport 與 Portals – 多層 Teleport 嵌套
簡介
在單頁應用程式 (SPA) 中,我們常會遇到需要把 UI 元件渲染到「畫面之外」的情況,例如全域的 Modal、Tooltip、或是 Drawer。Vue 3 提供的 <teleport> 元件正是為了這類需求而設計,讓開發者能夠將子樹的 DOM 傳送 到指定的目標容器,而不必改變組件的層級結構。
然而在實務開發中,多層 Teleport 嵌套(即 Teleport 內部再包含 Teleport)也是常見的需求。例如,一個 Modal 裡面可能還要放置一個 Tooltip,而 Tooltip 本身又需要被傳送到根節點的 body。如果不瞭解多層 Teleport 的工作原理,容易出現樣式、事件或是生命週期錯亂的問題。
本篇文章將深入說明 多層 Teleport 嵌套 的概念、實作方式與最佳實踐,並提供完整的程式碼範例,協助初學者與中階開發者在 Vue 3 專案中安全、有效地使用 Teleport。
核心概念
1. Teleport 基本原理
<teleport>會在 渲染階段 把其子節點的 VDOM 移動到to屬性指定的目標元素(CSS selector 或 HTMLElement)。- 被傳送的內容仍然屬於原本的 Vue 組件樹,保持相同的響應式資料、事件與生命週期。
- Teleport 只是一個 虛擬容器,不會產生額外的 DOM 結構。
重點:即使 DOM 被搬移,Vue 仍會在原本的組件樹中追蹤它,這也是 Teleport 能夠正確觸發
mounted、updated等鉤子的關鍵。
2. 為什麼會需要多層 Teleport
- 多層 UI 結構:如 Modal → Dropdown → Tooltip,每層都希望掛在不同的根容器上,以避免 CSS 層級衝突或 overflow 隱藏。
- 動態目標:某些情況下,內層 Teleport 的目標可能在外層 Teleport 之後才被掛載(例如在
mounted時才產生的 DOM)。 - 模組化設計:把每個 UI 元件的 Teleport 目標寫死在組件內,可以讓元件更具可重用性,無需在父層手動指定。
3. 多層 Teleport 的渲染順序
| 階段 | 內層 Teleport | 外層 Teleport |
|---|---|---|
| mount | 先渲染子樹,產生 VDOM | 再把子樹搬移到外層目標 |
| update | 內層更新會直接作用於目標容器 | 外層只會重新搬移一次(除非目標變更) |
| unmount | 內層先解除掛載,釋放事件 | 外層最後解除,回收整個傳送容器 |
技巧:若外層 Teleport 的目標在
mounted後才出現,內層 Teleport 仍會正常工作,因為 Vue 會在目標出現後自動重新搬移。
程式碼範例
以下示範三個常見的多層 Teleport 場景,並以完整的 Vue 3 SFC(單檔元件)呈現。
範例 1:Modal 裡的 Tooltip(兩層 Teleport)
<!-- ModalWithTooltip.vue -->
<template>
<!-- 外層 Teleport:把整個 Modal 移到 #modal-root -->
<teleport to="#modal-root">
<div class="modal-overlay" @click="close">
<div class="modal-content" @click.stop>
<h2>通知</h2>
<p>這是一個使用 Teleport 的範例。</p>
<!-- 內層 Teleport:把 Tooltip 移到 body -->
<teleport to="body">
<div
v-if="showTip"
class="tooltip"
:style="{ top: tipPos.y + 'px', left: tipPos.x + 'px' }"
>
這是 Tooltip
</div>
</teleport>
<button @mouseenter="showTip = true" @mouseleave="showTip = false"
@mousemove="updateTipPos">
Hover 我
</button>
<button @click="close">關閉</button>
</div>
</div>
</teleport>
</template>
<script setup>
import { ref } from 'vue'
const emit = defineEmits(['close'])
const showTip = ref(false)
const tipPos = ref({ x: 0, y: 0 })
function close() {
emit('close')
}
function updateTipPos(e) {
tipPos.value = { x: e.clientX + 10, y: e.clientY + 10 }
}
</script>
<style scoped>
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(0,0,0,0.5);
display: flex;
align-items: center;
justify-content: center;
}
.modal-content {
background: white;
padding: 1.5rem;
border-radius: 8px;
position: relative;
}
.tooltip {
position: fixed;
background: #333;
color: #fff;
padding: 0.4rem 0.6rem;
border-radius: 4px;
pointer-events: none;
font-size: 0.85rem;
}
</style>
說明
- 外層 Teleport 把 Modal 整體搬到
#modal-root(通常放在index.html的底部)。- 內層 Teleport 把 Tooltip 直接搬到
body,避免被 Modal 的overflow: hidden截斷。- 兩層 Teleport 的生命週期互不干擾,
showTip的變化只會影響 Tooltip 本身。
範例 2:動態目標的多層 Teleport(三層)
<!-- NestedPortals.vue -->
<template>
<!-- 第一層:把整個卡片搬到 #portal-root -->
<teleport :to="portalRoot">
<div class="card">
<h3>卡片標題</h3>
<!-- 第二層:把下拉選單搬到動態產生的 .dropdown-container -->
<teleport :to="dropdownContainer">
<ul class="dropdown" v-if="open">
<li @click="select('A')">選項 A</li>
<li @click="select('B')">選項 B</li>
</ul>
</teleport>
<!-- 第三層:把提示訊息搬到 body -->
<teleport to="body">
<div class="toast" v-if="msg">{{ msg }}</div>
</teleport>
<button @click="open = !open">切換下拉</button>
</div>
</teleport>
</template>
<script setup>
import { ref, onMounted } from 'vue'
const portalRoot = ref('#portal-root')
const dropdownContainer = ref(null)
const open = ref(false)
const msg = ref('')
// 動態產生下拉容器
onMounted(() => {
const div = document.createElement('div')
div.className = 'dropdown-container'
document.body.appendChild(div)
dropdownContainer.value = div
})
function select(item) {
msg.value = `你選了 ${item}`
open.value = false
setTimeout(() => (msg.value = ''), 2000)
}
</script>
<style scoped>
.card {
border: 1px solid #ddd;
padding: 1rem;
margin: 1rem;
}
.dropdown {
position: absolute;
background: white;
border: 1px solid #ccc;
list-style: none;
padding: 0;
}
.dropdown li {
padding: 0.5rem 1rem;
cursor: pointer;
}
.toast {
position: fixed;
bottom: 1rem;
right: 1rem;
background: #4caf50;
color: white;
padding: 0.8rem 1.2rem;
border-radius: 4px;
}
</style>
說明
- 第一層 Teleport 把卡片搬到
#portal-root,方便集中管理所有「浮層」元件。- 第二層 Teleport 使用在
onMounted時動態建立的容器,展示 動態目標 的彈性。- 第三層 Teleport 把短暫的 toast 訊息搬到
body,確保不受卡片位置限制。
範例 3:遞迴式多層 Teleport(四層)
假設我們有一個 樹狀選單,每個子節點都會在點擊後以 Popover 方式顯示下一層選項。每層 Popover 都需要被搬到 body,而根節點則放在 #app 中。
<!-- RecursiveTree.vue -->
<template>
<ul class="tree">
<TreeNode
v-for="node in data"
:key="node.id"
:node="node"
/>
</ul>
</template>
<script setup>
import { ref } from 'vue'
import TreeNode from './TreeNode.vue'
const data = ref([
{ id: 1, label: '父節點 1', children: [{ id: 3, label: '子節點 1-1' }] },
{ id: 2, label: '父節點 2', children: [{ id: 4, label: '子節點 2-1' }] },
])
</script>
<style scoped>
.tree {
list-style: none;
padding-left: 0;
}
</style>
<!-- TreeNode.vue -->
<template>
<li class="node">
<span @click="toggle">{{ node.label }}</span>
<!-- 第四層 Teleport:把 Popover 移到 body -->
<teleport to="body">
<div
v-if="open"
class="popover"
:style="{ top: pos.y + 'px', left: pos.x + 'px' }"
>
<ul>
<TreeNode
v-for="child in node.children"
:key="child.id"
:node="child"
/>
</ul>
</div>
</teleport>
</li>
</template>
<script setup>
import { ref } from 'vue'
import TreeNode from './TreeNode.vue' // 自己遞迴引用
const props = defineProps({
node: { type: Object, required: true }
})
const open = ref(false)
const pos = ref({ x: 0, y: 0 })
function toggle(e) {
open.value = !open.value
const rect = e.target.getBoundingClientRect()
pos.value = { x: rect.right + 5, y: rect.top }
}
</script>
<style scoped>
.node > span {
cursor: pointer;
display: inline-block;
padding: 0.3rem 0.6rem;
}
.popover {
position: fixed;
background: #fff;
border: 1px solid #aaa;
box-shadow: 0 2px 8px rgba(0,0,0,0.15);
padding: 0.5rem;
z-index: 1000;
}
</style>
說明
TreeNode以 遞迴 方式渲染子節點。每一次展開都會產生一個新的 Popover,透過 第四層 Teleport 把它搬到body。- 由於每層 Popover 都是獨立的 Teleport,層級不會相互干擾,且可藉由 CSS
z-index控制堆疊順序。
常見陷阱與最佳實踐
| 陷阱 | 說明 | 解決方案或最佳實踐 |
|---|---|---|
| 目標不存在 | Teleport 在 mounted 時找不到 to 指定的元素,會把內容渲染回原位。 |
確保目標在 DOM 中已經存在;若是動態建立,使用 nextTick 或 onMounted 後再渲染。 |
| CSS 層級衝突 | 多層 Teleport 可能導致 z-index 無法預期。 |
為每個 Teleport 設定 獨立的層級(例如 z-index: 1000 + depth),或使用 CSS 變數集中管理。 |
| 事件冒泡被截斷 | 被傳送的元素仍在原組件樹,事件會冒泡到原始父層,而非目標容器。 | 若需要在目標容器捕捉事件,可在目標上 手動委派(addEventListener)或使用 stopPropagation。 |
| Scroll/Overflow | 內層 Teleport 放在外層有 overflow: hidden 的容器中,會被裁切。 |
把需要脫離的內容 直接 Teleport 到 body,或調整外層容器的 overflow。 |
| 記憶體洩漏 | 動態建立的目標容器若未在組件卸載時移除,會留下垃圾節點。 | 在 onBeforeUnmount 中 清除 動態產生的 DOM 元素。 |
| SSR(伺服器端渲染) | Teleport 在 SSR 時仍會渲染到目標位置,若目標在客戶端才生成會出錯。 | 使用 v-if="isClient" 或 process.client 判斷,只在客戶端渲染 Teleport。 |
最佳實踐小結
- 統一管理目標容器:在
index.html中預留<div id="modal-root"></div>、<div id="portal-root"></div>,所有 Teleport 都指向這些固定容器,避免「找不到目標」的錯誤。 - 層級命名規則:依照功能(Modal、Tooltip、Toast)使用不同的
z-index範圍,讓樣式更易維護。 - 保持組件自足:讓每個 UI 元件自行決定 Teleport 目標(例如
to="body"),父層不必額外傳遞to,提升可重用性。 - 清理動態元素:使用
onBeforeUnmount移除在onMounted動態創建的容器,防止記憶體泄漏。 - 測試多層交互:在單元測試或端到端測試中,確認每層 Teleport 的事件、樣式與生命週期都正常運作。
實際應用場景
| 場景 | 為何需要多層 Teleport | 具體實作概念 |
|---|---|---|
| 全域通知(Toast) + 內嵌 Modal | Toast 必須在最上層顯示,Modal 內部可能還有 Tooltip。 | Toast Teleport 到 body,Modal Teleport 到 #modal-root,Tooltip 再 Teleport 到 body。 |
| 動態表單向導(Wizard) | 每一步的說明文字使用 Popover,且 Popover 必須脫離表單容器的 overflow:hidden。 |
步驟組件 Teleport 到 #wizard-root,每個 Popover 再 Teleport 到 body。 |
| 圖表套件(Chart)內的 ContextMenu | 圖表容器可能被 position: relative 包住,Menu 必須顯示在視窗最上層。 |
Chart 本身不 Teleport,ContextMenu 使用 Teleport 到 body,若 ContextMenu 裡還有子選單則再嵌套一次。 |
| 多語系切換的漂浮選單 | 語系切換按鈕在頁面左上角,選單需要在最上層且可跨頁面使用。 | 按鈕所在的 NavBar Teleport 到 #nav-root,選單 Teleport 到 body,子選單再 Teleport 到 body。 |
總結
- 多層 Teleport 讓我們能夠在同一個 Vue 組件樹中,將 UI 元件彈性搬移到任意的 DOM 位置,解決層級、overflow、z-index 等常見問題。
- 只要確保 目標容器的存在、層級的合理設計,以及在 組件卸載時清理動態產生的元素,就能安全使用多層 Teleport。
- 透過本篇提供的 三個實作範例(Modal+Tooltip、動態目標的三層 Teleport、遞迴式四層 Teleport),你可以快速掌握在實務專案中如何安排 Teleport 的層級與目標。
- 最後,別忘了在 測試、SSR 與 樣式管理 上做好額外的驗證,讓你的 Vue 3 應用在任何情境下都能保持 穩定、可維護。
祝你在 Vue 3 的開發旅程中,玩得開心、寫得順手! 🎉