本文 AI 產出,尚未審核

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-ifv-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.vuelayout.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 位置錯亂。 mountedsetup先檢查或建立 目標元素;在 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 屬性確保首次渲染也有動畫。

最佳實踐小結

  1. 明確規劃目標容器:在根 HTML 中預留 <div id="modal-root">#toast-root 等,讓 Teleport 有固定掛載點。
  2. 保持組件內部的可讀性:即使 UI 被搬移,仍建議在組件內部寫完整的 template、script、style,不要把 Teleport 的內容散落在多個檔案。
  3. 使用 v-if 控制掛載與卸載:避免在不需要時仍保留在 DOM 中,減少記憶體佔用。
  4. 測試不同螢幕尺寸:因為 Teleport 可能脫離父層的 overflow,要確認在行動裝置上不會被遮蔽。
  5. 結合 TypeScript:若專案使用 TS,to 屬性可限定為 string | HTMLElement | (() => HTMLElement),提升編譯期安全性。

實際應用場景

場景 為什麼選 Teleport? 範例簡述
全域 Modal / Dialog 需要遮蔽全畫面且不受父層 overflow:hidden 影響。 上述 範例 1
Toast / Notification 多條訊息同時顯示且需固定在畫面右上角。 範例 2 使用 transition-group
Dropdown / Tooltip 下拉選單或提示文字常被父層的 z-indexoverflow 截斷。 範例 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 程式碼! 🚀