Vue3 教學:Teleport 與 Portals – Teleport 是什麼?
簡介
在單頁應用 (SPA) 中,我們常會遇到「需要把一段 UI 從目前的組件樹搬到別的地方」的需求,例如全域的 Modal、Toast、Dropdown,或是需要在 <body> 之下直接掛載的彈出層。
傳統的做法是透過 document.body.appendChild 手動搬移 DOM,或是使用第三方的 Portal 套件。這樣的方式不但破壞了 Vue 的虛擬 DOM 管理,還會讓狀態同步變得複雜。
Vue 3 為了解決這類需求,推出了 Teleport(中文常譯為「瞬移」),它是 Vue 官方內建的 Portals 實作。透過 Teleport,我們可以 在保持組件邏輯完整的同時,將渲染結果搬到任意的 DOM 節點,而不必擔心事件綁定、資料流或樣式繼承的問題。
本文將從概念、語法、實作範例、常見陷阱與最佳實踐,逐層解析 Teleport 的使用方式,讓你在開發中能夠 快速、可靠 地處理跨層級 UI。
核心概念
1. Teleport 的基本語法
<template>
<!-- 這裡是原本的組件結構 -->
<button @click="show = true">開啟 Modal</button>
<!-- Teleport 會把裡面的內容搬到目標節點 -->
<teleport to="body">
<div v-if="show" class="modal">
<p>這是被 Teleport 的 Modal 內容。</p>
<button @click="show = false">關閉</button>
</div>
</teleport>
</template>
<script setup>
import { ref } from 'vue'
const show = ref(false)
</script>
<style scoped>
.modal {
position: fixed;
inset: 0;
background: rgba(0,0,0,0.5);
display: flex;
align-items: center;
justify-content: center;
}
</style>
to屬性接受 CSS 選擇器、DOM 元素 或 函式回傳的元素,決定最終渲染的目標。- Teleport 的內容會 保留在原本的組件作用域,因此
show、methods、computed 等仍然可以直接使用。
2. 為什麼 Teleport 能保持「資料流」?
Vue 的 響應式系統 是基於 虛擬 DOM(vnode)運作的。Teleport 並不是真正將組件搬移,而是 在渲染階段把 vnode 的實際掛載點改寫。因此:
- 事件 (
@click、@input…) 仍然透過 Vue 的事件系統冒泡到原始組件。 - 插槽 (
<slot>) 仍然在原始父子關係中解析,讓內容可以使用父層的資料。 - 樣式繼承:CSS 繼承規則仍以實際 DOM 結構為準,若需要全域樣式,建議把樣式寫在目標節點或使用
:global。
3. 多個 Teleport、條件渲染與動態目標
3.1 多個 Teleport 同時存在
<teleport to="#modal-root">
<div class="modal">Modal 內容</div>
</teleport>
<teleport to="#tooltip-root">
<div class="tooltip">提示文字</div>
</teleport>
- 每個 Teleport 可以指向不同的目標容器,讓 UI 結構更清晰。
3.2 條件渲染
<teleport v-if="show" to="body">
<div class="modal">...</div>
</teleport>
- 使用
v-if或v-show皆可,Vue 會在條件變化時自動掛載/卸載。
3.3 動態目標
<teleport :to="targetSelector">
<div class="popup">彈出層</div>
</teleport>
<script setup>
import { ref } from 'vue'
const targetSelector = ref('#default-root')
// 在某個事件後改變目標
function moveToSidebar() {
targetSelector.value = '#sidebar-root'
}
</script>
:to可以是 reactive 的,讓 UI 在執行時切換掛載位置。
4. Teleport 與 SSR(伺服器端渲染)
在 SSR 環境下,Vue 仍會先在 服務端渲染 完整的 HTML,<teleport> 會把內容渲染到相同的目標位置。唯一需要注意的是:
- 目標容器必須 在伺服器端的模板中已經存在。如果目標是動態插入的元素,需在
app.mount前手動建立,或使用client-only包裝 Teleport。
程式碼範例
以下提供 5 個實用範例,涵蓋常見需求與技巧。每個範例均附上說明註解,方便直接貼上測試。
範例 1:全局 Modal(最簡單的 Teleport)
<!-- App.vue -->
<template>
<button @click="open = true">顯示 Modal</button>
<!-- 把 Modal 移到 <body>,避免被父層 CSS 影響 -->
<teleport to="body">
<div v-if="open" class="modal-backdrop" @click.self="open = false">
<div class="modal-content">
<h2>全域 Modal</h2>
<p>內容可以直接使用 App.vue 的資料。</p>
<button @click="open = false">關閉</button>
</div>
</div>
</teleport>
</template>
<script setup>
import { ref } from 'vue'
const open = ref(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;
}
.modal-content {
background: white;
padding: 1.5rem;
border-radius: 8px;
}
</style>
重點:使用
@click.self只在點擊遮罩層時關閉,避免點擊內容區也觸發。
範例 2:Toast 通知(多個 Teleport + 動態目標)
<!-- ToastContainer.vue -->
<template>
<teleport :to="container">
<transition-group name="toast" tag="div" class="toast-wrapper">
<div v-for="msg in messages" :key="msg.id" class="toast-item">
{{ msg.text }}
<button @click="remove(msg.id)">✕</button>
</div>
</transition-group>
</teleport>
</template>
<script setup>
import { ref, onMounted } from 'vue'
const container = ref('#toast-root')
const messages = ref([])
function push(msg) {
const id = Date.now()
messages.value.push({ id, text: msg })
// 3 秒自動移除
setTimeout(() => remove(id), 3000)
}
function remove(id) {
messages.value = messages.value.filter(m => m.id !== id)
}
// 讓外部可以呼叫 push
export { push }
</script>
<style scoped>
.toast-wrapper {
position: fixed;
top: 1rem;
right: 1rem;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.toast-item {
background: #323232;
color: #fff;
padding: 0.5rem 1rem;
border-radius: 4px;
display: flex;
align-items: center;
justify-content: space-between;
}
.toast-enter-active, .toast-leave-active { transition: opacity .3s; }
.toast-enter-from, .toast-leave-to { opacity: 0; }
</style>
使用方式(在任意組件):
import { push as toast } from '@/components/ToastContainer.vue'
toast('資料已儲存成功')
toast('發生錯誤,請稍後再試')
技巧:
transition-group搭配 Teleport,可讓 動畫 正常運作。
範例 3:Dropdown 選單(在特定容器內 Teleport)
<!-- Dropdown.vue -->
<template>
<div class="dropdown" ref="trigger">
<button @click="open = !open">選擇項目 ▼</button>
<!-- 把選單搬到 .dropdown-portal,避免被 overflow:hidden 裁切 -->
<teleport :to="portalEl">
<ul v-show="open" class="menu" @click.outside="open = false">
<li v-for="item in items" :key="item" @click="select(item)">
{{ item }}
</li>
</ul>
</teleport>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
const open = ref(false)
const items = ['Apple', 'Banana', 'Cherry']
const trigger = ref(null)
const portalEl = ref(null)
onMounted(() => {
// 假設在根元素下有一個專門放 portal 的容器
portalEl.value = document.querySelector('#dropdown-portal')
})
function select(item) {
console.log('選取:', item)
open.value = false
}
</script>
<style scoped>
.dropdown { position: relative; display: inline-block; }
.menu {
position: absolute;
top: 100%; left: 0;
background: white;
border: 1px solid #ddd;
list-style: none;
padding: 0;
margin: 0;
width: 120px;
}
.menu li {
padding: 0.5rem;
cursor: pointer;
}
.menu li:hover { background: #f0f0f0; }
</style>
注意:
@click.outside需要自行實作(或使用v-click-outside指令),因為 Teleport 會把元素搬離原本的 DOM,必須在全局監聽點擊。
範例 4:在 SSR 中使用 Teleport(Nuxt 3 示例)
<!-- components/Modal.vue -->
<template>
<teleport to="#modal-root">
<div v-if="show" class="modal">
<slot />
<button @click="show = false">關閉</button>
</div>
</teleport>
</template>
<script setup>
import { ref, onMounted } from 'vue'
const show = ref(false)
// Nuxt 3 中,確保目標容器在 server side 已存在
onMounted(() => {
if (!document.querySelector('#modal-root')) {
const div = document.createElement('div')
div.id = 'modal-root'
document.body.appendChild(div)
}
})
</script>
<style scoped>
.modal {
position: fixed;
inset: 0;
background: rgba(0,0,0,0.6);
display: flex;
align-items: center;
justify-content: center;
color: #fff;
}
</style>
在 app.vue 或 layout.vue 中加入:
<div id="modal-root"></div>
重點:SSR 時 先在 HTML 中放置目標容器,避免 hydration 警告。
範例 5:Portal + Scoped Slot(子組件提供 UI,父組件決定渲染位置)
<!-- PortalProvider.vue -->
<template>
<slot name="content" :open="open" :toggle="toggle" />
<teleport :to="target">
<slot name="portal" />
</teleport>
</template>
<script setup>
import { ref } from 'vue'
const target = ref('#portal-target')
const isOpen = ref(false)
function open() { isOpen.value = true }
function close() { isOpen.value = false }
function toggle() { isOpen.value = !isOpen.value }
</script>
使用方式:
<PortalProvider>
<!-- 父層決定觸發按鈕 -->
<template #content="{ toggle }">
<button @click="toggle">顯示側邊欄</button>
</template>
<!-- 內容放在指定的 portal 容器 -->
<template #portal>
<aside class="sidebar">這是側邊欄內容</aside>
</template>
</PortalProvider>
<!-- 在根元素中放置目標容器 -->
<div id="portal-target"></div>
好處:父子組件可以 解耦 UI 位置與業務邏輯,非常適合設計「可插拔」的 UI 元件庫。
常見陷阱與最佳實踐
| 陷阱 | 說明 | 解決方案 / Best Practice |
|---|---|---|
| 目標容器不存在 | Teleport 在掛載時若找不到 to 指定的節點,會直接渲染在原位,導致 UI 位置錯亂。 |
在 mounted 或 setup 中 先檢查或建立 目標元素;在 SSR 時於 HTML 模板中寫入容器。 |
| 樣式繼承問題 | Teleport 會把內容搬到新位置,CSS 繼承會依照實際 DOM 層級變化,可能失去父層樣式。 | 使用 全域 CSS(或 :deep)或在目標容器上 加上需要的樣式;避免依賴父層的排版屬性。 |
| 事件冒泡被截斷 | 若目標容器設置了 pointer-events: none 或被其他元素遮擋,事件可能無法正常冒泡。 |
確保目標容器 可點擊,或在 Teleport 內部手動 emit 事件給父層。 |
| SSR 水合錯誤 | 目標容器在服務端渲染時缺失,會出現 Hydration completed but contains mismatches。 |
在 server template 中加入目標容器,或使用 client-only 包裝 Teleport。 |
| Transition 與 Teleport 的衝突 | Transition/TransitionGroup 必須在 Teleport 內部使用,否則動畫不會正確觸發。 | 把 <transition> 包裹在 Teleport 內部,或使用 appear 屬性確保首次渲染也有動畫。 |
最佳實踐小結
- 明確規劃目標容器:在根 HTML 中預留
<div id="modal-root">、#toast-root等,讓 Teleport 有固定掛載點。 - 保持組件內部的可讀性:即使 UI 被搬移,仍建議在組件內部寫完整的 template、script、style,不要把 Teleport 的內容散落在多個檔案。
- 使用
v-if控制掛載與卸載:避免在不需要時仍保留在 DOM 中,減少記憶體佔用。 - 測試不同螢幕尺寸:因為 Teleport 可能脫離父層的
overflow,要確認在行動裝置上不會被遮蔽。 - 結合 TypeScript:若專案使用 TS,
to屬性可限定為string | HTMLElement | (() => HTMLElement),提升編譯期安全性。
實際應用場景
| 場景 | 為什麼選 Teleport? | 範例簡述 |
|---|---|---|
| 全域 Modal / Dialog | 需要遮蔽全畫面且不受父層 overflow:hidden 影響。 |
上述 範例 1。 |
| Toast / Notification | 多條訊息同時顯示且需固定在畫面右上角。 | 範例 2 使用 transition-group。 |
| Dropdown / Tooltip | 下拉選單或提示文字常被父層的 z-index、overflow 截斷。 |
範例 3 把選單搬到專屬容器。 |
| 側邊欄 / Drawer | 需要在 body 之外滑入,且保持與主內容的資料同步。 |
可參考 範例 5 的 PortalProvider。 |
| SSR 頁面的全域彈窗 | 初始渲染時就需要在正確位置,避免 hydration 警告。 | 範例 4 的 Nuxt 3 實作。 |
實務建議:在大型專案中,可建立 共用的 Portal 容器(如
#modal-root、#toast-root、#portal-root),並在main.ts中一次性掛載,讓所有組件只要to="#modal-root"即可使用,減少重複的 DOM 操作。
總結
- Teleport 是 Vue 3 官方提供的 Portals 實作,讓開發者可以在保持組件邏輯完整的同時,把 UI 瞬間搬移到任意的 DOM 節點。
- 它的核心是 在渲染階段改變 vnode 的掛載點,因此資料流、事件、插槽都不會被破壞。
- 透過
to、條件渲染、動態目標,我們可以輕鬆實作 Modal、Toast、Dropdown、Drawer 等跨層級 UI。 - 使用時要留意 目標容器是否存在、樣式繼承、SSR 水合 等常見陷阱,並遵守 預先建立容器、使用 v-if、正確設定 CSS 的最佳實踐。
- 在實務上,將 Teleport 與 Transition、Scoped Slot、Composition API 結合,可打造 彈性、可重用、易維護 的 UI 元件庫。
掌握 Teleport 後,你的 Vue3 應用將不再受限於傳統的 DOM 結構,能自由地在任意位置呈現視覺層級最高的介面,提升使用者體驗與開發效率。祝你寫出更乾淨、更強大的 Vue3 程式碼! 🚀