Vue 基础体系 · 第 35/70 篇。示例基于 Vue 3、Composition API、TypeScript 与现代 Vite 工具链;版本敏感能力会单独标注。

Vue Teleport:层级、事件、SSR、可访问性和弹窗架构

<Teleport> 是 Vue 3 提供的内置组件,用于把一段组件模板渲染到当前组件 DOM 位置之外的目标节点,同时保留原有的 Vue 组件关系。

这句话包含两个容易混淆的“层级”:

  • 组件层级:组件之间仍然按照 Vue 模板中的父子关系组织,propsemitprovide/inject 和生命周期关系不变。
  • DOM 层级:被 Teleport 的节点会出现在 to 指定的 DOM 容器中,不再是当前组件原本的 DOM 子树。

弹窗、下拉菜单、上下文菜单和全局通知经常需要脱离祖先节点的 overflow: hiddentransform 或层叠上下文,因此 Teleport 主要解决的是 渲染位置问题,而不是状态管理、焦点管理或可访问性问题。


一、先建立一个可运行的 Teleport 场景

一个使用 Vite 的 Vue 应用通常可以在入口 HTML 中准备独立的挂载目标:

<!-- index.html -->
<body>
  <div id="app"></div>
  <div id="modal-root"></div>
</body>

然后在组件中使用:

<script setup lang="ts">
import { ref } from 'vue'

const open = ref(false)
</script>

<template>
  <button type="button" @click="open = true">
    打开弹窗
  </button>

  <Teleport to="#modal-root">
    <div v-if="open" class="modal">
      <h2>设置</h2>

      <button type="button" @click="open = false">
        关闭
      </button>
    </div>
  </Teleport>
</template>

opentrue 时,组件逻辑上仍然属于当前页面组件,但 DOM 结构类似于:

<div id="app">
  <button>打开弹窗</button>
</div>

<div id="modal-root">
  <div class="modal">
    <h2>设置</h2>
    <button>关闭</button>
  </div>
</div>

to 可以是 CSS 选择器,也可以是 DOM 元素:

<Teleport to="#modal-root">
  <!-- 内容 -->
</Teleport>
<script setup lang="ts">
import { ref } from 'vue'

const target = ref<HTMLElement | null>(null)
</script>

<template>
  <Teleport v-if="target" :to="target">
    <div>被传送的内容</div>
  </Teleport>
</template>

实际项目中,选择器通常更清晰,因为目标节点由应用壳层统一创建。to 指向的节点必须在 Teleport 尝试挂载时存在,否则会看到类似“无法找到 Teleport 目标”的运行时警告,内容也不会按预期挂载。


二、Teleport 改变了什么:DOM 层级与组件层级分离

2.1 组件关系没有改变

假设组件树是:

App
└── UserPanel
    └── UserDialog

UserDialog 内部使用:

<Teleport to="#modal-root">
  <div class="dialog">...</div>
</Teleport>

组件树仍然是:

App
└── UserPanel
    └── UserDialog

因此以下机制仍然沿用组件树:

  • UserPanel 可以向 UserDialog 传递 props
  • UserDialog 可以通过 emit 通知 UserPanel
  • provide/inject 仍然按照组件祖先关系查找。
  • onMountedonUnmounted 等生命周期仍然属于对应组件实例。
  • Vue 的响应式更新仍然由组件状态驱动。

Teleport 不是 iframe,也不是把组件复制到另一个 Vue 应用中。它只改变 DOM 插入位置。

2.2 DOM 关系发生了改变

如果没有 Teleport,以下模板:

<div class="page">
  <UserDialog />
</div>

最终可能产生:

<div class="page">
  <div class="dialog">...</div>
</div>

使用 Teleport 后则可能变为:

<div class="page"></div>

<div id="modal-root">
  <div class="dialog">...</div>
</div>

这会直接影响:

  • CSS 后代选择器;
  • DOM 原生事件冒泡路径;
  • position: absoluteposition: fixed 的参考环境;
  • overflow 裁剪;
  • z-index 和 stacking context;
  • 脚本通过 parentElementclosest() 查找祖先的结果。

因此,“组件上仍然是子组件”不能推导出“DOM 上仍然是子元素”。


三、为什么弹窗经常需要 Teleport:定位、裁剪和层叠上下文

