Code前端首页关于Code前端联系我们

Vue3里的Teleport是啥?怎么用它解决弹窗遮罩层级bug这类头疼问题?

terry 11小时前 阅读数 163 #Vue

刚上手Vue3时,很多人第一个留意到的新增特性除了Composition API,大概率就是Teleport了——毕竟这名字听着就挺玄乎,像能把组件“瞬间移动”到别的地方似的,但实际写业务的时候,光知道名字没用,得搞懂它到底能解决啥以前解决起来费劲的问题,具体怎么写才不踩坑,有没有什么高级用法或者注意事项?今天咱们就掰开揉碎了聊这些事儿,从最基础的概念到真实的项目踩坑经验,全给你整明白。

为什么Vue2时期弹窗、tooltip这类组件总出层级问题?

聊Teleport之前,先得回忆回忆Vue2里让人挠头的痛点,不然根本不知道它为什么被设计出来——很多新增特性都不是凭空蹦的,都是为了填旧版本或者开发时的大坑。

咱们做前端,肯定少不了写各种浮层类组件:比如电商页的商品详情页弹窗、顶部导航的下拉菜单、手机APP的底部操作面板、鼠标悬停显示的商品参数tooltip对吧?Vue2时期写这类组件,最常遇到的不是样式写不对,就是z-index层级控不住,要么是被某个更外层的兄弟组件或者嵌套很深的父组件盖住,要么是自己不小心把整个页面的层级体系搞乱了,调个z-index调半小时,还容易留下“定时炸弹”——比如今天加个广告弹窗z-index设了9999,明天加个活动弹窗又设10000,后天忘了这个规律,直接被外层组件overflow:hidden给“截胡”了,哭都来不及。

那为什么会出现这些问题呢?核心原因有两个,都是Vue2组件的渲染机制导致的: 第一个原因是Vue组件的渲染绑定在父节点的DOM树里,比如你在一个嵌套了三层div的商品卡片里写了一个“快速购买”的弹窗组件,Vue2会把这个弹窗的HTML直接插到那三层div的最下面,也就是商品卡片内部,如果这时候商品卡片的父容器或者祖先容器加了overflow:hidden/scroll/xscroll,那弹窗稍微超出卡片一点,就会被截断——尤其是做全屏遮罩的时候,本来想把整个页面盖住,结果只盖住了商品卡片那一块,完全没法用。 第二个原因是CSS层叠上下文的嵌套特性,咱们都知道z-index不是绝对的数值大小,而是要看所在的层叠上下文对吧?如果那个商品卡片或者它的祖先容器因为设置了transform: translate3d(0,0,0)、opacity: 0.5、position: fixed/absolute/sticky(并且z-index不是auto)这些属性,创建了自己的层叠上下文,那你就算把弹窗的z-index设到99999999,它也只能在这个祖先层叠上下文里“卷”,永远盖不住外面层级更低但属于外层根层叠上下文的元素,比如很多老项目用CSS3做平滑滚动,给body加了transform: translateZ(0),那整个body就成了一个巨大的层叠上下文,嵌套在里面的所有组件z-index再高,也盖不住body外的东西——不过body外本来就没多少东西,但如果是外层某个同级的广告位创建了自己的层叠上下文,z-index设成10,嵌套在商品卡片(层叠上下文z-index设成5)里的弹窗就算z-index是9999,也还是会被广告位盖住。

