做電商列表的時候,很常有這種需求:每張商品卡片上有一顆「查看詳情」,點下去跳出 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。
position: fixed 平常會以「視窗」當定位參考,這個參考對象在 CSS 規範裡叫做 containing block(包含區塊)。
但規範裡還有一條比較少人注意的規則:只要外層某個父層元素有下面這些屬性,它就會變成裡面 fixed 元素的 containing block:
transform(就算只是 translateY(0) 也算)filter、backdrop-filter
perspective
will-change: transform
contain: paint 等absolute 很像。回到上面的例子:
.card 有了 transform
.card 變成 Modal 的 containing block,inset: 0 只撐滿卡片.card 又有 overflow: hidden,超出的部分被裁掉overflow: hidden,其實是裁不到 fixed 元素的。因為 fixed 的參考點本來在視窗,不在這個父層裡面。真正讓 overflow 生效的,是 transform 先把 fixed 拉回了父層。所以遇到 Modal 被裁切,第一件事是往上找有沒有 transform、filter 這類屬性。就算沒有 transform,也可能遇到 Modal 被 header 蓋住,z-index 調到 9999 都沒用。
原因是 z-index 只會在同一個 stacking context(堆疊環境) 裡比大小。下列情況都會建立新的堆疊環境:
position 不是 static,且有設定 z-index
opacity 小於 1transform、filter 等屬性position: relative; z-index: 1,header 是 z-index: 10。那麼列表裡面的 Modal 就算設 9999,也只是在列表內部排第一,整個列表在外面還是輸給 header。可以想成學校排名:你在班上考第一,但整個班級在全校排倒數,你一樣排不進前十。
把上面兩種狀況放在一起看,原因其實是同一個:Modal 的 DOM 位置太深,被父層的 CSS 影響了。
Bootstrap 的 Modal 文件裡其實也寫過類似的提醒:盡量把 Modal 的 HTML 放在文件的最上層,避免被其他元件影響外觀和行為。
最乾淨的解法,就是讓 Modal 直接住在 <body> 底下,上面沒有任何父層可以干擾它。
方法一:把 Modal 移到 App.vue
DOM 位置是解決了,但 Modal 需要的資料(現在點的是哪個商品)和開關狀態,都要想辦法傳上去。最後可能得用 Pinia 或 provide / inject 繞一圈。
為了一個 CSS 問題,被迫改組件結構,原本屬於卡片的邏輯也被拆到兩個地方。
方法二:自己 appendChild 到 body
手動搬 DOM 會跟 Vue 的渲染機制打架:什麼時候搬、組件卸載時要不要移除、資料更新會不會同步,全部都要自己處理。
兩種方法都不太理想。我們想要的其實是:邏輯照常寫在卡片裡,只有畫面跑到 body 去。
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。
這是 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 接受 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">
例如桌機版要彈窗蓋滿全螢幕,手機版想直接展開在卡片下方:
<Teleport to="body" :disabled="isMobile">
...
</Teleport>
disabled 為 true 時,內容會留在原本的位置渲染,切換時 Vue 會自動把 DOM 搬來搬去。
會依照掛載順序,一個接一個附加在目標的最後面。
<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 的情況下會自然疊在上面,剛好符合「後開的在上層」的直覺。
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 本身一直存在,只是裡面的內容出現或消失,動畫就能正常觸發。
判斷方式很簡單:這個元素的視覺需要「跳出」它所在的版面嗎?
position: fixed 不一定相對視窗,父層有 transform、filter 等屬性時,參考點會變成那個父層z-index 只在同一個堆疊環境裡比較,數字再大也跳不出父層to 的目標要先存在,需要時可以用 defer;disabled 可以依情況決定要不要搬