3.1 overflow 会裁剪普通后代

.panel {
  position: relative;
  overflow: hidden;
}

如果弹窗 DOM 留在 .panel 内部,弹窗超出 .panel 边界的部分可能被裁剪。将其 Teleport 到页面级容器,可以脱离这个裁剪祖先。

但这不是绝对保证。Teleport 的目标节点本身仍然可能位于某个裁剪容器内,因此目标通常放在应用根节点附近,而不是放进业务卡片内部。

3.2 z-index 不能跨越所有 stacking context

许多 CSS 属性会创建新的 stacking context,例如:

  • transformnone
  • opacity 小于 1
  • filter
  • 某些 positionz-index 组合;
  • isolation: isolate
  • contain 的部分取值。

下面的代码中,子元素的 z-index: 999999 也不能跳出 .card 的 stacking context:

.card {
  position: relative;
  z-index: 1;
  transform: translateZ(0);
}

.dialog {
  position: fixed;
  z-index: 999999;
}

如果 .card 所处的层叠上下文整体低于另一个兄弟上下文,.dialog 仍然无法覆盖后者。Teleport 到顶层容器通常能减少这种问题,但最终仍取决于 Teleport 目标的祖先和页面的层叠结构。

3.3 position: fixed 的参考系也可能受祖先影响

通常 position: fixed 以视口为参考,但某些祖先属性,尤其是 transform,会让固定定位元素表现得像相对于该祖先定位。Teleport 到没有这类影响的页面级容器,能使全屏遮罩更接近预期。

这解释了为什么下面的结构常见:

<body>
  <div id="app"></div>
  <div id="modal-root"></div>
</body>

而不是:

<div id="app">
  <main>
    <div id="modal-root"></div>
  </main>
</div>

后者仍可能受到应用内部布局容器的裁剪和层叠影响。


四、Teleport 与 CSS:样式作用域不会改变 DOM 选择器语义

Vue 的 <style scoped> 会给组件渲染出的元素添加类似 data-v-xxxxxxx 的属性。例如:

<style scoped>
.page .dialog {
  color: red;
}
</style>

编译后选择器大致相当于:

.page .dialog[data-v-xxxxxxx] {
  color: red;
}

如果 .dialog 被 Teleport 到 .page 之外,.page .dialog 的后代关系不成立,样式就不会匹配。

因此,Teleport 组件的样式应尽量依赖自身稳定的类名,而不是依赖原始组件位置:

<style scoped>
.dialog {
  width: min(90vw, 480px);
  background: white;
}
</style>

如果确实需要全局的遮罩层基础样式,可以放到全局 CSS:

#modal-root {
  position: relative;
  z-index: 1000;
}

.modal-backdrop {
  position: fixed;
  inset: 0;
}

这不是 Teleport 的特殊 bug,而是 CSS 后代选择器对真实 DOM 关系的正常判断。


五、事件:组件事件仍沿组件树,原生事件沿真实 DOM 树

事件是 Teleport 最容易产生误解的部分。需要把两类事件分开。

5.1 Vue 组件事件不依赖 DOM 冒泡

子组件可以定义并触发自定义事件:

<!-- Dialog.vue -->
<script setup lang="ts">
const emit = defineEmits<{
  close: []
}>()
</script>

<template>
  <button type="button" @click="emit('close')">
    关闭
  </button>
</template>

父组件仍然可以监听:

<Dialog @close="open = false" />

即使 Dialog 的内容通过 Teleport 出现在 #modal-rootclose 也会传给逻辑上的父组件。这里发生的是:

  1. 原生 click 在按钮上发生;
  2. Dialog 的处理函数调用 emit('close')
  3. Vue 直接调用父组件注册的 close 监听器;
  4. 父组件更新 open
  5. Teleport 内容被卸载或更新。

这条链路不是浏览器的 DOM 冒泡。

5.2 原生事件冒泡只沿实际 DOM 树传播

考虑:

<template>
  <div class="page" @click="onPageClick">
    <Teleport to="#modal-root">
      <button type="button">传送后的按钮</button>
    </Teleport>
  </div>
</template>

按钮的实际 DOM 祖先是:

button
└── #modal-root
    └── body