这两个问题,Vue2时期有没有办法解决?有,但都是“野路子”,要么不够优雅,要么有性能问题,要么有兼容性问题: 野路子1:把浮层组件直接挂载到根节点上,比如在main.js里创建一个Vue实例挂载到#app之外的#modal-container,然后用event bus或者Vuex来控制这个浮层的显示隐藏、内容传递,这个办法能解决层级和截断的问题,但问题也很多:组件的逻辑和样式完全和使用它的父组件割裂了,没法用props直接传数据,也没法用emit直接接收事件,得靠event bus或者状态管理,代码耦合度高,维护起来超级麻烦;要手动处理浮层组件的挂载、卸载,不然容易内存泄漏;Composition API出来之前还好,出来之后想把浮层的逻辑封装成hook,跨实例用就更费劲了。 野路子2:暴力修改CSS,比如给所有浮层祖先容器都加overflow: visible(这显然不可能,很多列表、卡片必须要截断),或者把祖先容器的transform、opacity这些属性去掉(更不可能,页面的动画和效果全靠这些),或者硬着头皮一层一层往上查层叠上下文,把所有能冲突的z-index都调一遍(费时费力,还容易留坑)。

所以你看,Vue2时期浮层组件的问题真的是“刚需痛点”,必须有一个官方的、优雅的、高效的解决方案——Teleport就是干这个的。

Teleport到底是什么?官方定义是怎么说的?

Teleport这个单词直译过来是“瞬间移动”“心灵传输”,很形象对吧?它的官方定义就是:一个内置组件,能把它包裹的内容“瞬间移动”到Vue应用之外或者指定的DOM节点里,但逻辑上仍然属于原来的父组件,能正常接收props、emit事件、使用父组件的provide/inject,甚至能共享父组件的样式(只要你用的是scoped之外的全局样式或者Teleport内部单独写了样式)

