Vue 3 Teleport 实战:弹窗管理的正确姿态

Vue 3 Teleport 实战:弹窗管理的正确姿态

弹窗、对话框、通知 toast,这些组件有个共同特点:它们在组件树里的位置和它们表现的位置不一样。弹窗明明是 App.vue 的子组件,但它渲染出来的 DOM 要在 <body> 最外层,否则会被父容器的 overflow: hiddenz-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"> 时,它们的渲染顺序和组件树顺序不一定一致。

解决

Teleportdisabled 属性来控制渲染时机,或者用 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">&times;</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 场景的原生方案,用法简单,但要写好需要处理几个点:

  1. Teleport 和 CSS 层级:用足够高的 z-index 确保在最上层
  2. 多个 Teleport 的渲染顺序:用 v-ifkey 控制
  3. 弹窗的打开/关闭状态:控制 body 滚动,防止背景穿透
  4. ESC 和点击遮罩关闭:需要手动绑定键盘事件
  5. Toast 多实例管理:用单独的状态管理,确保多个 toast 可以并存
  6. 路由切换时的状态清理:防止弹窗开着时切换路由

把这些边界情况都处理好了,弹窗组件才算真正好用。

最后更新 4/20/2026, 12:15:46 AM