它不是 .page 的 DOM 后代。因此按钮上的原生 click 不会冒泡到 .page@click

这与组件事件可以同时成立:

  • @click 作为 DOM 原生事件,遵循真实 DOM 路径;
  • @close 作为 Vue 自定义事件,遵循组件监听关系。

5.3 Teleport 对事件路径的完整推导

假设页面是:

组件树:
Page
└── Dialog

DOM 树:
body
├── #app
└── #modal-root
    └── .dialog
        └── button

点击 button 时,浏览器的事件路径近似是:

button → .dialog → #modal-root → body → document → window

不会经过:

#app → Page 对应的 DOM 节点

Dialog 仍然可以在按钮监听器中:

emit('close')

Page 收到 close。这两条路径不能互相替代。

5.4 点击遮罩关闭应该使用 .self

典型弹窗结构:

<div class="backdrop" @click.self="emit('close')">
  <section class="dialog">
    <!-- 内容 -->
  </section>
</div>

.self 表示只有事件目标本身就是 .backdrop 时才关闭。点击 .dialog 内容时,事件虽然会冒泡到 .backdrop,但事件目标是内部元素,不满足 .self

如果不使用 .self

<div class="backdrop" @click="emit('close')">

点击弹窗内任意按钮、输入框或文本区域都可能触发关闭,除非继续调用 stopPropagation().self 通常表达得更准确,也不需要阻断内部事件。


六、Teleport 的核心 API 和边界

6.1 to

<Teleport to="#modal-root">
  <MyDialog />
</Teleport>

to 支持:

  • CSS 选择器字符串;
  • HTMLElement
  • 响应式地变化的目标。

目标节点应该是稳定的页面基础设施,而不是每次渲染都会销毁的业务节点。

6.2 disabled

disabled 为真时,Teleport 不再传送内容,而是在当前位置渲染:

<Teleport to="#modal-root" :disabled="isMobileInline">
  <div class="dialog">...</div>
</Teleport>

这会改变 DOM 层级,因此也会改变:

  • CSS 选择器匹配;
  • 原生事件冒泡路径;
  • 定位和层叠上下文;
  • SSR 与客户端首次渲染的结构。

如果 isMobileInline 依赖浏览器宽度,不能直接让服务端和客户端首次结果不同,否则可能出现 hydration mismatch。应该在客户端挂载后再读取窗口状态,或者使用 CSS 媒体查询解决纯视觉差异。

6.3 多个 Teleport 指向同一个目标

多个 Teleport 可以共享目标:

<Teleport to="#modal-root">
  <ToastList />
</Teleport>

<Teleport to="#modal-root">
  <ConfirmDialog />
</Teleport>

Vue 会将它们的内容追加到同一个目标中。依赖自然追加顺序管理复杂弹窗层级并不稳妥;如果业务需要明确的 z-index、遮罩和关闭策略,应由统一的 overlay manager 管理,而不是让各组件隐式竞争 DOM 顺序。

6.4 defer:版本敏感能力

较新的 Vue 3 版本提供 defer,用于允许 Teleport 等待同一轮挂载过程中稍后出现的目标:

<Teleport defer to="#late-target">
  <div>内容</div>
</Teleport>

它解决的是“目标在同一渲染周期的后续位置才出现”的问题,不等价于等待任意异步请求、定时器或下一次不相关的页面更新。

由于 defer 属于版本敏感能力,使用前应确认项目的 Vue 版本和官方 API 文档。对于应用级弹窗,直接在 index.html 中声明稳定的 #modal-root 通常比依赖延迟查找更容易验证。


七、SSR:服务端输出与客户端 hydration 必须共享同一个目标结构

7.1 Teleport 在 SSR 中不是简单地把内容丢弃

服务端渲染时,Teleport 的内容不会像普通模板一样直接混入应用根 HTML。Vue SSR 会把 Teleport 内容收集到 SSR 上下文的 teleports 中。

下面是一个最小示例:

// entry-server.ts
import { createSSRApp } from 'vue'
import { renderToString } from '@vue/server-renderer'
import App from './App.vue'

export async function render() {
  const app = createSSRApp(App)
  const context: Record<string, unknown> = {}

  const appHtml = await renderToString(app, context)

  return {
    appHtml,
    teleports: context.teleports,
  }
}