划重点哈,这个定义里有三个关键点,一定要记牢:

  1. 移动的是渲染后的DOM内容,不是组件的逻辑和状态,这是Teleport最核心的优势,也是它和“野路子1”最本质的区别——逻辑、状态、props、emit、provide/inject、生命周期,全都是和原来的父组件绑定的,你不用写一行额外的通信代码,该怎么用还是怎么用,只是视觉上DOM不在原来的位置了。
  2. 可以指定移动到任意已经存在的DOM节点,这个DOM节点可以是Vue应用之外的(比如index.html里#app旁边的#modal-root、#tooltip-root),也可以是Vue应用之内但层级更高、没有overflow截断、不会乱创建层叠上下文的节点(app的直接子节点)。
  3. Vue3的Composition API完全兼容Teleport,你可以把浮层的逻辑封装成一个useModal的hook,然后在任何组件里直接用这个hook生成Teleport包裹的浮层,超级方便,代码复用性直接拉满。

Teleport的基本语法是什么?写一个简单的全屏遮罩弹窗试试

Teleport的基本语法超级简单,比你写一个普通组件还要简单——它只有两个核心属性:todisabled

to属性是必填的,用来指定要把包裹的内容移动到哪个DOM节点里,它的值可以是:

  • 一个CSS选择器字符串,比如"#modal-root"".tooltip-container"
  • 一个原生的DOM元素引用,比如document.getElementById("modal-root")——不过这个一般用得少,因为SSR的时候document不存在,容易报错,所以推荐用CSS选择器。

disabled属性是可选的,布尔类型,默认是false,用来控制是否开启“瞬间移动”:如果是true,包裹的内容就不会移动,会像普通插槽一样渲染在原来的父节点里;如果是false,就会移动到to指定的节点里,这个属性在做SSR、或者需要在某些特定条件下让浮层留在原来位置的时候很有用。

好,咱们现在写一个最简单的全屏遮罩弹窗,来感受一下Teleport的威力: 咱们要在index.html里加一个专门用来放浮层的容器,这个容器要放在#app的外面,或者#app的最上面,这样才能保证它不会被overflow截断,也不会随便被别的层叠上下文盖住:

<!DOCTYPE html>
<html lang="zh-CN">
  <head>
    <meta charset="UTF-8" />
    <link rel="icon" type="image/svg+xml" href="/vite.svg" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />Vue3 Teleport 演示</title>
  </head>
  <body>
    <!-- 专门放浮层的根容器 -->
    <div id="overlay-root"></div>
    <!-- Vue应用根容器 -->
    <div id="app"></div>
    <script type="module" src="/src/main.js"></script>
  </body>
</html>

咱们写一个简单的ProductCard.vue组件,里面有一个“快速购买”按钮,点击之后弹出一个全屏遮罩的快速购买弹窗,这个弹窗就用Teleport包裹:

<template>
  <div class="product-card">
    <!-- 这里加一个transform: translateZ(0),创建一个层叠上下文,模拟Vue2时期的坑 -->
    <div class="product-card-inner" style="transform: translateZ(0)">
      <img :src="product.image" :alt="product.name" class="product-image" />
      <h3 class="product-name">{{ product.name }}</h3>
      <p class="product-price">¥{{ product.price }}</p>
      <button class="buy-btn" @click="showModal = true">快速购买</button>
    </div>
    <!-- 用Teleport包裹的全屏遮罩弹窗 -->
    <Teleport to="#overlay-root">
      <!-- 这里用v-if控制显示隐藏,v-show也可以,但v-if更省内存 -->
      <div v-if="showModal" class="overlay-container" @click.self="showModal = false">
        <div class="quick-buy-modal">
          <button class="close-btn" @click="showModal = false">×</button>
          <h2>快速购买 {{ product.name }}</h2>
          <p>价格:¥{{ product.price }}</p>
          <p>数量:<input type="number" v-model="quantity" min="1" max="99" /></p>
          <button class="confirm-btn" @click="handleConfirmBuy">确认购买</button>
        </div>
      </div>
    </Teleport>
  </div>
</template>
<script setup>
import { ref } from 'vue'
// 模拟商品数据
const product = ref({
  id: 1,
  name: 'Vue3实战开发指南',
  price: 99.00,
  image: 'https://picsum.photos/200/300'
})
// 控制弹窗显示隐藏的状态
const showModal = ref(false)
// 购买数量
const quantity = ref(1)
// 确认购买的逻辑
const handleConfirmBuy = () => {
  alert(`成功购买 ${quantity.value} 本《${product.value.name}》!`)
  showModal.value = false
}
</script>
<style scoped>
/* 商品卡片的样式 */
.product-card {
  width: 220px;
  margin: 50px auto;
  border-radius: 10px;
  box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
  padding: 15px;
}
.product-card-inner {
  overflow: hidden; /* 这里再加一个overflow: hidden,彻底模拟Vue2时期的双重坑 */
}
.product-image {
  width: 100%;
  height: 200px;
  object-fit: cover;
  border-radius: 5px;
}
.product-name {
  font-size: 16px;
  margin: 10px 0 5px;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}
.product-price {
  font-size: 18px;
  color: #e53935;
  font-weight: bold;
  margin: 0 0 10px;
}
.buy-btn {
  width: 100%;
  padding: 8px 0;
  background-color: #42b883;
  color: white;
  border: none;
  border-radius: 5px;
  cursor: pointer;
  font-size: 14px;
  transition: background-color 0.2s;
}
.buy-btn:hover {
  background-color: #35a06c;
}
</style>
<!-- 注意:浮层的样式不能加scoped,因为Teleport移动后的DOM不属于product-card的作用域了 -->
<style>
/* 全屏遮罩容器的样式 */
.overlay-container {
  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; /* 这里只需要设一个合理的z-index,不用和别人卷 */
}
/* 快速购买弹窗的样式 */
.quick-buy-modal {
  width: 400px;
  background-color: white;
  border-radius: 10px;
  padding: 20px;
  position: relative;
}
.close-btn {
  position: absolute;
  top: 10px;
  right: 15px;
  font-size: 24px;
  color: #999;
  border: none;
  background: none;
  cursor: pointer;
  transition: color 0.2s;
}
.close-btn:hover {
  color: #333;
}
.quick-buy-modal h2 {
  font-size: 20px;
  margin-top: 0;
  margin-bottom: 15px;
}
.quick-buy-modal p {
  font-size: 16px;
  margin: 10px 0;
}
.quick-buy-modal input[type="number"] {
  width: 80px;
  padding: 5px;
  border: 1px solid #ddd;
  border-radius: 3px;
  font-size: 14px;
}
.confirm-btn {
  width: 100%;
  padding: 10px 0;
  margin-top: 15px;
  background-color: #42b883;
  color: white;
  border: none;
  border-radius: 5px;
  cursor: pointer;
  font-size: 16px;
  transition: background-color 0.2s;
}
.confirm-btn:hover {
  background-color: #35a06c;
}
</style>

咱们现在运行这个代码,点击“快速购买”按钮,会发现什么? 第一,虽然product-card-inner加了transform: translateZ(0)overflow: hidden,但弹窗完全没有被截断,也没有被盖在商品卡片下面,而是正常显示在整个页面的最上面——这就是Teleport的威力! 第二,弹窗的逻辑完全和ProductCard绑定:product的数据直接从组件里拿,showModal状态直接在组件里控制,点击确认购买之后的handleConfirmBuy也是组件里的方法——和写在普通插槽里的组件一模一样,没有任何区别! 第三,咱们看一下浏览器的Elements面板,会发现Teleport包裹的内容确实被插到了#overlay-root里,而不是product-card-inner的下面——太神奇了!

Teleport的常用场景有哪些?除了弹窗还能用来干嘛?

很多人以为Teleport只能用来写全屏遮罩弹窗,其实不然——它的常用场景非常多,只要是需要让视觉DOM脱离当前父节点的布局和层叠上下文约束,但逻辑上仍然属于当前父组件的情况,都可以用Teleport,咱们今天说几个最常用的:

全屏遮罩类组件

这个刚才已经演示过了,是最常用的场景,

  • 商品详情页弹窗、登录注册弹窗、活动规则弹窗;
  • 图片预览组件、视频全屏播放组件;
  • 全局加载动画、全局错误提示组件;
  • 表单提交的确认对话框、删除操作的二次确认对话框。

定位类浮层组件

这类组件不需要全屏,但需要精准定位到某个触发元素的旁边,而且不能被父节点的overflow截断,

  • 鼠标悬停显示的商品参数tooltip、按钮的功能说明tooltip;
  • 顶部导航的下拉菜单、侧边栏的展开面板;
  • 日期选择器、时间选择器、下拉选择框的下拉列表;
  • 右键菜单组件。

这里要注意一点,tooltip、下拉菜单这类定位类浮层组件,虽然用Teleport把DOM移走了,但定位还是要相对于触发元素来做的——这时候咱们可以用Vue3的useElementSize(来自VueUse,一个超级好用的Vue3 Composition API工具库)、useMouseInElement这些hook来获取触发元素的位置和大小,然后用position: fixed或者position: absolute相对于视口或者#overlay-root来定位浮层,这样就能既脱离父节点的约束,又能精准定位了。

SSR/SSG的兼容场景

在做SSR(服务端渲染)或者SSG(静态站点生成)的时候,Vue3会在服务端生成HTML,然后在客户端进行“ hydration(注水)”——也就是把服务端生成的HTML和Vue的组件实例绑定起来,这时候如果你的浮层组件在服务端生成时就试图移动到一个不存在的DOM节点里,就会报错——因为服务端没有document对象,也没有浏览器的DOM树。

这时候咱们可以用disabled属性来解决这个问题:在服务端渲染时,把disabled设为true,让浮层渲染在原来的父节点里;等客户端注水完成、DOM树完全加载之后,再把disabled设为false,让浮层移动到指定的节点里,咱们可以用onMounted这个生命周期hook来实现这个逻辑,因为onMounted只会在客户端执行,服务端不会执行:

<template>
  <Teleport to="#tooltip-root" :disabled="isServer">
    <div v-if="showTooltip" class="tooltip">这是一个兼容SSR的tooltip</div>
  </Teleport>
</template>
<script setup>
import { ref, onMounted } from 'vue'
// 初始值设为true,服务端渲染时不会移动
const isServer = ref(true)
// 控制tooltip显示隐藏的状态
const showTooltip = ref(false)
// 客户端挂载完成之后,把isServer设为false,开启瞬间移动
onMounted(() => {
  isServer.value = false
})
</script>

如果你用的是VueUse的useSSR或者useIsClient这些hook,会更简单:

<script setup>
import { useIsClient } from '@vueuse/core'
const isClient = useIsClient()
// 当客户端时disabled为false,服务端时为true
const teleportDisabled = computed(() => !isClient.value)
</script>

拆分复杂组件的场景

有时候咱们写的组件非常复杂,比如一个大型的表单组件,里面有很多需要动态插入到页面不同位置的内容——这时候咱们可以用Teleport把这些内容拆分出来,既保持了组件的逻辑完整性,又让页面的DOM结构更清晰,方便SEO和调试。

比如咱们写一个用户注册表单组件,里面有一个“头像上传预览”的功能——可以点击头像选择框弹出一个全屏的头像裁剪组件,裁剪完成之后再把头像显示在选择框里,这时候咱们就可以把头像裁剪组件用Teleport包裹,逻辑上还是属于注册表单组件,但DOM上是独立的,不会影响表单的布局。

Teleport有哪些注意事项?别踩这些坑

虽然Teleport很好用,但也不是万能的,有一些注意事项一定要记牢,不然很容易踩坑:

必须有且只有一个根节点(Vue3.3之前)

哦,不对,等一下——这个是Vue3.3之前的限制,Vue3.3版本之后已经支持Teleport包裹多个根节点了!但如果你用的是Vue3.3之前的版本,那Teleport包裹的内容必须有且只有一个根节点,否则会报错,比如下面这个写法在Vue3.3之前是错的:

<Teleport to="#modal-root">
  <div v-if="showModal" class="overlay"></div>
  <div v-if="showModal" class="modal"></div>
</Teleport>

在Vue3.3之前,必须把这两个div包在一个父div里:

<Teleport to="#modal-root">
  <div v-if="showModal" class="modal-wrapper">
    <div class="overlay"></div>
    <div class="modal"></div>
  </div>
</Teleport>

现在Vue3.3已经发布很久了,大部分项目应该都升级到Vue3.3以上了,但如果你的项目还在用老版本,一定要注意这个限制。

被包裹的内容不能用父组件的scoped样式

这个刚才的演示里也提到了——因为Teleport移动后的DOM不属于原来的父组件的作用域了,所以父组件里scoped样式的data-v-xxx属性不会加到Teleport包裹的DOM元素上,父组件的scoped样式也就不会生效。

那怎么解决这个问题呢?有三个办法: 办法1:把浮层的样式写在全局样式文件里,比如src/assets/css/global.css,然后在main.js里引入——这个办法适合所有浮层通用的样式,比如overlay-container的样式。 办法2:把浮层的样式写在Teleport所在组件的非scoped style标签里——这个办法适合特定浮层的样式,但要注意样式命名冲突的问题,最好用BEM命名法或者CSS Modules来避免冲突。 办法3:把浮层封装成一个单独的组件,然后用Teleport包裹这个单独的组件——这个办法是最推荐的,因为单独的组件可以有自己的scoped样式,而且代码复用性更高,维护起来更方便,咱们把刚才的快速购买弹窗封装成一个单独的QuickBuyModal.vue组件试试:

<!-- QuickBuyModal.vue -->
<template>
  <div v-if="visible" class="overlay-container" @click.self="$emit('close')">
    <div class="quick-buy-modal">
      <button class="close-btn" @click="$emit('close')">×</button>
      <h2>快速购买 {{ product.name }}</h2>
      <p>价格:¥{{ product.price }}</p>
      <p>数量:<input type="number" v-model="innerQuantity" min="1" max="99" /></p>
      <button class="confirm-btn" @click="handleConfirm">确认购买</button>
    </div>
  </div>
</template>
<script setup>
import { ref, watch } from 'vue'
// 接收props
const props = defineProps({
  visible: {
    type: Boolean,
    required: true
  },
  product: {
    type: Object,
    required: true
  }
})
// 发送事件
const emit = defineEmits(['close', 'confirm'])
// 内部的购买数量,避免直接修改props
const innerQuantity = ref(1)
// 当visible变为false时,重置购买数量
watch(() => props.visible, (newVal) => {
  if (!newVal) {
    innerQuantity.value = 1
  }
})
// 确认购买的逻辑
const handleConfirm = () => {
  emit('confirm', innerQuantity.value)
  emit('close')
}
</script>
<style scoped>
/* 单独组件的scoped样式,完全没问题 */
.overlay-container {
  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;
}
.quick-buy-modal {
  width: 400px;
  background-color: white;
  border-radius: 10px;
  padding: 20px;
  position: relative;
}
.close-btn {
  position: absolute;
  top: 10px;
  right: 15px;
  font-size: 24px;
  color: #999;
  border: none;
  background: none;
  cursor: pointer;
  transition: color 0.2s;
}
.close-btn:hover {
  color: #333;
}
.quick-buy-modal h2 {
  font-size: 20px;
  margin-top: 0;
  margin-bottom: 15px;
}
.quick-buy-modal p {
  font-size: 16px;
  margin: 10px 0;
}
.quick-buy-modal input[type="number"] {
  width: 80px;
  padding: 5px;
  border: 1px solid #ddd;
  border-radius: 3px;
  font-size: 14px;
}
.confirm-btn {
  width: 100%;
  padding: 10px 0;
  margin-top: 15px;
  background-color: #42b883;
  color: white;
  border: none;
  border-radius: 5px;
  cursor: pointer;
  font-size: 16px;
  transition: background-color 0.2s;
}
.confirm-btn:hover {
  background-color: #35a06c;
}
</style>

然后咱们在ProductCard.vue里直接用Teleport包裹这个单独的组件:

<template>
  <div class="product-card">
    <div class="product-card-inner" style="transform: translateZ(0); overflow: hidden;">
      <!-- 商品卡片的内容 -->
      <img :src="product.image" :alt="product.name" class="product-image" />
      <h3 class="product-name">{{ product.name }}</h3>
      <p class="product-price">¥{{ product.price }}</p>
      <button class="buy-btn" @click="showModal = true">快速购买</button>
    </div>
    <!-- 用Teleport包裹单独的QuickBuyModal组件 -->
    <Teleport to="#overlay-root">
      <QuickBuyModal
        :visible="showModal"
        :product="product"
        @close="showModal = false"
        @confirm="handleConfirmBuy"
      />
    </Teleport>
  </div>
</template>
<script setup>
import { ref } from 'vue'
// 引入单独的QuickBuyModal组件
import QuickBuyModal from './QuickBuyModal.vue'
// 商品数据
const product = ref({
  id: 1,
  name: 'Vue3实战开发指南',
  price: 99.00,
  image: 'https://picsum.photos/200/300'
})
// 控制弹窗显示隐藏的状态
const showModal = ref(false)
// 确认购买的逻辑
const handleConfirmBuy = (quantity) => {
  alert(`成功购买 ${quantity} 本《${product.value.name}》!`)
}
</script>
<style scoped>
/* 商品卡片的样式,还是scoped的,完全没问题 */
.product-card {
  width: 220px;
  margin: 50px auto;
  border-radius: 10px;
  box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
  padding: 15px;
}
.product-image {
  width: 100%;
  height: 200px;
  object-fit: cover;
  border-radius: 5px;
}
.product-name {
  font-size: 16px;
  margin: 10px 0 5px;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}
.product-price {
  font-size: 18px;
  color: #e53935;
  font-weight: bold;
  margin: 0 0 10px;
}
.buy-btn {
  width: 100%;
  padding: 8px 0;
  background-color: #42b883;
  color: white;
  border: none;
  border-radius: 5px;
  cursor: pointer;
  font-size: 14px;
  transition: background-color 0.2s;
}
.buy-btn:hover {
  background-color: #35a06c;
}
</style>

这样写是不是更优雅了?代码复用性更高,样式也不会冲突,维护起来也更方便——强烈推荐大家用这种办法!

要确保to指定的DOM节点在Teleport渲染之前就已经存在

这个也是一个很容易踩的坑——如果to指定的DOM节点在Teleport渲染的时候还不存在,那Teleport就会报错,或者不会生效。

那怎么确保DOM节点在Teleport渲染之前就存在呢?有两个办法: 办法1:把指定的DOM节点直接写在index.html里——这个是最常用的办法,刚才的演示里就是这么做的,因为index.html里的DOM节点会在Vue应用挂载之前就加载完成。 办法2:在Vue应用挂载之后、Teleport所在组件挂载之前,动态创建指定的DOM节点——这个办法适合不想修改index.html的情况,咱们可以在main.js里或者App.vue的onBeforeMount生命周期hook里动态创建DOM节点:

// main.js
import { createApp } from 'vue'
import App from './App.vue'
// 动态创建#overlay-root节点
const overlayRoot = document.createElement('div')
overlayRoot.id = 'overlay-root'
document.body.appendChild(overlayRoot)
createApp(App).mount('#app')
<!-- App.vue -->
<script setup>
import { onBeforeMount } from 'vue'
onBeforeMount(() => {
  // overlay-root不存在,就动态创建一个
  if (!document.getElementById('overlay-root')) {
    const overlayRoot = document.createElement('div')
    overlayRoot.id = 'overlay-root'
    document.body.appendChild(overlayRoot)
  }
})
</script>

这两个办法都可以,大家根据自己的项目情况选择就行。

多个Teleport可以移动到同一个DOM节点里

很多人担心如果多个Teleport都移动到同一个DOM节点里,会不会出现覆盖的问题?不会的——多个Teleport移动到同一个DOM节点里之后,它们的内容会按照渲染顺序依次排列在这个节点里,就像普通的兄弟组件一样,比如你在ProductCard1和ProductCard2里都用Teleport包裹了QuickBuyModal,点击ProductCard1的快速购买按钮,会显示ProductCard1的弹窗;点击ProductCard2的快速购买按钮,会显示ProductCard2的弹窗;如果两个弹窗同时显示,它们会按顺序叠在一起,你可以通过控制z-index来调整它们的层级。

Teleport不会影响组件的生命周期

刚才咱们也提到了,Teleport移动的只是渲染后的DOM内容,不是组件的逻辑和状态——所以Teleport所在的组件和它包裹的组件的生命周期,完全和没有Teleport的时候一样:Teleport所在的组件挂载时,包裹的组件也会挂载(如果v-if/v-show为true的话);Teleport所在的组件卸载时,包裹的组件也会卸载;包裹的组件的onMounted、onUnmounted这些生命周期hook,也会正常执行。

有没有Teleport的高级用法?比如用动态组件或者render函数

当然有!Teleport非常灵活,不仅可以包裹普通的模板内容,还可以包裹动态组件、甚至可以用render函数来写,咱们今天说两个比较常用的高级用法:

用Teleport包裹动态组件

如果你有多个不同的浮层组件,比如QuickBuyModal、LoginModal、ActivityRuleModal,你可以用动态组件<component :is="currentModal">来包裹它们,然后用Teleport把动态组件移到指定的DOM节点里,这样就不用写多个Teleport了,代码更简洁:

<template>
  <div class="home-page">
    <button @click="currentModal = 'QuickBuy'">快速购买</button>
    <button @click="currentModal = 'Login'">登录</button>
    <button @click="currentModal = 'ActivityRule'">活动规则</button>
    <Teleport to="#modal-root">
      <component
        :is="currentModalComponent"
        v-if="currentModal"
        :visible="!!currentModal"
        @close="currentModal = null"
        v-bind="modalProps"
      />
    </Teleport>
  </div>
</template>
<script setup>
import { ref, computed, shallowRef } from 'vue'
// 引入所有的浮层组件
import QuickBuyModal from './QuickBuyModal.vue'
import LoginModal from './LoginModal.vue'
import ActivityRuleModal from './ActivityRuleModal.vue'
// 用shallowRef来引入组件,避免不必要的响应式追踪,提高性能
const modalComponents = shallowRef({
  QuickBuy: QuickBuyModal,
  Login: LoginModal,
  ActivityRule: ActivityRuleModal
})
// 当前显示的浮层名称
const currentModal = ref(null)
// 传递给浮层组件的props
const modalProps = ref({})
// 当前显示的浮层组件
const currentModalComponent = computed(() => {
  return currentModal.value ? modalComponents.value[currentModal.value] : null
})
// 模拟点击快速购买按钮时传递商品数据
const openQuickBuyModal = (product) => {
  modalProps.value = { product }
  currentModal.value = 'QuickBuy'
}
</script>

这里要注意一点,引入多个浮层组件的时候,最好用shallowRef或者markRaw来包裹组件对象,避免Vue对组件对象进行响应式追踪,因为组件对象本身不需要响应式,这样可以提高性能。

用render函数或者h函数来写Teleport

如果你喜欢用JSX或者render函数来写组件,也完全可以用h函数来创建Teleport——Vue3的Teleport也是一个普通的组件,所以可以用h函数来渲染:

// 用h函数写一个简单的全局错误提示组件
import { h, Teleport, ref, onMounted, onUnmounted } from 'vue'
export function useGlobalError() {
  const errorMessage = ref('')
  const showError = ref(false)
  let timer = null
  const openError = (message, duration = 3000) => {
    errorMessage.value = message
    showError.value = true
    clearTimeout(timer)
    timer = setTimeout(() => {
      showError.value = false
    }, duration)
  }
  const renderError = () => {
    if (!showError.value) return null
    return h(Teleport, { to: '#error-root' }, [
      h('div', {
        class: 'global-error',
        style: {
          position: 'fixed',
          top: '20px',
          left: '50%',
          transform: 'translateX(-50%)',
          backgroundColor: '#e53935',
          color: 'white',
          padding: '10px 20px',
          borderRadius: '5px',
          zIndex: '10000'
        }
      }, errorMessage.value)
    ])
  }
  // 动态创建#error-root节点
  onMounted(() => {
    if (!document.getElementById('error-root')) {
      const errorRoot = document.createElement('div')
      errorRoot.id = 'error-root'
      document.body.appendChild(errorRoot)
    }
  })
  // 卸载时清除定时器
  onUnmounted(() => {
    clearTimeout(timer)
  })
  return { openError, renderError }
}

然后你可以在任何组件里直接用这个hook:

<template>
  <div class="some-component">
    <button @click="openError('这是一个全局错误提示!')">触发错误</button>
    <!-- 渲染全局错误提示 -->
    <component :is="renderError" />
  </div>
</template>
<script setup>
import { useGlobalError } from './useGlobalError'
const { openError, renderError } = useGlobalError()
</script>

这个高级用法非常适合封装一些全局的、不需要模板的浮层组件,比如全局错误提示、全局成功提示、全局加载动画。

Teleport到底值不值得学?

当然值得学!Teleport是Vue3最实用的新增特性之一,它完美解决了Vue2时期浮层组件的层级和截断问题,而且语法超级简单,使用场景非常多,高级用法也很灵活——只要你做Vue3的项目,几乎肯定会用到它。

最后再给大家留一个小作业:用Teleport封装一个通用的tooltip组件,要求:

  1. 支持鼠标悬停和点击两种触发方式;
  2. 支持顶部、底部、左侧、右侧四种定位;
  3. 兼容SSR;
  4. 可以用props自定义样式和内容。

大家可以动手试试,做完之后会对Teleport有更深入的理解!

版权声明

本文仅代表作者观点,不代表Code前端网立场。
本文系作者Code前端网发表,如需转载,请注明页面地址。

热门