本文 AI 產出,尚未審核

Vue3 – Teleport 與 Portals:跨層元件結構維護


簡介

在大型單頁應用 (SPA) 中,畫面常會出現 Modal、Tooltip、Dropdown 等需要「脫離」父層 DOM 結構、直接掛載到根節點或其他特定容器的 UI 元件。傳統做法是透過 document.body.appendChild 手動操作 DOM,既破壞了 Vue 的響應式系統,也讓元件的生命週期變得難以追蹤。

Vue 3 為了解決這類「跨層」渲染需求,提供了 Teleport(官方)與 Portals(第三方插件)兩種機制。透過它們,我們可以在 保持元件結構與資料流完整 的同時,將渲染結果「傳送」到任意 DOM 節點,達到 層級分離、樣式隔離 以及 更佳的可維護性

本文將從概念說明、實作範例、常見陷阱與最佳實踐,最後列舉實務應用場景,帶你完整掌握跨層元件的開發技巧。


核心概念

1. Teleport 基礎

<teleport> 是 Vue 3 內建的組件,語法如下:

<teleport to="#target">
  <!-- 這裡的內容會被渲染到 #target 所指向的元素 -->
</teleport>
  • to:接受 CSS selector、DOM 元素或是返回元素的函式。若目標不存在,內容會暫時保留在原位置,等目標出現後再搬移。
  • disabled(可選):設定為 true 時,暫停搬移行為,內容仍停留在原位,適合在 SSR 或測試環境使用。

重點:Teleport 只搬移渲染結果(VNode),不會改變元件的生命週期響應式資料,因此在 Teleport 內部仍可使用 setup()propsemit 等功能。

2. Portals(第三方)

在 Vue 2 時代,社群提供了 portal-vue 套件作為跨層渲染的解決方案。Vue 3 中仍可使用 vue3-portalportal-vue@next,語法與 Teleport 類似:

<portal to="modal-root">
  <MyModal />
</portal>

<!-- 在根組件中定義目標容器 -->
<portal-target name="modal-root" />
  • to / name:指定目標容器的名稱或 ID。
  • PortalTarget:相當於 Teleport 的「接收端」,可在任意位置放置,讓多個 Portal 共用同一個目標。

何時選擇 Portals?

  • 需要 多層嵌套的目標(例如同時支援多個不同層級的 modal 堆疊)。
  • 想要 動態切換目標,或在同一個應用中混用 Vue 2 與 Vue 3 元件。

3. 為什麼跨層結構重要?

場景 若不使用 Teleport / Portals 使用後的好處
Modal 需要覆蓋全畫面 必須把 Modal 放在根組件,導致父層傳遞大量 props 解耦:Modal 自己管理狀態,父層只負責開關
Tooltip 必須在 body 中避免 overflow 隱藏 必須調整 CSS z-indexposition,且仍受父層 overflow 影響 層級隔離:直接掛在 body,不受父層限制
動態表單彈窗需要在不同頁面共用 重複寫相同的 DOM 結構或使用全局事件總線 重用性:同一個 Portal/Teleport 元件可在多處使用

程式碼範例

以下示範 5 個常見且實用的跨層情境,均以 Vue 3 + Teleport 為主,必要時補充 Portals 的寫法。

1️⃣ 基本 Modal

<!-- App.vue -->
<template>
  <button @click="show = true">開啟 Modal</button>

  <teleport to="body">
    <div v-if="show" class="modal-backdrop" @click.self="show = false">
      <div class="modal-content">
        <h3>這是 Modal</h3>
        <p>內容放在 body 中,不會被父層 overflow 裁切。</p>
        <button @click="show = false">關閉</button>
      </div>
    </div>
  </teleport>
</template>

<script setup>
import { ref } from 'vue'
const show = ref(false)
</script>