概念上,返回结果可能包含:

{
  appHtml: '<div id="app-content">...</div>',
  teleports: {
    '#modal-root': '<div class="dialog">...</div>'
  }
}

服务端框架需要把对应的 Teleport 输出插入最终 HTML 中。例如最终文档应具有类似结构:

<div id="app">
  <div id="app-content">...</div>
</div>

<div id="modal-root">
  <div class="dialog">...</div>
</div>

具体 SSR 框架会负责如何读取和拼接上下文;不能假设 renderToString() 返回的主字符串已经包含所有 Teleport 内容。

7.2 hydration 的必要条件

客户端 hydration 需要同时满足:

  1. 服务端已经输出了 Teleport 的目标节点;
  2. 客户端首次渲染使用相同的 to
  3. 服务端和客户端的 open 状态一致;
  4. Teleport 内容结构一致;
  5. 目标节点没有被其他脚本提前改写。

如果服务端认为弹窗打开:

open = true

而客户端首次运行时认为:

open = false

Vue 必须修正服务端 DOM,可能出现 hydration 警告或闪烁。弹窗初始状态通常应由同一份服务端可用状态决定,不能只在客户端无条件打开或关闭。

7.3 为什么不建议直接传送到 body

将内容传送到 body 在浏览器中可以工作:

<Teleport to="body">
  <div class="dialog">...</div>
</Teleport>

但 SSR hydration 时,body 还包含应用根节点、脚本和服务端注入的其他内容。Vue 很难仅凭整个 body 的结构判断哪些节点属于某个 Teleport。

更稳定的方案是使用专用目标:

<body>
  <div id="app"></div>
  <div id="modal-root"></div>
</body>

然后始终:

<Teleport to="#modal-root">

专用目标也便于设置统一的层级、测试选择器和 overlay 生命周期。


八、可访问性:Teleport 不会自动把普通 div 变成可用弹窗

视觉上出现在屏幕中央,只说明 CSS 生效,不说明辅助技术理解了页面状态。

一个可访问的模态弹窗至少要处理以下问题:

  1. 语义:使用 role="dialog",并提供名称。
  2. 模态关系:使用 aria-modal="true" 表达背景内容不可交互。
  3. 标题关联:通过 aria-labelledby 指向可见标题。
  4. 打开焦点:弹窗出现后把焦点放入弹窗。
  5. 焦点陷阱:Tab 不能跑到被遮罩的页面内容。
  6. 关闭焦点:关闭后焦点回到打开弹窗的控件。
  7. 键盘关闭:通常响应 Escape。
  8. 背景隔离:阻止鼠标、键盘和辅助技术继续访问背景。
  9. 滚动控制:模态弹窗打开时通常禁止页面背景滚动。
  10. 错误反馈:表单错误应与输入控件建立可访问关联。

aria-modal="true" 是语义声明,不是浏览器自动实现的焦点陷阱,也不会自动阻止背景点击。因此还必须配合 DOM 和键盘行为。


九、一个可运行的 TypeScript 弹窗组件

下面组件演示:

  • Teleport 到 #modal-root
  • role="dialog"aria-modal 和标题关联;
  • v-model 控制打开状态;
  • 打开时聚焦关闭按钮;
  • Tab 在弹窗内部循环;
  • Escape 关闭;
  • 点击遮罩空白处关闭;
  • 关闭后恢复触发元素焦点。

9.1 ModalDialog.vue

<script setup lang="ts">
import { nextTick, onBeforeUnmount, watch } from 'vue'

const props = withDefaults(
  defineProps<{
    open: boolean
    title: string
    returnFocusEl?: HTMLElement | null
  }>(),
  {
    returnFocusEl: null,
  },
)

const emit = defineEmits<{
  'update:open': [value: boolean]
}>()

const titleId = 'modal-dialog-title'

function close() {
  emit('update:open', false)
}

function getFocusableElements(container: HTMLElement): HTMLElement[] {
  const selector = [
    'a[href]',
    'area[href]',
    'button:not([disabled])',
    'input:not([disabled]):not([type="hidden"])',
    'select:not([disabled])',
    'textarea:not([disabled])',
    '[contenteditable="true"]',
    '[tabindex]:not([tabindex="-1"])',
  ].join(',')

  return Array.from(container.querySelectorAll<HTMLElement>(selector))
    .filter((element) => {
      const style = window.getComputedStyle(element)
      return style.display !== 'none' && style.visibility !== 'hidden'
    })
}

