一、背景:为什么需要"传送门"
在 Vue 组件树中,子组件的 DOM 默认渲染在父组件的 DOM 结构内。但有些场景我们希望把内容"搬"到组件树之外的某个 DOM 节点去渲染:
- 模态框 / 对话框(需要
position: fixed且避免被父级overflow:hidden、transform、z-index影响) - Toast / 通知 / 全局提示
- 浮层菜单、下拉、Tooltip
- 跨布局区域的内容注入(如把某块内容"投递"到页脚的插槽)
这类需求的核心是 逻辑仍属于原组件,但渲染位置脱离原组件树,即"传送门(Portal)"模式。
二、Vue2 方案:portal-vue 库
Vue2 官方没有内置传送门,社区事实标准是 portal-vue 库(v2 适配 Vue2)。它提供一对配合使用的组件:
| 组件 | 作用 |
|---|---|
<portal> | 发送端,把包裹的内容"投递"到指定目标 |
<portal-target> | 接收端,在自身位置渲染收到的内容 |
1. 基本用法
<!-- 发送端:在某个深层组件里 -->
<template>
<div>
<portal to="modal-destination">
<div class="dialog">我是被传送到外部的弹窗</div>
</portal>
</div>
</template>
<!-- 接收端:通常放在 App.vue 或 body 附近 -->
<template>
<div id="app">
<router-view />
<!-- portal 把内容投到这里 -->
<portal-target name="modal-destination" />
</div>
</template>
<portal to="xxx"> 与 <portal-target name="xxx"> 通过同名字符串匹配,<portal-target> 处即渲染内容。
2. portal-target 常用配置
| 属性 | 说明 |
|---|---|
name | 目标名称,必须全局唯一(除非开启 multiple) |
multiple | 允许多个 portal 同时投递到同一 target,内容按顺序拼接 |
slim | 为 true 时不渲染额外的 wrapper 元素,直接用 slot 的单一根节点 |
tag | 指定 wrapper 渲染的标签(默认 div),可设为空字符串配合 slim |
order | 多个 portal 投递时的排序权重 |
slotProps / slotTag | 当 source 用 scoped slot 时的透传配置 |
默认情况下,一个 target 只能接收最后一个 portal 的内容(后到的覆盖先到的),要接收多个必须显式 multiple:
<portal-target name="footer" multiple />
3. Vue2 + portal-vue 的典型痛点
- 强依赖第三方库:
portal-vue体积不小,且需随 Vue2 版本严格匹配(v2 库对应 Vue2,v3 库对应 Vue3),升级成本高。 - SSR 需要特殊处理:服务端渲染时 portal 内容默认不出现在 HTML 中(库提供
SSRegress等机制),配置繁琐、易踩坑。 - 调试困难:内容在组件树 A 中定义、却渲染在组件树 B 的节点里,Vue DevTools 里父子关系割裂,定位问题要跨树查找。
- wrapper 嵌套:
portal-target默认套一层<div>,破坏意图样式(需slim规避),且多个 portal 时 DOM 结构不够直观。 - 单根限制:Vue2 模板要求单一根节点,配合传送时写复杂内容不够灵活。
- 响应式/重渲染逻辑隐式:portal 内容的更新依赖库的订阅机制,偶发"内容不刷新"需要排查 target 注册时机。
三、Vue3 方案:内置 <Teleport>
Vue3 官方内置 <Teleport> 组件,直接把"传送门"作为一等公民纳入框架,从根上解决了上述痛点。
1. 基本用法
<template>
<button @click="open = true">打开弹窗</button>
<!-- to 支持 CSS 选择器;disabled 时内容留在原地 -->
<Teleport to="body">
<div v-if="open" class="modal" @click.self="open = false">
<p>我会被直接渲染到 <body> 下</p>
</div>
</Teleport>
</template>
to 可以是任意 CSS 选择器(如 "#modal-root"、"body"、".some-class"),也可以是一个真实的 HTMLElement。
2. Vue3 的针对性优化点
| 优化维度 | Vue2 portal-vue | Vue3 Teleport |
|---|---|---|
| 是否内置 | 第三方库,独立维护 | 框架原生,零额外依赖 |
| 多源合并 | 需要 multiple 显式开启 | 天然支持,多个 <Teleport to="body"> 自动顺序追加 |
| 条件禁用 | portal 侧无内建 disabled(v3 库才有) | 内建 :disabled 属性,为 true 时内容留在原地渲染 |
| SSR | 需额外配置 SSRegress | 官方内建 SSR 支持,无需 hack |
| 调试 | 跨树割裂,需手动找 target | DevTools 中保持逻辑父子关系,来源清晰 |
| wrapper | portal-target 默认包一层 div | 不引入任何额外 DOM 包裹 |
| 根节点限制 | 受 Vue2 单根约束 | Vue3 Fragment 支持,内容可多根 |
| 体积/性能 | 库级运行时开销 | 编译期优化,运行时极轻 |
3. 进阶能力
defer(Vue 3.1+):延迟到组件挂载后再查找目标元素,解决"目标节点在初始渲染时尚未存在"的问题(典型场景:目标由其他异步组件动态创建)。
<Teleport to="#lazy-mounted-root" defer>
<span>目标还没出现,挂载后再搬过去</span>
</Teleport>
多 Teleport 同名合并:多个 <Teleport to="body"> 不会相互覆盖,而是依次成为 body 的子节点,等价于 portal-vue 开启 multiple,但无需任何配置。
禁用传送保留原位渲染:
<!-- 移动端弹窗直接内联,桌面端才传送到 body -->
<Teleport to="body" :disabled="isMobile">
<div class="modal">...</div>
</Teleport>
四、迁移建议(Vue2 → Vue3)
- 卸载
portal-vue,移除所有<portal>/<portal-target>。 - 发送端改写:
<portal to="x">→<Teleport to="x">(注意to仍是名称字符串,但表示选择器,建议改成真实 DOM 选择器如#x更直观)。 - 接收端删除:
<portal-target name="x" />不再需要——<Teleport>直接把内容渲染到to指向的 DOM 节点,无需显式声明 target 占位。 - 多源场景:原来靠
multiple拼接的,直接写多个<Teleport to="同一选择器">即可,删除multiple。 - 条件渲染:原先靠动态
to或销毁 portal 来"留在原地"的逻辑,改用:disabled。 - SSR 项目:移除
SSRegress相关配置,Teleport 已原生支持。 - slim/wrapper 清理:原来为了消除包裹
div的slim配置可删除,Teleport 本身不引入包裹节点。
迁移对照表
<!-- Vue2 -->
<portal to="dialog">
<div class="box">hi</div>
</portal>
<!-- 接收端 -->
<portal-target name="dialog" multiple />
<!-- Vue3 -->
<Teleport to="#dialog">
<div class="box">hi</div>
</Teleport>
<!-- 无需接收端,#dialog 节点直接作为 DOM 挂载点 -->
五、一句话总结
Vue2 用
portal-vue(<portal>+<portal-target>配对)实现传送门,能力完整但有依赖、SSR、调试、wrapper 等多重痛点;Vue3 用原生<Teleport>把这套能力收编进框架,零依赖、内建 SSR、支持disabled条件禁用与多源自动合并,并借助 Fragment 与编译优化进一步减重提效。