Vue 3.4.x 刚升完级,PM 就飘过来一句话:“加个全局通知,要在任何页面都能弹出来。” 我翻了翻旧代码——十个页面里八个弹窗嵌在局部组件里,遮罩叠遮罩,按钮点都点不动。表面是加个弹窗,实际是跟 z-index 和组件层级较劲,这仗没法绕。

打开旧代码那一刻,头皮发麻

最常见的问题是,两个弹窗同时出现时,后打开的经常被之前的 mask 遮住。为了“解决”,大家不约而同往上加 999、1001、1003……很快这个数字就彻底失控。更糟的是,一旦某个第三方库也插一脚,整条堆叠上下文链就可能崩盘。

<div class="modal" style="z-index: 2000">
  <!-- 仍然可能被父级 fixed 背景挡住 -->
</div>

没有集中管理之前,每个业务页都得独立 import 弹窗、绑定 visible、监听 confirm。复制粘贴几次后,连关闭回调都散落在不同文件,新人改逻辑根本找不到入口。深层表单想删数据前询问,只能 this.$emit('show-confirm') 一路透传到顶层,中间人组件纯粹做传话。多一层就多一份维护成本,还容易漏掉 cleanup。

Vue Teleport component moving DOM to body

Teleport:让 DOM 说走就走

Vue 3 的 Teleport 不是什么黑魔法。它的核心就一句话:让你能把子组件里的某段模板,「传送」到 DOM 树的任意位置去渲染。顺带一提,Vue 官方文档里管它叫 Teleport,不是 Teleportation,别拼错。

<template>
  <Teleport to="body">
    <div class="global-modal">
      <h3>确定要删除这条记录?</h3>
      <button @click="confirm">确认</button>
    </div>
  </Teleport>
</template>

to="body" 告诉 Vue:你这堆 DOM 节点别挂在本组件的位置,直接挪到 <body> 下面去。你在 Chrome DevTools 里看,弹窗的父节点确实变成了 <body>,而不是哪个被 卡死的父容器。但问题来了——DOM 位置变了,组件上下文还在吗?

这是很多人第一次用 Teleport 时的疑虑。答案是在。Teleport 只动 DOM 结构,不动组件树的逻辑层级。你的 propsemitprovide/inject,甚至 slots,全部照常传递。弹窗里 this.$parent 指向的依然是声明它的父组件,而不是 body。说白了,它就是在渲染阶段做了一次 DOM 节点的「物理搬迁」,但组件实例之间的父子关系纹丝不动。这也意味着,你完全可以在某个深层嵌套的表格单元格里触发一个弹窗,而它的 DOM 最终渲染在 body 下,z-index 只需要全局统一定一个层级,不用再跟父级的 transform 或者 filter 打架。

实战里我最常用的是 to="#app" 或者 to=".global-container",而不是直接 body。因为某些老版本的 Element UI 会在 body 上额外插东西,两个系统抢同一个 z-index 又得吵架。指定一个你控制得住的容器,比丢到 body 更稳。

动态组件:别让弹窗内容绑架你的模板

Teleport 解决了层级打架的问题,但很快会遇到另一个麻烦:弹窗内容本身会变。确认删除、填写表单、展示图片预览——如果每个都写一套 <Modal> 包裹逻辑,过两个月你自己都找不到哪个文件里塞了哪种弹窗。

把 v-if 拆开之后反而更清爽

<component :is="currentComponent"> 就是干这个的。它允许你在运行时决定渲染哪个组件,而不需要把所有类型的弹窗都堆在同一个 template 里做条件判断。想象一下,你有一个全局触发器叫 showModal('confirmDelete', payload),参数里直接告诉系统这次该显示什么。

const currentComponent = ref(null);

function showModal(type, payload) {
  // payload 可以是配置对象,也可以是直接传参
  const registry = getRegistry(); // 后面会讲怎么建这个表
  if (registry.has(type)) {
    currentComponent.value = registry.get(type);
    // 同时可以把 payload 存进一个响应式对象供弹窗读取
    modalPayload.value = payload;
  }
}

