iT邦幫忙

2026 iThome 鐵人賽

0
Modern Web

重新認識Vue 走過路過不要錯過系列 第 27 篇

Vue 走過路過不要錯過 Day27 - Teleport:讓 Modal 不再被 overflow 卡住

  • 分享至 

  • xImage
  •  

做電商列表的時候,很常有這種需求:每張商品卡片上有一顆「查看詳情」,點下去跳出 Modal 顯示商品說明。

邏輯上 Modal 屬於這張卡片,它要用卡片的資料、開關狀態也只跟這張卡片有關,所以很自然就會把 Modal 寫在 ProductCard.vue 裡面。

然後你會發現一件很奇怪的事:平常打開都好好的,但只要滑鼠停在卡片上再點,Modal 就縮成卡片那麼大,還被圓角裁掉一角。

明明已經寫了 position: fixed,為什麼還是被卡住?

先重現問題

<!-- ProductCard.vue -->
<template>
  <div class="card">
    <h3>{{ product.title }}</h3>
    <button @click="open = true">查看詳情</button>
 
    <div v-if="open" class="modal-mask">
      <div class="modal">
        <p>{{ product.description }}</p>
        <button @click="open = false">關閉</button>
      </div>
    </div>
  </div>
</template>
 
<script setup>
import { ref } from 'vue'
 
defineProps({
  product: Object
})
 
const open = ref(false)
</script>
 
<style scoped>
.card {
  overflow: hidden;
  border-radius: 12px;
  transition: transform 0.2s;
}
 
.card:hover {
  transform: translateY(-4px);
}
 
.modal-mask {
  position: fixed;
  inset: 0;
  display: grid;
  place-items: center;
  background: rgba(0, 0, 0, 0.5);
  z-index: 1000;
}
</style>

卡片 hover 上浮是很常見的效果,問題就出在這行 transform。

fixed 不一定相對視窗

position: fixed 平常會以「視窗」當定位參考,這個參考對象在 CSS 規範裡叫做 containing block(包含區塊)。

但規範裡還有一條比較少人注意的規則:只要外層某個父層元素有下面這些屬性,它就會變成裡面 fixed 元素的 containing block:

  • transform(就算只是 translateY(0) 也算)
  • filter、backdrop-filter
  • perspective
  • will-change: transform
  • contain: paint 等
    一旦發生這件事,fixed 就不再相對視窗,而是相對那個父層定位,行為變得跟 absolute 很像。

回到上面的例子:

  1. 滑鼠移到卡片上,.card 有了 transform
  2. .card 變成 Modal 的 containing block,inset: 0 只撐滿卡片
  3. .card 又有 overflow: hidden,超出的部分被裁掉
    這裡有個細節值得注意:如果只有 overflow: hidden,其實是裁不到 fixed 元素的。因為 fixed 的參考點本來在視窗,不在這個父層裡面。真正讓 overflow 生效的,是 transform 先把 fixed 拉回了父層。所以遇到 Modal 被裁切,第一件事是往上找有沒有 transform、filter 這類屬性。

另一種卡住:z-index 怎麼調都沒用

就算沒有 transform,也可能遇到 Modal 被 header 蓋住,z-index 調到 9999 都沒用。

原因是 z-index 只會在同一個 stacking context(堆疊環境) 裡比大小。下列情況都會建立新的堆疊環境:

  • position 不是 static,且有設定 z-index
  • opacity 小於 1
  • 有 transform、filter 等屬性
    假設卡片列表外層是 position: relative; z-index: 1,header 是 z-index: 10。那麼列表裡面的 Modal 就算設 9999,也只是在列表內部排第一,整個列表在外面還是輸給 header。

可以想成學校排名:你在班上考第一,但整個班級在全校排倒數,你一樣排不進前十。

問題的根源:DOM 住得太深

把上面兩種狀況放在一起看,原因其實是同一個:Modal 的 DOM 位置太深,被父層的 CSS 影響了。

Bootstrap 的 Modal 文件裡其實也寫過類似的提醒:盡量把 Modal 的 HTML 放在文件的最上層,避免被其他元件影響外觀和行為。

最乾淨的解法,就是讓 Modal 直接住在 <body> 底下,上面沒有任何父層可以干擾它。

沒有 Teleport 的時候

方法一:把 Modal 移到 App.vue

DOM 位置是解決了,但 Modal 需要的資料(現在點的是哪個商品)和開關狀態,都要想辦法傳上去。最後可能得用 Pinia 或 provide / inject 繞一圈。

為了一個 CSS 問題,被迫改組件結構,原本屬於卡片的邏輯也被拆到兩個地方。

方法二:自己 appendChild 到 body

手動搬 DOM 會跟 Vue 的渲染機制打架:什麼時候搬、組件卸載時要不要移除、資料更新會不會同步,全部都要自己處理。

兩種方法都不太理想。我們想要的其實是:邏輯照常寫在卡片裡,只有畫面跑到 body 去。

Teleport 的寫法

Vue 3 內建的 <Teleport> 就是在做這件事。改法只要包一層:

<!-- ProductCard.vue -->
<template>
  <div class="card">
    <h3>{{ product.title }}</h3>
    <button @click="open = true">查看詳情</button>
 
    <Teleport to="body">
      <div v-if="open" class="modal-mask" @click.self="open = false">
        <div class="modal">
          <p>{{ product.description }}</p>
          <button @click="open = false">關閉</button>
        </div>
      </div>
    </Teleport>
  </div>
</template>

<script setup> 和樣式都不用改。打開 DevTools 的 Elements 面板,可以看到實際渲染結果:

<body>
  <div id="app">
    <div class="card">
      <h3>安全帽 A</h3>
      <button>查看詳情</button>
      <!--teleport start-->
      <!--teleport end-->
    </div>
  </div>
 
  <div class="modal-mask">
    <div class="modal">...</div>
  </div>
</body>

原本的位置只留下兩行註解當標記,Modal 本體被送到 <body> 最後面。這時候卡片再怎麼 transform、overflow,都影響不到它了。

而 open、product 還是在 ProductCard 裡管理,不用往上傳,也不用另外開 store。

搬走的是 DOM,不是組件關係

這是 Teleport 最容易搞混的地方。它只改變 DOM 渲染的位置,Vue 的組件樹完全沒變。

項目 會不會受影響
props、emit 照常運作,父子關係不變
provide / inject 照常運作
ref、reactive 響應式資料 照常運作
scoped style 照常套用,元素上的 data-v-xxx 屬性還在
Vue Devtools 組件樹 仍顯示在原本的位置
原生 DOM 事件冒泡 會改變,跟著真實 DOM 走

前面幾項都很直覺,最後一項要特別注意。

如果你在卡片外層綁了點擊事件:

<div class="card" @click="trackCardClick">
  ...
  <Teleport to="body">
    <div class="modal-mask">
      <button>加入購物車</button>
    </div>
  </Teleport>
</div>

在沒有 Teleport 的時候,點 Modal 裡的按鈕,事件會一路冒泡到 .card,觸發 trackCardClick。加上 Teleport 之後,按鈕在真實 DOM 裡已經不在 .card 底下了,所以事件不會傳上去。

這是因為 Vue 的事件就是瀏覽器原生事件,冒泡路徑由真實 DOM 決定。寫「點外面關閉」或事件委派這類邏輯時,要記得 Modal 已經不在原本的位置。

順帶一提,React 的 Portal 在這點剛好相反。React 有自己的合成事件系統,Portal 裡的事件會照 React 組件樹冒泡,所以兩邊框架的行為不一樣,切換時要留意。

幾個實用細節

to 可以指定任何目標

to 接受 CSS 選擇器或 DOM 元素:

<Teleport to="body">
<Teleport to="#modals">
<Teleport to=".popup-layer">

很多專案會在 index.html 多放一個專門裝彈窗的容器,跟 #app 同層:

<body>
  <div id="app"></div>
  <div id="modals"></div>
</body>

所有 Modal、Toast、Dropdown 集中在同一個地方,DevTools 查起來也比較清楚。

目標要先存在

Teleport 掛載的時候,to 指向的元素必須已經在畫面上,不然會找不到。

放在 index.html 裡的容器沒有這個問題。但如果目標是同一個 Vue app 裡另一個組件渲染出來的,就可能時機對不上。Vue 3.5 之後可以加上 defer,讓 Teleport 等整個 app 掛載完成後再去找目標:

<Teleport defer to="#modal-container">

disabled:有時候不想搬

例如桌機版要彈窗蓋滿全螢幕,手機版想直接展開在卡片下方:

<Teleport to="body" :disabled="isMobile">
  ...
</Teleport>

disabled 為 true 時,內容會留在原本的位置渲染,切換時 Vue 會自動把 DOM 搬來搬去。

多個 Teleport 指向同一個目標

會依照掛載順序,一個接一個附加在目標的最後面。

<Teleport to="#modals">
  <div>A</div>
</Teleport>
 
<Teleport to="#modals">
  <div>B</div>
</Teleport>
<div id="modals">
  <div>A</div>
  <div>B</div>
</div>

後開的彈窗排在 DOM 後面,在相同 z-index 的情況下會自然疊在上面,剛好符合「後開的在上層」的直覺。

搭配 Transition 做開關動畫

v-if 放在 Teleport 裡面,外面包 <Transition>:

<Teleport to="body">
  <Transition name="fade">
    <div v-if="open" class="modal-mask">
      ...
    </div>
  </Transition>
</Teleport>
.fade-enter-active,
.fade-leave-active {
  transition: opacity 0.2s;
}
 
.fade-enter-from,
.fade-leave-to {
  opacity: 0;
}

Teleport 本身一直存在,只是裡面的內容出現或消失,動畫就能正常觸發。

什麼時候該用 Teleport

判斷方式很簡單:這個元素的視覺需要「跳出」它所在的版面嗎?

  • Modal、Dialog:要蓋滿整個畫面
  • Toast、通知:固定在視窗角落
  • Dropdown、Tooltip:可能會超出父層的捲動區域
  • 全螢幕圖片預覽
    這些都適合用 Teleport。反過來說,如果元素本來就應該乖乖待在父層的框框裡,就不需要搬。

小結

  • position: fixed 不一定相對視窗,父層有 transform、filter 等屬性時,參考點會變成那個父層
  • z-index 只在同一個堆疊環境裡比較,數字再大也跳不出父層
  • 兩個問題的根源都是 DOM 位置太深,被父層的 CSS 影響
  • Teleport 讓邏輯留在原本的組件,只把 DOM 渲染到指定位置
  • props、emit、provide / inject、scoped style 都照常運作,但原生事件冒泡會跟著真實 DOM 走
  • to 的目標要先存在,需要時可以用 defer;disabled 可以依情況決定要不要搬
    CSS 的問題,不一定要靠改組件結構來解決。Teleport 把「邏輯放哪裡」跟「畫面出現在哪裡」拆開,各自放在最合理的位置。

上一篇
Vue 走過路過不要錯過 Day26 - Pinia 實務:storeToRefs 與跨組件共享
系列文
重新認識Vue 走過路過不要錯過 共 27 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言