function onKeydown(event: KeyboardEvent) {
  const dialog = event.currentTarget as HTMLElement

  if (event.key === 'Escape') {
    event.preventDefault()
    close()
    return
  }

  if (event.key !== 'Tab') {
    return
  }

  const focusable = getFocusableElements(dialog)

  if (focusable.length === 0) {
    event.preventDefault()
    dialog.focus()
    return
  }

  const first = focusable[0]
  const last = focusable[focusable.length - 1]
  const active = document.activeElement

  if (event.shiftKey && active === first) {
    event.preventDefault()
    last.focus()
  } else if (!event.shiftKey && active === last) {
    event.preventDefault()
    first.focus()
  }
}

watch(
  () => props.open,
  async (open) => {
    if (open) {
      await nextTick()

      const dialog = document.querySelector<HTMLElement>(
        '[data-modal-dialog]',
      )

      dialog?.focus()
    } else {
      await nextTick()
      props.returnFocusEl?.focus()
    }
  },
)

onBeforeUnmount(() => {
  if (!props.open) {
    props.returnFocusEl?.focus()
  }
})
</script>

<template>
  <Teleport to="#modal-root">
    <div
      v-if="open"
      class="modal-backdrop"
      @click.self="close"
    >
      <section
        data-modal-dialog
        class="modal-dialog"
        role="dialog"
        aria-modal="true"
        :aria-labelledby="titleId"
        tabindex="-1"
        @keydown="onKeydown"
      >
        <header class="modal-header">
          <h2 :id="titleId">{{ title }}</h2>

          <button
            type="button"
            aria-label="关闭弹窗"
            @click="close"
          >
            ×
          </button>
        </header>

        <div class="modal-body">
          <slot />
        </div>
      </section>
    </div>
  </Teleport>
</template>

<style scoped>
.modal-backdrop {
  position: fixed;
  inset: 0;
  z-index: 1000;
  display: grid;
  place-items: center;
  padding: 1rem;
  background: rgb(0 0 0 / 50%);
}

.modal-dialog {
  width: min(100%, 32rem);
  max-height: calc(100vh - 2rem);
  overflow: auto;
  border-radius: 0.5rem;
  background: white;
  color: #222;
  box-shadow: 0 1rem 3rem rgb(0 0 0 / 30%);
}

.modal-header {
  display: flex;
  align-items: center;
  justify-content: space-between;
  padding: 1rem;
  border-bottom: 1px solid #ddd;
}

.modal-header h2 {
  margin: 0;
  font-size: 1.25rem;
}

.modal-body {
  padding: 1rem;
}
</style>

这个示例中的 querySelector('[data-modal-dialog]') 假设页面同时只有一个该弹窗。如果应用支持多个并发弹窗,应将 dialog 用模板 ref 传入,而不是查询全局文档。

9.2 父组件管理状态和触发焦点

<script setup lang="ts">
import { ref } from 'vue'
import ModalDialog from './ModalDialog.vue'

const open = ref(false)
const opener = ref<HTMLElement | null>(null)

function openDialog(event: MouseEvent) {
  opener.value = event.currentTarget as HTMLElement
  open.value = true
}
</script>

<template>
  <div id="page-content" :inert="open ? '' : undefined">
    <button type="button" @click="openDialog">
      打开设置
    </button>

    <p>
      弹窗打开时,这部分页面内容不应继续被键盘和鼠标操作。
    </p>
  </div>

  <ModalDialog
    v-model:open="open"
    title="设置"
    :return-focus-el="opener"
  >
    <p>这是一个通过 Teleport 渲染到页面级容器的弹窗。</p>

    <label>
      显示名称
      <input type="text" />
    </label>
  </ModalDialog>
</template>

这里的 inert 作用在 #page-content,而 Teleport 目标 #modal-root 位于其外部,因此不会把弹窗本身一并禁用。