以前你得写 <ConfirmDeleteModal v-if="type === 'delete'" /> 然后下一行 <ImagePreviewModal v-if="type === 'image'" />,现在只需要一行 <component :is="currentComponent" />。维护起来直观得多,新增弹窗也不用动旧代码。

给每种弹窗发一张「身份证」

组件注册表听起来高大上,其实就是个 Map。键是语义化的名字(比如 'confirmDelete'),值要么是组件本身,要么是返回 Promise 的懒加载函数。这样做有两个好处:第一,集中管理命名空间,避免起名冲突;第二,你可以在打开弹窗前做权限校验或者埋点统计。

  • Map.prototype.set() 用来注册新类型
  • Map.prototype.get() 用来按需取用
  • Map.prototype.has() 可以在调用前做防御性判断

举个实际点的例子,假设你正在做一个后台管理系统,CRUD 表格里经常要弹出确认框。与其在 20 个页面里各自引用 <BaseModal> 并传入不同的 slot,不如定义一种通用协议:

modalRegistry.set('confirm', {
  component: defineAsyncComponent(() => import('./ConfirmDialog.vue')),
  props: { title: '确认操作', cancelText: '取消' },
});

当你在某个深山老林般的业务组件里写下 this.$bus.emit('openModal', { type: 'confirm', content: '确定要删除这条吗?' }) 时,根节点上的监听器拿到类型就能自动匹配对应的组件,连 prop 都能预设好。

让首屏别为还没用到的弹窗买单

很多团队喜欢一次性把所有可能用到的弹窗都打进主包,结果首屏加载多了几十 KB。其实大部分用户根本不会点那个「高级设置」按钮,为什么要让他们多等 200ms? 配合注册表刚好解决这个问题。只有在真正需要显示的时候,才会去请求对应的 chunk。注意别滥用——如果你的应用确实会在初始化阶段就大概率用到某个弹窗(比如登录失败提示),那就老老实实走静态导入。懒加载是有成本的,首次交互延迟会增加几百毫秒,这对用户体验来说很敏感。权衡的标准很简单:用户有多大概率在前三分钟内用到它?低于 50% 就值得拆出去。

global modal manager with promise API

队列管理:把弹窗当数据,而不是当组件

前面我们把组件注册表搭好了,类型和预设 prop 都能按需取用。但还缺个核心问题:这些弹窗的状态存放在哪?组件之间怎么通信?总不能每个页面都手动 import 一个 ModalContainer 再挂到 template 里吧——那就回到老路子上了。说实话,我见过不少项目直接在根组件里放一个 showModal 布尔值,然后用 v-if 控制显隐。单个弹窗还行,弹窗一多就崩:你得维护七八个布尔值,还得手动管理它们的互斥关系。更糟的是,如果弹窗 A 正在展示,用户操作后要立即弹出弹窗 B,这种顺序控制靠布尔值根本没法写。

用 reactive 数组管理弹窗队列

换个思路:把弹窗当成一个个任务。每个弹窗其实就是一个对象,包含它自己的身份信息。我在项目里通常这样定义:

interface ModalItem {
  id: string
  type: string
  props?: Record<string, any>
  resolve?: (value: any) => void
  reject?: (reason?: any) => void
  priority?: number
  zIndex?: number
}

const modalQueue = reactive<ModalItem[]>([])

idnanoid 或者自增计数器生成,保证唯一;type 对应注册表里的键名,props 是每次调用时传入的动态参数。最关键的是 resolvereject——有了它们,我们才能实现 Promise 化的调用方式。队列本身就是一个数组,push 新弹窗就追加到末尾,shiftsplice 移除已关闭的。如果你想支持堆叠(比如同时显示两个互不阻挡的通知),就维护一个 zIndex 计数器,每次新弹窗的 zIndex 等于当前最大值加 1。

通信链路:provide/inject 还是事件总线

