本文 AI 產出,尚未審核

Vue3 教學:Teleport 與 Portals – 多層 Teleport 嵌套


簡介

在單頁應用程式 (SPA) 中,我們常會遇到需要把 UI 元件渲染到「畫面之外」的情況,例如全域的 ModalTooltip、或是 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 能夠正確觸發 mountedupdated 等鉤子的關鍵。

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 中已經存在;若是動態建立,使用 nextTickonMounted 後再渲染。
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。

最佳實踐小結

  1. 統一管理目標容器:在 index.html 中預留 <div id="modal-root"></div><div id="portal-root"></div>,所有 Teleport 都指向這些固定容器,避免「找不到目標」的錯誤。
  2. 層級命名規則:依照功能(Modal、Tooltip、Toast)使用不同的 z-index 範圍,讓樣式更易維護。
  3. 保持組件自足:讓每個 UI 元件自行決定 Teleport 目標(例如 to="body"),父層不必額外傳遞 to,提升可重用性。
  4. 清理動態元素:使用 onBeforeUnmount 移除在 onMounted 動態創建的容器,防止記憶體泄漏。
  5. 測試多層交互:在單元測試或端到端測試中,確認每層 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 的開發旅程中,玩得開心、寫得順手! 🎉