目录
<Teleport>
是一个内置组件,它可以将一个组件内部的一部分模板“传送”到该组件的 DOM 结构外层的位置去。
基本用法
有时我们可能会遇到这样的场景:一个组件模板的一部分在逻辑上从属于该组件,但从整个应用视图的角度来看,它在 DOM 中应该被渲染在其他地方,甚至在整个 Vue 应用外部。
这类场景最常见的例子就是全屏的模态框。理想情况下,我们希望触发模态框的按钮和模态框本身的代码是在同一个单文件组件中,因为它们都与组件的开关状态有关。但这意味着该模态框将与按钮一起渲染在应用 DOM 结构里很深的地方。这会导致该模态框的 CSS 布局代码很难写。
试想下面这样的 HTML 结构:
<div class="outer">
<h3>Tooltips with Vue 3 Teleport</h3>
<div>
<MyModal />
</div>
</div>
接下来我们来看看 <MyModal>
的实现:
<script setup>
import { ref } from 'vue'
const open = ref(false)
</script>
<template>
<button @click="open = true">Open Modal</button>
<div v-if="open" class="modal">
<p>Hello from the modal!</p>
<button @click="open = false">Close</button>
</div>
</template>
<style scoped>
.modal {
position: fixed;
z-index: 999;
top: 20%;
left: 50%;
width: 300px;
margin-left: -150px;
}
</style>
这个组件中有一个 <button>
按钮来触发打开模态框,和一个 class 名为 .modal
的 <div>
,它包含了模态框的内容和一个用来关闭的按钮。
当在初始 HTML 结构中使用这个组件时,会有一些潜在的问题:
position: fixed
能够相对于浏览器窗口放置有一个条件,那就是不能有任何祖先元素设置了transform
、perspective
或者filter
样式属性。也就是说如果我们想要用 CSStransform
为祖先节点<div class="outer">
设置动画,就会不小心破坏模态框的布局!这个模态框的
z-index
受限于它的容器元素。如果有其他元素与<div class="outer">
重叠并有更高的z-index
,则它会覆盖住我们的模态框。
<Teleport>
提供了一个更简单的方式来解决此类问题,让我们不需要再顾虑 DOM 结构的问题。让我们用 <Teleport>
改写一下 <MyModal>
:
<button @click="open = true">Open Modal</button>
<Teleport to="body">
<div v-if="open" class="modal">
<p>Hello from the modal!</p>
<button @click="open = false">Close</button>
</div>
</Teleport>
<Teleport>
接收一个 to
prop 来指定传送的目标。to
的值可以是一个 CSS 选择器字符串,也可以是一个 DOM 元素对象。这段代码的作用就是告诉 Vue“把以下模板片段传送到 body
标签下”。
你可以点击下面这个按钮,然后通过浏览器的开发者工具,在 <body>
标签下找到模态框元素:
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Vue 3 模态框演示</title>
<!-- 引入 Vue 3 CDN -->
<script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
}
body {
background: linear-gradient(135deg, #1a2a6c, #b21f1f, #fdbb2d);
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
padding: 40px 20px;
color: #fff;
}
.container {
max-width: 1000px;
width: 100%;
text-align: center;
}
header {
margin-bottom: 40px;
}
h1 {
font-size: 3rem;
margin-bottom: 10px;
text-shadow: 0 2px 10px rgba(0,0,0,0.2);
}
.subtitle {
font-size: 1.2rem;
opacity: 0.9;
max-width: 600px;
margin: 0 auto;
}
.features {
display: flex;
justify-content: center;
gap: 30px;
margin: 50px 0;
flex-wrap: wrap;
}
.feature-card {
background: rgba(255, 255, 255, 0.1);
backdrop-filter: blur(10px);
border-radius: 15px;
padding: 25px;
width: 250px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.2);
border: 1px solid rgba(255, 255, 255, 0.18);
transition: transform 0.3s ease;
}
.feature-card:hover {
transform: translateY(-10px);
}
.feature-icon {
font-size: 3rem;
margin-bottom: 20px;
color: #fdbb2d;
}
.feature-title {
font-size: 1.5rem;
margin-bottom: 15px;
}
.feature-desc {
font-size: 1rem;
opacity: 0.8;
}
.open-modal-btn {
background: linear-gradient(to right, #ff416c, #ff4b2b);
color: white;
border: none;
padding: 16px 40px;
font-size: 1.2rem;
border-radius: 50px;
cursor: pointer;
transition: all 0.3s ease;
box-shadow: 0 4px 20px rgba(255, 75, 43, 0.4);
margin: 20px 0;
font-weight: bold;
letter-spacing: 1px;
}
.open-modal-btn:hover {
transform: translateY(-3px);
box-shadow: 0 6px 25px rgba(255, 75, 43, 0.6);
}
.open-modal-btn:active {
transform: translateY(1px);
}
.instructions {
background: rgba(0, 0, 0, 0.2);
padding: 25px;
border-radius: 15px;
max-width: 700px;
margin: 40px auto;
text-align: left;
}
.instructions h2 {
margin-bottom: 15px;
text-align: center;
}
.instructions code {
background: rgba(0, 0, 0, 0.3);
padding: 2px 8px;
border-radius: 4px;
font-family: monospace;
}
/* 模态框样式 */
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.5);
display: flex;
justify-content: center;
align-items: center;
z-index: 9999;
}
.modal-container {
background: linear-gradient(to bottom right, #2c3e50, #1a1a2e);
border-radius: 15px;
width: 90%;
max-width: 500px;
padding: 30px;
box-shadow: 0 10px 50px rgba(0, 0, 0, 0.3);
position: relative;
transform: scale(0.95);
opacity: 0;
animation: modalAppear 0.4s forwards;
}
@keyframes modalAppear {
to {
transform: scale(1);
opacity: 1;
}
}
.modal-header {
text-align: center;
margin-bottom: 25px;
}
.modal-header h2 {
font-size: 2rem;
color: #fdbb2d;
margin-bottom: 10px;
}
.modal-content {
margin-bottom: 30px;
line-height: 1.6;
font-size: 1.1rem;
}
.modal-footer {
display: flex;
justify-content: center;
gap: 15px;
}
.modal-btn {
padding: 12px 30px;
border: none;
border-radius: 8px;
font-size: 1rem;
font-weight: bold;
cursor: pointer;
transition: all 0.2s ease;
}
.close-btn {
background: linear-gradient(to right, #ff416c, #ff4b2b);
color: white;
}
.action-btn {
background: linear-gradient(to right, #00b09b, #96c93d);
color: white;
}
.modal-btn:hover {
transform: translateY(-2px);
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.2);
}
footer {
margin-top: 50px;
opacity: 0.7;
font-size: 0.9rem;
}
@media (max-width: 768px) {
.features {
flex-direction: column;
align-items: center;
}
h1 {
font-size: 2.2rem;
}
}
</style>
</head>
<body>
<div id="app">
<div class="container">
<header>
<h1>Vue 3 模态框演示</h1>
<p class="subtitle">使用Vue 3的Teleport组件创建高性能模态框,实现内容渲染到body元素</p>
</header>
<div class="features">
<div class="feature-card">
<div class="feature-icon">🚀</div>
<h3 class="feature-title">Teleport 特性</h3>
<p class="feature-desc">使用Teleport将模态框渲染到body元素,避免CSS层级问题</p>
</div>
<div class="feature-card">
<div class="feature-icon">🎨</div>
<h3 class="feature-title">平滑动画</h3>
<p class="feature-desc">CSS动画实现模态框的平滑进入和退出效果</p>
</div>
<div class="feature-card">
<div class="feature-icon">📱</div>
<h3 class="feature-title">响应式设计</h3>
<p class="feature-desc">适配各种屏幕尺寸,在移动设备上完美展示</p>
</div>
</div>
<button class="open-modal-btn" @click="open = true">打开模态框</button>
<div class="instructions">
<h2>实现说明</h2>
<p>此演示使用Vue 3的Composition API和Teleport组件:</p>
<ul>
<li>使用<code><Teleport to="body"></code>将模态框渲染到body元素</li>
<li>通过<code>v-if</code>指令控制模态框的显示/隐藏</li>
<li>使用CSS动画实现模态框的平滑过渡效果</li>
<li>点击模态框外部或关闭按钮可关闭模态框</li>
<li>使用Vue 3的CDN版本,无需构建步骤</li>
</ul>
</div>
</div>
<!-- 使用Teleport将模态框渲染到body -->
<Teleport to="body">
<div v-if="open" class="modal-overlay" @click.self="open = false">
<div class="modal-container">
<div class="modal-header">
<h2>Vue 3 模态框</h2>
<p>使用Teleport实现</p>
</div>
<div class="modal-content">
<p>这是一个使用Vue 3的Teleport组件创建的模态框示例。</p>
<p>模态框被渲染到body元素,避免了CSS层级问题,同时实现了平滑的动画效果。</p>
<p>点击外部区域、关闭按钮或按ESC键都可以关闭此模态框。</p>
</div>
<div class="modal-footer">
<button class="modal-btn action-btn">确认操作</button>
<button class="modal-btn close-btn" @click="open = false">关闭</button>
</div>
</div>
</div>
</Teleport>
<footer>
<p>Vue 3 模态框演示 © 2023</p>
</footer>
</div>
<script>
const { createApp, ref } = Vue
createApp({
setup() {
const open = ref(false)
// 添加键盘事件监听
const handleKeydown = (e) => {
if (open.value && e.key === 'Escape') {
open.value = false
}
}
// 在组件挂载时添加事件监听
Vue.onMounted(() => {
window.addEventListener('keydown', handleKeydown)
})
// 在组件卸载时移除事件监听
Vue.onUnmounted(() => {
window.removeEventListener('keydown', handleKeydown)
})
return {
open
}
}
}).mount('#app')
</script>
</body>
</html>
TIP
<Teleport>
挂载时,传送的to
目标必须已经存在于 DOM 中。理想情况下,这应该是整个 Vue 应用 DOM 树外部的一个元素。如果目标元素也是由 Vue 渲染的,你需要确保在挂载<Teleport>
之前先挂载该元素。
我们也可以将 <Teleport>
和 <Transition> 结合使用来创建一个带动画的模态框。你可以看看这个示例。
搭配组件使用
<Teleport>
只改变了渲染的 DOM 结构,它不会影响组件间的逻辑关系。也就是说,如果 <Teleport>
包含了一个组件,那么该组件始终和这个使用了 <Teleport>
的组件保持逻辑上的父子关系。传入的 props 和触发的事件也会照常工作。
这也意味着来自父组件的注入也会按预期工作,子组件将在 Vue Devtools 中嵌套在父级组件下面,而不是放在实际内容移动到的地方。
禁用 Teleport
在某些场景下可能需要视情况禁用 <Teleport>
。举例来说,我们想要在桌面端将一个组件当做浮层来渲染,但在移动端则当作行内组件。我们可以通过对 <Teleport>
动态地传入一个 disabled
prop 来处理这两种不同情况:
<Teleport :disabled="isMobile">
...
</Teleport>
然后我们可以动态地更新 isMobile
。
多个 Teleport 共享目标
一个可重用的 <Modal>
组件可能同时存在多个实例。对于此类场景,多个 <Teleport>
组件可以将其内容挂载在同一个目标元素上,而顺序就是简单的顺次追加,后挂载的将排在目标元素下更后面的位置上,但都在目标元素中。
比如下面这样的用例:
<Teleport to="#modals">
<div>A</div>
</Teleport>
<Teleport to="#modals">
<div>B</div>
</Teleport>
渲染的结果为:
<div id="modals">
<div>A</div>
<div>B</div>
</div>
延迟解析的 Teleport
在 Vue 3.5 及更高版本中,我们可以使用 defer
prop 推迟 Teleport 的目标解析,直到应用的其他部分挂载。这允许 Teleport 将由 Vue 渲染且位于组件树之后部分的容器元素作为目标:
<Teleport defer to="#late-div">...</Teleport>
<!-- 稍后出现于模板中的某处 -->
<div id="late-div"></div>
请注意,目标元素必须与 Teleport 在同一个挂载/更新周期内渲染,即如果 <div>
在一秒后才挂载,Teleport 仍然会报错。延迟 Teleport 的原理与 mounted
生命周期钩子类似。
总结
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Vue 3 Teleport 特性演示</title>
<script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
padding: 20px;
}
.container {
max-width: 800px;
margin: 0 auto;
background: white;
border-radius: 10px;
padding: 30px;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.2);
}
h1 {
text-align: center;
color: #667eea;
margin-bottom: 30px;
}
.demo-section {
margin-bottom: 30px;
padding: 20px;
border: 1px solid #e1e8ed;
border-radius: 8px;
background: #f8f9fa;
}
.demo-section h2 {
color: #333;
margin-bottom: 15px;
}
.demo-section p {
color: #666;
margin-bottom: 15px;
line-height: 1.6;
}
.btn {
background: #667eea;
color: white;
border: none;
padding: 10px 20px;
border-radius: 5px;
cursor: pointer;
margin: 5px;
transition: background 0.3s;
}
.btn:hover {
background: #5a67d8;
}
.btn-danger {
background: #e53e3e;
}
.btn-danger:hover {
background: #c53030;
}
.code-block {
background: #f1f5f9;
padding: 15px;
border-radius: 5px;
font-family: 'Courier New', monospace;
font-size: 14px;
margin: 10px 0;
border-left: 4px solid #667eea;
white-space: pre-line;
}
/* 模态框样式 */
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
justify-content: center;
align-items: center;
z-index: 1000;
}
.modal {
background: white;
border-radius: 8px;
padding: 20px;
max-width: 400px;
width: 90%;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.3);
}
/* 共享目标容器 */
.shared-container {
position: fixed;
top: 20px;
right: 20px;
width: 300px;
background: #2d3748;
color: white;
border-radius: 8px;
padding: 15px;
box-shadow: 0 5px 15px rgba(0, 0, 0, 0.3);
z-index: 999;
}
.shared-item {
background: rgba(255, 255, 255, 0.1);
padding: 10px;
margin: 8px 0;
border-radius: 5px;
font-size: 14px;
}
/* 延迟目标容器 */
.deferred-target {
margin-top: 20px;
padding: 15px;
background: #e6fffa;
border: 2px solid #38b2ac;
border-radius: 8px;
min-height: 60px;
}
.deferred-content {
background: #bee3f8;
padding: 10px;
border-radius: 5px;
margin: 10px 0;
}
.status {
display: inline-block;
padding: 4px 8px;
border-radius: 12px;
font-size: 12px;
margin-left: 10px;
}
.status.enabled {
background: #c6f6d5;
color: #22543d;
}
.status.disabled {
background: #fed7d7;
color: #742a2a;
}
.disabled-content {
margin-top: 15px;
padding: 15px;
background: #fff3cd;
border: 1px solid #ffc107;
border-radius: 5px;
}
</style>
</head>
<body>
<div id="app">
<div class="container">
<h1>Vue 3 Teleport 特性演示</h1>
<!-- 1. 基础 Teleport -->
<div class="demo-section">
<h2>1. 基础 Teleport</h2>
<p>将模态框传送到 body 元素,脱离当前组件的 DOM 层级</p>
<div class="code-block"><Teleport to="body">
<div v-if="showModal" class="modal-overlay">
<div class="modal">模态框内容</div>
</div>
</Teleport></div>
<button class="btn" @click="showBasicModal = true">打开模态框</button>
</div>
<!-- 2. 禁用 Teleport -->
<div class="demo-section">
<h2>2. 禁用 Teleport</h2>
<p>使用 <code>:disabled</code> 属性控制是否启用传送功能</p>
<div class="code-block"><Teleport to="body" :disabled="isDisabled">
<div v-if="showModal">内容</div>
</Teleport></div>
<button class="btn" @click="showDisabledModal = true">打开模态框</button>
<button class="btn" @click="isDisabled = !isDisabled">
{{ isDisabled ? '启用' : '禁用' }} Teleport
</button>
<span class="status" :class="isDisabled ? 'disabled' : 'enabled'">
{{ isDisabled ? '已禁用' : '已启用' }}
</span>
<!-- 禁用时显示在原位置 -->
<div v-if="showDisabledModal && isDisabled" class="disabled-content">
<h4>禁用状态下的内容</h4>
<p>当 Teleport 被禁用时,内容会保留在原始位置而不是传送到目标位置</p>
<button class="btn btn-danger" @click="showDisabledModal = false">关闭</button>
</div>
</div>
<!-- 3. 多个 Teleport 共享目标 -->
<div class="demo-section">
<h2>3. 多个 Teleport 共享目标</h2>
<p>多个 Teleport 组件可以将内容挂载到同一个目标元素,内容会按顺序追加</p>
<div class="code-block"><!-- 第一个 Teleport -->
<Teleport to="#modals">
<div>A</div>
</Teleport>
<!-- 第二个 Teleport -->
<Teleport to="#modals">
<div>B</div>
</Teleport></div>
<button class="btn" @click="showItemA = !showItemA">
{{ showItemA ? '隐藏' : '显示' }} 项目 A
</button>
<button class="btn" @click="showItemB = !showItemB">
{{ showItemB ? '隐藏' : '显示' }} 项目 B
</button>
<button class="btn" @click="showItemC = !showItemC">
{{ showItemC ? '隐藏' : '显示' }} 项目 C
</button>
<p style="font-size: 14px; color: #666; margin-top: 10px;">
查看右上角的共享容器,内容会按照挂载顺序依次追加
</p>
</div>
</div>
<!-- 基础模态框 Teleport -->
<Teleport to="body">
<div v-if="showBasicModal" class="modal-overlay" @click="showBasicModal = false">
<div class="modal" @click.stop>
<h3 style="margin-bottom: 15px;">基础 Teleport 模态框</h3>
<p>这个模态框被传送到 body 元素,脱离了当前组件的 DOM 层级</p>
<button class="btn btn-danger" @click="showBasicModal = false">关闭</button>
</div>
</div>
</Teleport>
<!-- 可禁用的模态框 Teleport -->
<Teleport to="body" :disabled="isDisabled">
<div v-if="showDisabledModal && !isDisabled" class="modal-overlay" @click="showDisabledModal = false">
<div class="modal" @click.stop>
<h3 style="margin-bottom: 15px;">可禁用 Teleport 模态框</h3>
<p>当前 Teleport 状态:{{ isDisabled ? '已禁用' : '已启用' }}</p>
<button class="btn btn-danger" @click="showDisabledModal = false">关闭</button>
</div>
</div>
</Teleport>
<!-- 多个 Teleport 共享同一个目标 -->
<Teleport to="#shared-target">
<div v-if="showItemA" class="shared-item">
<strong>项目 A</strong> - 第一个 Teleport 组件的内容
</div>
</Teleport>
<Teleport to="#shared-target">
<div v-if="showItemB" class="shared-item">
<strong>项目 B</strong> - 第二个 Teleport 组件的内容
</div>
</Teleport>
<Teleport to="#shared-target">
<div v-if="showItemC" class="shared-item">
<strong>项目 C</strong> - 第三个 Teleport 组件的内容
</div>
</Teleport>
</div>
<!-- 共享目标容器 (在 Vue 应用外部) -->
<div id="shared-target" class="shared-container">
<h3 style="margin-bottom: 15px; text-align: center;">共享目标容器</h3>
<p style="font-size: 13px; opacity: 0.8; margin-bottom: 10px;">
多个 Teleport 的内容会按挂载顺序依次追加到这里
</p>
</div>
<script>
const { createApp, ref } = Vue
createApp({
setup() {
// 基础模态框
const showBasicModal = ref(false)
// 可禁用的模态框
const showDisabledModal = ref(false)
const isDisabled = ref(false)
// 共享目标项目
const showItemA = ref(false)
const showItemB = ref(false)
const showItemC = ref(false)
return {
showBasicModal,
showDisabledModal,
isDisabled,
showItemA,
showItemB,
showItemC,
}
}
}).mount('#app')
</script>
</body>
</html>