两种方案我都试过。provide/inject 的好处是类型安全,适合组件树结构明确的项目。你在 App.vue 里 provide('modalQueue', modalQueue),子组件通过 inject 拿到队列引用后直接 push 新任务。但缺点也很明显:你必须保证注入的键名不冲突,而且非父子关系的组件调用起来不太直观。事件总线(用 mitt 或者 Vue 3 自带的 emitter)更灵活,但也更容易失控。我的做法是只暴露一个全局函数 showModal,内部封装了对事件总线的调用:

import { emitter } from './emitter'
import { generateId } from './utils'

export function showModal(type: string, props?: Record<string, any>): Promise<any> {
  return new Promise((resolve, reject) => {
    const id = generateId()
    emitter.emit('open-modal', { id, type, props, resolve, reject })
  })
}

这样业务组件只需要 import { showModal } from '@/composables/useModal',然后 const result = await showModal('confirm', { title: '确定删除?' })。调用方不用关心队列怎么管理,也不用知道事件总线是否存在。干净利落。至于监听端,在根组件(或者一个专门的 组件)里用 onMounted 注册监听:

onMounted(() => {
  emitter.on('open-modal', (item: ModalItem) => {
    modalQueue.push(item)
  })
})

队列变化后,Teleport 包裹的渲染层自动响应,把队列里的弹窗依次渲染到 body 下。

Promise 化的真实收益

很多人觉得 Promise 化只是语法糖,但实际用起来差别很大。举个例子,删除操作需要先确认,确认成功后可能还要再弹一个 loading 提示。用传统回调写法:

showConfirm('确定删除?', (confirmed) => {
  if (confirmed) {
    showLoading()
    deleteUser(id).then(() => {
      hideLoading()
      showSuccess('删除成功')
    })
  }
})

回调嵌套还不算深,但一旦加上超时重试、错误恢复这些逻辑,直接裂开。改成 Promise 之后:

const confirmed = await showModal('confirm', { title: '确定删除?' })
if (!confirmed) return

showModal('loading')
try {
  await deleteUser(id)
  showModal('toast', { type: 'success', message: '删除成功' })
} catch {
  showModal('toast', { type: 'error', message: '删除失败' })
} finally {
  closeModal('loading')
}

看到没?每一行都是同步的写法,但实际是异步流程。await 让代码从上往下读,脑子不用来回切换上下文。而且 Promise 的 resolve 可以携带任意返回值——确认框传布尔值,输入框传字符串,选择器传对象。调用方拿到的就是最终结果,不需要再通过事件或回调去取。

通知系统:轻量版队列,别让遮罩挡住

前面我们把模态框当“数据”放进队列,Promise 化之后流程顺了。但页面里还有一类更轻、更快、更碎的东西——通知。它不像弹窗那样需要用户停下手头的事,却要求立刻被看见,而且千万别被遮罩盖住。

先把容器挪到最外层

写一个全局通知列表,最容易踩的坑就是 CSS 层级。父元素来个 transform 或者 filter,z-index 再高也可能被锁死在里面。Vue 3 的 Teleport 就是为了这种场景准备的:你只管在组件里声明内容,渲染时它会“瞬移”到你指定的地方。我们在 body 下面塞一个固定的容器,所有通知都往那儿丢。

<Teleport to="body">
  <div class="notification-container"></div>
</Teleport>

容器本身不复杂,,右上定位,,确保点击能命中,同时不会挡住页面滚动。

四种类型,一种接口

通知只有四个字面:success、error、warning、info。设计上我们约定同一套调用签名:addNotification({ type, message, duration? })。duration 默认 3 秒,足够让大多数人读完,又不至于一直占着屏幕。内部实现也很直接:维护一个响应式数组 queue,每次添加新项就 push 进去,并在 setTimeout 后 pop。图标和颜色不用硬编码成四个分支。把类型映射到一个图标字典,再把类型名交给 CSS 变量,既能统一视觉节奏,又能在后期换肤时少改几处。

用动态组件把逻辑拉平