<style scoped>
.modal-backdrop {
  position: fixed; inset: 0; background: rgba(0,0,0,0.5);
  display: flex; align-items: center; justify-content: center;
}
.modal-content { background: #fff; padding: 1.5rem; border-radius: 8px; }
</style>

說明@click.self 只在點擊遮罩層時關閉,避免點到內容時也觸發。整個 Modal 完全脫離了 App.vue 的 DOM 結構,卻仍能透過 show 控制顯隱。

2️⃣ 多層 Portals(堆疊 Modal)

<!-- ModalStack.vue -->
<template>
  <teleport to="#modal-root">
    <transition-group name="fade" tag="div">
      <div v-for="(item, i) in stack" :key="item.id" class="modal">
        <h4>第 {{ i + 1 }} 個 Modal</h4>
        <button @click="pop">關閉最上層</button>
        <button @click="push">再開一層</button>
      </div>
    </transition-group>
  </teleport>
</template>

<script setup>
import { ref } from 'vue'
const stack = ref([{ id: 1 }])

function push() {
  const id = stack.value.length + 1
  stack.value.push({ id })
}
function pop() {
  if (stack.value.length) stack.value.pop()
}
</script>

<style scoped>
.modal { background: #fff; margin: 1rem auto; padding: 1rem; width: 300px; }
.fade-enter-active, .fade-leave-active { transition: opacity .3s; }
.fade-enter-from, .fade-leave-to { opacity: 0; }
</style>

<!-- 在 index.html 中加入目標容器 -->
<body>
  <div id="app"></div>
  <div id="modal-root"></div>
</body>

說明:透過 transition-group 搭配 Teleport,可在同一目標容器中實現 多層堆疊,且每層都有獨立的生命週期。

3️⃣ Tooltip 躲避父層 Overflow

<!-- Tooltip.vue -->
<template>
  <span @mouseenter="open" @mouseleave="close" class="tooltip-trigger">
    <slot />
    <teleport to="body">
      <div v-if="visible" class="tooltip" :style="posStyle">{{ text }}</div>
    </teleport>
  </span>
</template>

<script setup>
import { ref, onMounted, onBeforeUnmount } from 'vue'
const props = defineProps({ text: String })
const visible = ref(false)
const posStyle = ref({})

function updatePos(e) {
  const { clientX, clientY } = e
  posStyle.value = {
    left: `${clientX + 8}px`,
    top: `${clientY + 8}px`,
  }
}
function open(e) {
  visible.value = true
  updatePos(e)
  window.addEventListener('mousemove', updatePos)
}
function close() {
  visible.value = false
  window.removeEventListener('mousemove', updatePos)
}
onBeforeUnmount(() => window.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;
}
.tooltip-trigger { cursor: help; }
</style>

說明:把 Tooltip 移到 body 後,即使外層容器有 overflow: hidden,Tooltip 仍能完整顯示。position: fixed 確保不受父層定位影響。

4️⃣ 使用 Portals 建立全局通知區

<!-- Notification.vue -->
<template>
  <portal to="notify-root">
    <transition-group name="slide" tag="div" class="notify-list">
      <div v-for="msg in messages" :key="msg.id" class="notify-item">
        {{ msg.text }}
        <button @click="remove(msg.id)">✕</button>
      </div>
    </transition-group>
  </portal>
</template>

<script setup>
import { ref } from 'vue'
const messages = ref([])

function push(msg) {
  const id = Date.now()
  messages.value.push({ id, text: msg })
  setTimeout(() => remove(id), 3000)
}
function remove(id) {
  messages.value = messages.value.filter(m => m.id !== id)
}
</script>

<style scoped>
.notify-list { position: fixed; top: 1rem; right: 1rem; width: 260px; }
.notify-item { background: #444; color: #fff; margin-bottom: .5rem; padding: .6rem; border-radius: 4px; display: flex; justify-content: space-between; }
.slide-enter-active, .slide-leave-active { transition: all .3s; }
.slide-enter-from { opacity: 0; transform: translateX(100%); }
.slide-leave-to   { opacity: 0; transform: translateX(100%); }
</style>

<!-- 在主入口 index.html 加入目標 -->
<body>
  <div id="app"></div>
  <div id="notify-root"></div>
</body>

說明portal-vue<portal><portal-target>全局通知 可以在任何子組件內呼叫 push(),而不必把通知元件往上層傳遞。

5️⃣ 動態切換 Teleport 目標(多主題)

<!-- ThemeSwitcher.vue -->
<template>
  <div>
    <select v-model="targetId">
      <option value="#theme-a">主題 A</option>
      <option value="#theme-b">主題 B</option>
    </select>

    <teleport :to="targetId">
      <div class="banner">這段文字會依選擇的主題掛載</div>
    </teleport>
  </div>
</template>

<script setup>
import { ref } from 'vue'
const targetId = ref('#theme-a')
</script>

<style scoped>
.banner { padding: .8rem; background: #ffeb3b; text-align: center; }
</style>

<!-- index.html 中放兩個容器 -->
<body>
  <div id="app"></div>
  <div id="theme-a"></div>
  <div id="theme-b"></div>
</body>

說明to 可以是 響應式 的,讓同一段內容在不同 UI 區塊之間自由搬移,適合 多主題切換側邊欄/主內容切換


常見陷阱與最佳實踐

陷阱 為什麼會發生 解決方式
目標容器未即時掛載 Teleport 會在目標不存在時保留內容,導致 UI 暫時不顯示或位置錯位。 在根組件的 mounted 中先確保目標容器已加入 DOM,或使用 v-if 包裹 Teleport,等目標出現後再渲染。
CSS 作用域衝突 Teleport 內的樣式仍受父層 scoped 限制,可能找不到目標容器的樣式。 使用 全局樣式<style> 不加 scoped)或 CSS Modules,或在 Teleport 內部自行定義必要樣式。
事件冒泡失效 Teleport 跳脫 DOM 階層,@click.stop@keyup.enter 等修飾符仍有效,但若在目標容器外部監聽全局事件,可能找不到來源。 使用 Vue 的事件系統emit)或 provide/inject 代替直接的 DOM 事件傳遞。
SSR (Server‑Side Rendering) 不支援 在伺服器端渲染時,document 尚未存在,teleport 會直接渲染在原位。 為 SSR 設定 disabled 屬性或在 setup 判斷 import.meta.env.SSR 後調整行為。
記憶體洩漏 動態建立大量 Teleport/Portal,未在 onBeforeUnmount 清除事件監聽或全局狀態。 在組件銷毀前 移除所有全域監聽,或使用 watchEffect 自動清理。

最佳實踐清單

  1. 目標容器預先放置:在 index.html 或根組件中先宣告 <div id="modal-root"></div><div id="tooltip-root"></div>,避免渲染時的閃爍。
  2. 使用 v-show 而非 v-if(視需求而定):若頻繁開關 Modal,v-show 能保留渲染結果,提升效能。
  3. 統一樣式管理:將 Teleport/Portal 相關樣式抽離成全局 CSS(例如 src/assets/teleport.css),確保不同頁面共用。
  4. provide / inject 傳遞資料:跨層元件仍可使用依賴注入,避免過深的 prop drilling。
  5. 保持單一職責:讓 Teleport 僅負責「渲染位置」的切換,業務邏輯仍寫在原始元件內,提升可測試性。

實際應用場景

1. 全域 Modal 管理

在企業後台系統中,常需要同時開啟 檔案上傳、表單編輯、確認視窗。使用 Teleport 把所有 Modal 統一掛在 #modal-root,再透過 Vuex 或 Pinia 管理開關狀態,能讓任何子頁面只要 dispatch('modal/open', payload) 即可彈出對應視窗,不必在路由層級傳遞大量的 props

2. 彈性 Tooltip / Popover

電商網站的商品卡片若放在有 overflow: hidden 的容器內,普通的 Tooltip 會被裁切。將 Tooltip 移到 body(或專屬 #tooltip-root),再根據滑鼠座標計算位置,就能在任何卡片上正確顯示,提升使用者體驗

3. 多主題或多語系切換

在支援深色/淺色模式的應用,常會把 通知條全局提示 放在不同的主題容器中。透過 動態 Teleport 目標,同一段程式碼即可根據主題切換掛載位置,減少重複 UI 程式碼。

4. 嵌入式微前端平台

大型企業可能採用微前端,每個子應用都有自己的根節點。使用 Portals 可以把 全局 Loading錯誤提示 從子應用「搬」到主應用的容器,維持一致的 UI 標準,同時不影響子應用的獨立部署。

5. SSR 與 CSR 混合渲染

在 Nuxt 3 或 VitePress 中,首屏渲染需要在服務端產生完整 HTML。若 Teleport 目標在客戶端才會生成,可在 setup 中根據 process.server 判斷是否禁用 Teleport,確保 首屏不會出現空白,而在客戶端掛載後再啟用。


總結

  • Teleport 是 Vue 3 原生、輕量且與響應式系統完美結合的跨層渲染工具,適合大多數「Modal、Tooltip、Dropdown」等 UI 場景。
  • Portals(第三方)提供更彈性的目標管理與多層堆疊功能,適合需要 多目標微前端 的複雜專案。
  • 正確使用 目標容器預置、全局樣式、provide/inject,可以避免常見的渲染閃爍、樣式衝突與記憶體洩漏。
  • 在實務開發中,將 UI 的「位置」與「行為」分離,能讓團隊更容易 維護、測試與擴充,同時提升使用者的互動體驗。

掌握了 Teleport 與 Portals 後,你就能在 Vue 3 中自由地 跨層組織元件結構,寫出乾淨、可維護且具彈性的前端程式碼。祝開發順利,玩得開心!