需要注意:

  • inert 主要负责让背景子树不可聚焦、不可交互。
  • 旧浏览器对 inert 的支持可能需要兼容方案或 polyfill。
  • aria-hidden="true" 不能简单替代 inert:它主要影响辅助技术树,不一定阻止鼠标和键盘操作。
  • 如果弹窗嵌套在另一个 overlay 中,不能无条件给整个 #app 设置 inert,否则可能误伤仍需交互的外层容器。

十、焦点管理的状态变化

一个模态弹窗的焦点生命周期可以表示为:

stateDiagram-v2
    [*] --> Closed
    Closed --> Opening: 用户激活触发按钮
    Opening --> Open: DOM 挂载并完成 nextTick
    Open --> Open: Tab / Shift+Tab 在弹窗内循环
    Open --> Closing: 点击关闭、遮罩或 Escape
    Closing --> Closed: 更新 open=false 并卸载
    Closed --> Closed: 焦点恢复到触发按钮

关键顺序不是“设置状态后立刻查询 DOM”,而是:

  1. 触发按钮保存到 opener
  2. 设置 open = true
  3. Vue 创建 Teleport 内容;
  4. nextTick() 等待当前 DOM 更新完成;
  5. 查询或使用模板 ref 获取弹窗;
  6. 调用 .focus()
  7. 关闭时设置 open = false
  8. DOM 卸载完成后恢复触发按钮焦点。

如果在第 2 步后立即执行:

open.value = true
document.querySelector('.modal-dialog')?.focus()

通常查询不到元素,因为 Vue 的 DOM 更新尚未完成。

生产实现还应处理这些边界:

  • 弹窗关闭过程中有 <Transition> 时,焦点恢复时机应与实际卸载时机一致;
  • 触发按钮可能已经被卸载,此时不能强制调用 .focus()
  • 弹窗内部可能有异步加载的首个输入控件,应在内容可用后决定焦点;
  • 多个弹窗同时存在时,焦点陷阱必须只作用于当前最上层弹窗。

十一、滚动锁和弹窗状态不能分散管理

打开弹窗时经常需要阻止页面背景滚动:

watch(
  () => open.value,
  (value) => {
    document.body.style.overflow = value ? 'hidden' : ''
  },
)

这个简单实现只适合页面永远只有一个弹窗。如果两个组件都修改 body.style.overflow

  1. 弹窗 A 打开,设置 hidden
  2. 弹窗 B 打开,设置 hidden
  3. 弹窗 A 关闭,恢复空字符串;
  4. 弹窗 B 仍然打开,但背景滚动被错误恢复。

因此滚动锁需要引用计数或统一管理器:

let lockCount = 0
let previousOverflow = ''

export function acquireScrollLock() {
  if (lockCount === 0) {
    previousOverflow = document.body.style.overflow
    document.body.style.overflow = 'hidden'
  }

  lockCount += 1

  return () => {
    lockCount = Math.max(0, lockCount - 1)

    if (lockCount === 0) {
      document.body.style.overflow = previousOverflow
    }
  }
}

组件在打开时获取释放函数,在卸载或关闭时执行释放函数。这样关闭一个弹窗不会破坏其他弹窗的锁定状态。

移动端还可能出现滚动条消失导致页面横向跳动、视觉视口变化和触摸滚动穿透等问题。Teleport 只负责节点位置,不负责这些平台行为。


十二、弹窗架构:状态、内容、层级和关闭原因要分开

一个可维护的弹窗系统通常包含四个概念。

12.1 状态所有权

页面或 overlay manager 应拥有“哪个弹窗打开”的状态:

type DialogState =
  | { kind: 'none' }
  | { kind: 'delete-user'; userId: string }
  | { kind: 'settings' }

而不是让每个弹窗通过全局 DOM 查询决定自己的显示状态。状态单向流动:

用户操作
  ↓
父组件或 manager 更新状态
  ↓
Dialog 接收 props
  ↓
Dialog emit close / confirm
  ↓
父组件更新状态

12.2 内容与壳层分离

弹窗壳层负责:

  • Teleport;
  • 遮罩;
  • 焦点;
  • Escape;
  • 滚动锁;
  • 层级。

业务内容负责:

  • 表单字段;
  • 异步提交;
  • 校验;
  • 成功和失败状态。

这样“删除用户确认框”和“编辑设置弹窗”可以复用同一个可访问的壳层,而不重复实现焦点和关闭逻辑。

