Vue 3 Teleport 实战:弹窗管理的正确姿态
Vue 3 Teleport 实战:弹窗管理的正确姿态
弹窗、对话框、通知 toast,这些组件有个共同特点:它们在组件树里的位置和它们表现的位置不一样。弹窗明明是 App.vue 的子组件,但它渲染出来的 DOM 要在 <body> 最外层,否则会被父容器的 overflow: hidden 或 z-index 影响。
Vue 2 时代处理这个问题的方案很多:用 portal-vue 插件、把弹窗组件放 App.vue 顶层、手动操作 DOM。Vue 3 的 Teleport 终于从框架层面原生支持了这个需求。
但 Teleport 用不对,也会出问题。我总结一下实际项目里踩过的坑。
Teleport 基础用法
<template>
<button @click="showModal = true">打开弹窗</button>
<Teleport to="body">
<div v-if="showModal" class="modal-overlay">
<div class="modal-content">
<h3>弹窗标题</h3>
<p>弹窗内容</p>
<button @click="showModal = false">关闭</button>
</div>
</div>
</Teleport>
</template>
<script setup>
import { ref } from 'vue'
const showModal = ref(false)
</script>
<Teleport to="body"> 会把里面的内容渲染到 <body> 的最末尾(而不是组件的当前位置)。
弹窗组件的常见问题
问题一:Teleport 和 CSS 样式冲突
有时弹窗渲染到 body 后,被页面的全局样式影响:
/* 页面里可能有 */
body {
overflow: hidden; /* 或者其他影响弹窗的样式 */
}
解决
<Teleport to="body">
<div class="modal-overlay" style="position: fixed; z-index: 9999;">
<!-- 弹窗内容 -->
</div>
</Teleport>
或者用 z-index 足够高的数值确保在最上层。
问题二:页面有多个 Teleport,顺序错乱
当多个组件都用 <Teleport to="body"> 时,它们的渲染顺序和组件树顺序不一定一致。
解决
用 Teleport 的 disabled 属性来控制渲染时机,或者用 v-if 确保顺序:
<!-- 父组件 -->
<Teleport to="body">
<ConfirmDialog v-if="showConfirm" />
<AlertDialog v-if="showAlert" />
</Teleport>
或者用 key 控制渲染顺序:
<Teleport to="body">
<div key="confirm" v-if="showConfirm">确认弹窗</div>
<div key="alert" v-if="showAlert">警告弹窗</div>
</Teleport>
封装一个通用的弹窗 composable
实际项目里不会每个弹窗都手写一遍。我推荐封装一个 useModal composable:
// composables/useModal.ts
import { ref, onMounted, onUnmounted } from 'vue'
interface ModalOptions {
onClose?: () => void
closeOnOverlay?: boolean // 点击遮罩是否关闭
closeOnEsc?: boolean // 按 ESC 是否关闭
}
export function useModal(options: ModalOptions = {}) {
const isOpen = ref(false)
const {
closeOnOverlay = true,
closeOnEsc = true,
onClose,
} = options
function open() {
isOpen.value = true
document.body.style.overflow = 'hidden' // 防止背景滚动
}
function close() {
isOpen.value = false
document.body.style.overflow = ''
onClose?.()
}
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Escape' && isOpen.value && closeOnEsc) {
close()
}
}
function handleOverlayClick(e: MouseEvent) {
if (e.target === e.currentTarget && closeOnOverlay) {
close()
}
}
onMounted(() => {
document.addEventListener('keydown', handleKeydown)
})
onUnmounted(() => {
document.removeEventListener('keydown', handleKeydown)
document.body.style.overflow = ''
})
return {
isOpen,
open,
close,
handleOverlayClick,
}
}
使用方式:
<script setup>
import { useModal } from '@/composables/useModal'
import ModalContent from '@/components/ModalContent.vue'
const { isOpen, open, close, handleOverlayClick } = useModal({
closeOnOverlay: true,
onClose: () => console.log('modal closed'),
})
</script>
<template>
<button @click="open">打开弹窗</button>
<Teleport to="body">
<div
v-if="isOpen"
class="modal-overlay"
@click="handleOverlayClick"
>
<div class="modal-content">
<ModalContent @close="close" />
</div>
</div>
</Teleport>
</template>
封装一个可复用的 Modal 组件
进一步封装:
<!-- components/BaseModal.vue -->
<script setup lang="ts">
import { watch } from 'vue'
const props = defineProps<{
modelValue: boolean
title?: string
width?: string
closeOnOverlay?: boolean
}>()
const emit = defineEmits<{
'update:modelValue': [value: boolean]
}>()
function close() {
emit('update:modelValue', false)
}
function handleOverlayClick(e: MouseEvent) {
if (props.closeOnOverlay !== false && e.target === e.currentTarget) {
close()
}
}
// 监听打开/关闭,控制 body 滚动
watch(() => props.modelValue, (open) => {
document.body.style.overflow = open ? 'hidden' : ''
})
</script>
<template>
<Teleport to="body">
<Transition name="modal">
<div
v-if="modelValue"
class="modal-overlay"
@click="handleOverlayClick"
>
<div class="modal-container" :style="{ width: width || '500px' }">
<header class="modal-header">
<h3>{{ title }}</h3>
<button class="close-btn" @click="close">×</button>
</header>
<div class="modal-body">
<slot />
</div>
<footer class="modal-footer">
<slot name="footer" />
</footer>
</div>
</div>
</Transition>
</Teleport>
</template>
<style scoped>
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 9999;
}
.modal-container {
background: white;
border-radius: 8px;
max-height: 90vh;
overflow: auto;
}
.modal-enter-active,
.modal-leave-active {
transition: opacity 0.2s ease;
}
.modal-enter-from,
.modal-leave-to {
opacity: 0;
}
</style>
使用方式:
<script setup>
import { ref } from 'vue'
import BaseModal from '@/components/BaseModal.vue'
const showModal = ref(false)
</script>
<template>
<button @click="showModal = true">打开弹窗</button>
<BaseModal v-model="showModal" title="操作确认" width="400px">
<p>确定要删除这条数据吗?</p>
<template #footer>
<button @click="showModal = false">取消</button>
<button @click="handleConfirm">确定</button>
</template>
</BaseModal>
</template>
Toast 通知的实现
Teleport 另一个常用场景是 Toast 通知。Toast 和弹窗不同,它通常是多个并存的,而且有自动消失的逻辑。
// composables/useToast.ts
import { ref } from 'vue'
interface Toast {
id: number
message: string
type: 'success' | 'error' | 'warning' | 'info'
duration: number
}
const toasts = ref<Toast[]>([])
let toastId = 0
export function useToast() {
function show(message: string, type: Toast['type'] = 'info', duration = 3000) {
const id = ++toastId
toasts.value.push({ id, message, type, duration })
if (duration > 0) {
setTimeout(() => {
remove(id)
}, duration)
}
return id
}
function remove(id: number) {
const index = toasts.value.findIndex(t => t.id === id)
if (index > -1) {
toasts.value.splice(index, 1)
}
}
return {
toasts,
success: (msg: string, duration?: number) => show(msg, 'success', duration),
error: (msg: string, duration?: number) => show(msg, 'error', duration),
warning: (msg: string, duration?: number) => show(msg, 'warning', duration),
info: (msg: string, duration?: number) => show(msg, 'info', duration),
remove,
}
}
Toast 组件:
<!-- components/ToastContainer.vue -->
<script setup lang="ts">
import { useToast } from '@/composables/useToast'
const { toasts, remove } = useToast()
</script>
<template>
<Teleport to="body">
<div class="toast-container">
<TransitionGroup name="toast">
<div
v-for="toast in toasts"
:key="toast.id"
:class="['toast', `toast-${toast.type}`]"
@click="remove(toast.id)"
>
{{ toast.message }}
</div>
</TransitionGroup>
</div>
</Teleport>
</template>
<style scoped>
.toast-container {
position: fixed;
top: 20px;
right: 20px;
z-index: 10000;
display: flex;
flex-direction: column;
gap: 8px;
}
.toast {
padding: 12px 20px;
border-radius: 6px;
cursor: pointer;
min-width: 200px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
.toast-success { background: #52c41a; color: white; }
.toast-error { background: #ff4d4f; color: white; }
.toast-warning { background: #faad14; color: white; }
.toast-info { background: #1890ff; color: white; }
.toast-enter-active,
.toast-leave-active {
transition: all 0.3s ease;
}
.toast-enter-from {
opacity: 0;
transform: translateX(100%);
}
.toast-leave-to {
opacity: 0;
transform: translateX(100%);
}
</style>
在 App.vue 里全局注册:
<script setup>
import ToastContainer from '@/components/ToastContainer.vue'
</script>
<template>
<router-view />
<ToastContainer />
</template>
Teleport 与 Vue Router 结合的坑
如果弹窗在路由切换时还开着,会被路由守卫意外关闭。
解决方案:
// 路由守卫里处理
router.beforeEach((to, from) => {
const modal = useModal()
if (modal.isOpen.value) {
modal.close()
}
return true
})
或者在 onBeforeRouteLeave 里处理:
import { onBeforeRouteLeave } from 'vue-router'
const { isOpen, close } = useModal()
onBeforeRouteLeave(() => {
if (isOpen.value) {
close()
return false // 阻止路由切换
}
})
总结
Teleport 是 Vue 3 处理 portal 场景的原生方案,用法简单,但要写好需要处理几个点:
- Teleport 和 CSS 层级:用足够高的 z-index 确保在最上层
- 多个 Teleport 的渲染顺序:用
v-if或key控制 - 弹窗的打开/关闭状态:控制 body 滚动,防止背景穿透
- ESC 和点击遮罩关闭:需要手动绑定键盘事件
- Toast 多实例管理:用单独的状态管理,确保多个 toast 可以并存
- 路由切换时的状态清理:防止弹窗开着时切换路由
把这些边界情况都处理好了,弹窗组件才算真正好用。