不同类型之间差异很小:图标、背景色、边框 accent。把它们拆成独立的小组件太啰嗦,反而增加维护成本。这里用 component :is="..." 做动态渲染:传入一个注册好的子组件对象,Props 完全由上层控制。这样以后想加一个“loading”或者“neutral”,也只是在映射表里多一行,顺手加上 enter-active 和 leave-active,位移用 transform,别碰 height,否则容易抖动。很多人第一次做列表动画会忘记给每个条目稳定的 key,结果 Vue 复用节点导致状态粘滞,这点务必留心。

别忘了手动关闭和点击回调

自动消失是便利,但不是全部场景。用户可能想把错误日志复制走,或者在网络恢复后重试操作。因此每个通知都要暴露一个显式的关闭按钮,并且允许通过 onClick 回调回到业务逻辑。对外暴露的 API 保持简单:const notice = addNotification({ type, message, duration, onClick }); notice.close()。函数返回的对象包含关闭方法,既方便调试,也能让外部精确控制生命周期。

塞进真实项目:从 App.vue 开始改

核心组件和队列逻辑都拆清楚了,但零件堆在那不组装,等于零。直接拿一个真实项目里的“删除确认”场景开刀,把它塞进去跑一遍。你会发现,Teleport 把弹窗扔到 body 顶层,动态组件按需加载确认框内容,这东西搭起来比你想象的顺手。

先改造 App.vue。别想复杂,就两件事:放容器、挂全局方法。

<template>
  <div id="app">
    <router-view />
    <!-- 弹窗容器,Teleport 到 body 尾部 -->
    <Teleport to="body">
      <ModalContainer ref="modalRef" />
    </Teleport>
    <!-- 通知容器同理 -->
    <Teleport to="body">
      <NotificationContainer ref="notifyRef" />
    </Teleport>
  </div>
</template>

注意这里用了两个独立的 Teleport,别图省事塞进同一个容器里——弹窗和通知的 z-index 层级、动画节奏都不一样,放一起反而容易互相干扰。然后注册全局方法。在 setup 里通过 getCurrentInstance().appContext.config.globalProperties 挂载 $modal$notify

import { getCurrentInstance, onMounted } from 'vue'

const instance = getCurrentInstance()
const app = instance.appContext.app

app.config.globalProperties.$modal = {
  confirm: (options) => modalRef.value?.confirm(options),
  alert: (options) => modalRef.value?.alert(options)
}

app.config.globalProperties.$notify = (options) => {
  notifyRef.value?.add(options)
}

之所以用 ref 拿子组件实例,是因为容器内部封装了队列管理和状态。直接用 也能行,但全局方法对老项目更友好——任何 this.$modal.confirm() 就能调,不用改业务组件的模板结构。

看看实际场景吧——用户鼠标点下那个删除按钮,事情就开始变得有意思了。

const handleDelete = async (id) => {
  const confirmed = await this.$modal.confirm({
    title: '确认删除',
    content: '这条记录删除后不可恢复,确定继续吗?',
    confirmText: '狠心删除',
    cancelText: '我再想想'
  })
  if (!confirmed) return

  try {
    await deleteApi(id)
    this.$notify({
      type: 'success',
      message: '删除成功,数据已从数据库移除',
      duration: 4000
    })
  } catch (err) {
    this.$notify({
      type: 'error',
      message: `删除失败:${err.message}`,
      duration: 0  // 不自动关闭,用户手动点掉
    })
  }
}

注意弹窗用了 async/await 风格——confirm 内部返回一个 Promise,点确认 resolve true,点取消 resolve false。这种写法比回调嵌套清晰太多,而且跟业务逻辑的 try/catch 完全对齐。对比传统方案:以前你可能在每个页面手动写一个 v-if 弹窗,还要维护 showConfirm 这类布尔变量,再手动调 createApp 插到 body。现在呢?一个全局方法调用,层级问题由 Teleport 兜底,动态组件由 解决。代码量至少砍一半,而且你再也不用担心某个弹窗被父组件的 裁掉。