12.3 关闭原因应可区分

不要只用一个无参数的 close,如果业务需要审计或不同交互策略,可以定义关闭原因:

type CloseReason = 'confirm' | 'cancel' | 'escape' | 'backdrop'

const emit = defineEmits<{
  close: [reason: CloseReason]
}>()

调用方可以区分:

function onClose(reason: CloseReason) {
  if (reason === 'escape') {
    // 例如只关闭最上层,而不提交草稿
  }

  open.value = false
}

12.4 最上层规则

当多个 overlay 并发出现时,需要明确:

  • 谁拥有最高 z-index;
  • Escape 关闭哪一个;
  • 背景点击作用于哪一层;
  • 哪一层拥有焦点;
  • 是否允许弹窗打开弹窗;
  • 关闭外层时如何处理内层。

Teleports 都指向同一个 #modal-root 并不能自动解决这些规则。常见做法是由 manager 维护一个栈:

type OverlayEntry = {
  id: string
  kind: 'dialog' | 'popover' | 'toast'
  close: () => void
}

Escape 到来时只关闭栈顶的可关闭 overlay,而不是让每个组件都在 window 上注册一个独立监听器。


十三、<dialog> 与 Teleport 的关系

现代浏览器提供原生 <dialog>

<dialog open>
  <h2>原生对话框</h2>
</dialog>

它可以配合 showModal() 进入浏览器的 modal 行为和 top layer。原生 <dialog> 在焦点、Escape 和可访问性方面可能比普通 div 更接近平台语义,但浏览器兼容性、样式、动画、表单行为和项目封装方式仍需验证。

Teleport 与 <dialog> 可以组合:

<Teleport to="#modal-root">
  <dialog ref="dialogEl">
    ...
  </dialog>
</Teleport>

这时:

  • Teleport 决定 <dialog> 在哪个 DOM 容器中创建;
  • <dialog> API 决定它是否进入浏览器 top layer;
  • 两者不是互相替代的关系。

也不能因为使用了 <dialog> 就完全跳过关闭原因、业务状态和 SSR 校验。是否使用原生 <dialog> 应根据目标浏览器和交互要求决定。


十四、常见失败表现与诊断方法

14.1 找不到 Teleport 目标

表现:

Failed to locate Teleport target with selector "#modal-root"

检查顺序:

  1. index.html 是否真的包含 #modal-root
  2. 目标 ID 是否拼写一致;
  3. Teleport 是否在目标创建之前执行;
  4. 是否在测试环境中忘记挂载目标;
  5. 是否使用了动态目标但目标已被卸载。

单元测试中可以显式准备目标:

beforeEach(() => {
  const target = document.createElement('div')
  target.id = 'modal-root'
  document.body.appendChild(target)
})

afterEach(() => {
  document.querySelector('#modal-root')?.remove()
})

14.2 点击弹窗没有触发页面上的 @click

这通常不是 Vue 事件丢失,而是原生事件没有沿原来的 DOM 祖先冒泡。应确认:

  • 监听器是在组件上,还是 DOM 元素上;
  • 子组件是否通过 emit 转发;
  • 页面监听器是否依赖 Teleport 前的 DOM 后代关系。

如果需要业务通知,使用自定义事件:

emit('confirm')

如果需要监听所有文档级点击,使用 documentwindow 监听,并在逻辑中判断目标,但要注意清理监听器和事件竞态。

14.3 弹窗被遮住

检查实际 DOM,而不是只看组件模板:

document.querySelector('.modal-dialog')?.parentElement

然后检查:

  • Teleport 目标是否位于低层叠上下文;
  • 目标或祖先是否设置了 transformfilteropacity
  • 是否存在更高 z-index 的兄弟 stacking context;
  • 是否有 overflow 裁剪;
  • 是否有其他 overlay 使用了浏览器 top layer。

提高 z-index 只能解决同一个 stacking context 内的排序,不能突破父级 stacking context。

14.4 SSR hydration 警告

重点检查服务端和客户端首次渲染是否一致:

服务端:open = true
客户端:open = false

或:

服务端目标:#modal-root
客户端目标:body

还要确认 SSR 输出阶段把 context.teleports 插入了最终 HTML,而不是只返回主应用字符串。

14.5 弹窗打开后键盘仍能操作背景

这说明仅设置了遮罩或 aria-modal,但没有实现交互隔离。验证方法包括:

  1. 打开弹窗;
  2. 连续按 Tab;
  3. 观察焦点是否离开弹窗;
  4. 尝试点击背景页面按钮;
  5. 使用屏幕阅读器检查背景是否仍可访问。

解决方案通常需要 inert、焦点循环、正确的关闭时机,以及对旧浏览器的兼容处理。


十五、性能、并发和生命周期边界

Teleport 本身不会创建新的 Vue 应用,也不会天然产生跨线程或跨进程并发。它仍然在同一个响应式更新和 JavaScript 事件循环中执行。

但弹窗系统会遇到异步并发问题。例如:

  1. 用户打开确认框;
  2. 点击确认,开始异步删除;
  3. 用户快速按 Escape;
  4. 删除请求尚未完成,但弹窗已经关闭;
  5. 请求返回后,旧回调又尝试更新已关闭弹窗。

因此异步操作应拥有明确的状态:

const submitting = ref(false)

async function confirm() {
  if (submitting.value) {
    return
  }

  submitting.value = true

  try {
    await deleteUser()
    emit('close', 'confirm')
  } catch (error) {
    // 保持弹窗打开,显示错误
    console.error('删除失败', error)
  } finally {
    submitting.value = false
  }
}

关闭按钮和 Escape 是否在提交期间可用,应由业务规则决定。危险操作通常不能因为弹窗卸载就取消服务器请求;如果需要取消,应使用 AbortController,并在组件生命周期中明确释放。

同时,任何全局监听器都必须在生命周期结束时移除:

import { onMounted, onUnmounted } from 'vue'

function onGlobalKeydown(event: KeyboardEvent) {
  // ...
}

onMounted(() => {
  window.addEventListener('keydown', onGlobalKeydown)
})

onUnmounted(() => {
  window.removeEventListener('keydown', onGlobalKeydown)
})

否则每次打开弹窗都可能增加一个监听器,最终表现为一次 Escape 触发多次关闭逻辑。


十六、何时不应该使用 Teleport

Teleport 适合“组件逻辑归属于当前位置,但 DOM 需要位于其他层级”的场景。以下情况不一定需要它:

  • 普通表单区域,不存在裁剪或层叠问题;
  • 只需要响应式显示隐藏,不需要改变 DOM 位置;
  • 依赖严格的 DOM 后代选择器和原生事件冒泡;
  • 目标节点不稳定,且没有 SSR 方案;
  • 只是想让元素显示在最上面,但真正问题是错误的 stacking context;
  • 需要浏览器 top layer 行为,但没有评估原生 <dialog>

Teleport 不是“全局组件”的同义词,也不是状态管理器。一个组件可以 Teleport,但仍然应该由清晰的父组件或 manager 控制它的生命周期和业务状态。


十七、最终模型

可以用下面的模型理解 Teleport:

Vue 组件树
  ├── props / emits
  ├── provide / inject
  ├── 生命周期
  └── 响应式更新
          │
          │  Teleport 只改变 DOM 插入位置
          ▼
真实 DOM 树
  ├── CSS 后代关系
  ├── 原生事件冒泡
  ├── 定位参考系
  ├── overflow 裁剪
  └── stacking context

因此,使用 Teleport 构建弹窗时应分别回答五个问题:

  1. 层级:目标节点是否真正脱离了裁剪和不利的 stacking context?
  2. 事件:业务通知使用的是 Vue emit,还是依赖原生 DOM 冒泡?
  3. SSR:服务端是否输出了 Teleport 目标和对应内容,客户端首次状态是否一致?
  4. 可访问性:焦点、键盘、背景隔离、语义和关闭后的焦点是否完整?
  5. 架构:状态所有权、overlay 栈、滚动锁、异步请求和关闭原因是否统一管理?

只解决第一项,得到的只是“视觉上出现在顶层”的节点;同时处理其余四项,才是一个可用于生产环境的弹窗架构。


系列导航与关联阅读

官方资料

本文依据 Vue、Vite 与生态项目官方文档重新梳理;正文与示例由 WR BLOG 编写。