vue3学习笔记之传送门Teleport

2,737 阅读5分钟

传送门Teleport

本文主要包含以下内容:

  1. Teleport 是什么?为什么需要使用Teleport
  2. 经典案例: 包含全屏模式的组件
  3. Teleport 使用详解

(一) 为什么需要使用Teleport

Teleport 传送门组件提供一种简洁的方式可以指定它里面内容的父元素。允许我们控制Teleport的嵌套的内容在DOM中哪个父节点下呈现HTML,而不必求助于全局状态或者拆为两个组件

使用场景:像modal 模态框这样的组件,我们一般会将它完全的和我们的vue应用的DOM 完全剥离, 管理起来反而容易

原因在于模态框的 position:absolute 以父级相对定位的 div 作为引用,若模态框是在深层嵌套的div中渲染,那么处理嵌套组件的定位,z-index和样式就会变得比较困难

这就是Teleport 解决的问题,使用Teleport 组件,可以让我们在组件的逻辑位置写模板代码,可以使用组件的data或者props状态,然后在组件的范围之外渲染它

(二)经典案例:包含全屏模式的组件

<template>
  <div>
    <button @click="modelOpen = true">点击打开弹窗	</button>
    <teleport to="body">
      <div v-if="modelOpen" class="model">
        <div class="model-body">
          这是一个模态框
          <button @click="modelOpen = false">关闭弹窗</button>
        </div>
      </div>
    </teleport>
  </div>
</template>
<script>
  import { defineComponent, ref } from "vue";

  export default defineComponent({
    name: 'ModelButton',
    setup() {
      const modelOpen = ref(false);
      return {
        modelOpen
      }
    }
  })
</script>
<style scoped>
  .model {
    position: absolute;
    left: 0;
    top: 0;
    right: 0;
    bottom: 0;
    background: rgba(0, 0, 0, .3);
    display: flex;
    align-items: center;
    justify-content: center;
  }

  .model-body {
    width: 300px;
    height: 250px;
    background: #fff;
  }
</style>

使用teleport 组件,通过props to 属性指定该组件的渲染位置在body下,但该组件是modelOpen 状态则由vue 内部组件控制

image-20210410224936353.png (三)Teleport 使用详解

  • Props:

    • to - string, 必传属性,必须为有效的查询选择器或者HTMLElement(如果在浏览器环境中使用),指定组件嵌套内容移动到所在位置下的目标的元素

      <!-- 正确 -->
      <teleport to="#some-id" />
      <teleport to=".some-class" />
      <teleport to="[data-teleport]" />
      
      <!-- 错误 -->
      <teleport to="h1" />
      <teleport to="some-string" />
      
    • disabled - boolean, 为可选属性,用于禁用<teleport> 的功能,意味着起插槽的内容将不会移动到任何位置,而是在父组件中指定了<teleport> 的位置渲染

      以上面的全屏组件为例:

       <template>
        <div>
          <button @click="modelOpen = true">点击打开弹窗</button>
          <teleport to="body" disabled>
            <div v-if="modelOpen" class="model">
              <div class="model-body">
                这是一个模态框
                <button @click="modelOpen = false">关闭弹窗</button>
              </div>
            </div>
          </teleport>
        </div>
      </template>
      

      实际效果如下:

image-20210410230935091.png

注意点:

<teleport to="#popup" :disabled="displayVideoInline">
  <video src="./my-movie.mp4">
</teleport>

请注意,这将移动实际的 DOM 节点,而不是被销毁和重新创建,并且它还将保持任何组件实例的活动状态。所有有状态的 HTML 元素 (即播放的视频) 都将保持其状态。

  • Vue component 一起使用

    如果<teleport> 包含 vue 组件,则它仍将是 <teleport> 父组件的逻辑子组件

    以一个 modal 组件为例:

    index.html
    <div id="app"></div> 
     <div id="modal-container"></div>
    
    vue内部组件
     <teleport to="#modal-container">
        <!-- use the modal component, pass in the prop -->
        <modal :show="showModal" @close="showModal = false">
           <template #header>
            <h3>custom header</h3>
          </template>
        </modal>
      </teleport>
    
    js核心代码
    
    import { defineComponent, ref } from 'vue';
    import Modal from './Modal.vue';
    export default defineComponent({
      components: {
        Modal
      },
      setup() {
        // modal 的封装
        const showModal = ref(false);
        return {
          showModal
        }
      }
    })
    

    在这种情况下,即使在不同的地方渲染 Modal,它仍将是当前组件(调用 Modal 的组件)的子级,并将从中接收 show prop

    这也意味着来自父组件的注入按预期工作,并且子组件将嵌套在 Vue Devtools 中的父组件之下,而不是放在实际内容移动到的位置

    看实际效果以及在 Vue Devtool

291e87ce22414512b4e9f9f828843b0d_tplv-k3u1fbpfcp-zoom-1.gif

思考
  1. teleport 取代vue2中的portal-vue插件,然而,实践发现,teleport 的目标元素不能由组件本身呈现,只能移植到组件外部有效

    const app = {
      template: `
      <div>
        <h1>App</h1>
          <!-- Teleport目标元素 -->
        <div id="dest"></div>
          <!-- comp 包含Teleport -->
        <comp />
      </div>`
    }
    
    
    Vue.createApp(app).component('comp', {
      template: `
      <div>
        A component
        <Teleport to="#dest">
          Hello From Portal
        </Teleport>
      </div>`
    }).mount('#app')
    
    <script src="https://unpkg.com/vue@3.0.0-rc.9/dist/vue.global.js"></script>
    <div id="app"></div>
    

    页面显示效果如下,Teleport 移植的内容并没有渲染到对应的位置,并且控制台告警

image-20210414123454014.png

警告信息:目标元素必须在组件挂载之前存在——也就是说,目标不能由组件本身呈现,理想情况下应该在整个Vue组件树之外。

[Vue warn]: Failed to locate Teleport target with selector "#dest". Note the target element must exist before the component is mounted - i.e. the target cannot be rendered by the component itself, and ideally should be outside of the entire Vue component tree. 

总结:teleport的目标元素若是在直接引用包含teleport的组件的父组件上,则无效,并会告警,移植到组件外部有效

对于这种情况的解决方案:

方案一: 添加挂载判断

const app = {
  template: `
  <div>
    <h1>App</h1>
    <div id="dest"></div>
    <comp />
  </div>`
}

Vue.createApp(app).component('comp', {
  template: `
  <div>
    A component
    <Teleport to="#dest" v-if="isMounted">
      Hello From Portal
    </Teleport>
  </div>`,
  data: function(){
    return { 
        isMounted: false
    }
  },
  mounted(){
    this.isMounted = true
  }
}).mount('#app')

方案二:与方案一本质上是一致的,目标元素挂载后再启用teleport

 const useTele = () => {
    const target = ref(null)
    return () => target
  }

  const useThisTele = useTele()

  createApp({
    setup() {
      return { target: useThisTele() }
    },
    template: `
      <div>
      <h1>App</h1>
      <div id="dest" :ref="d => target = d"></div>
      <parent-comp/>
      </div>`
  }).component('parent-comp', {
    template: `
  <div>
    <child-comp/>
  </div>`
  }).component('child-comp', {
    setup() {
      return { target: useThisTele() }
    },
    template: `
      <div>
      <Teleport :to="target" :disabled="!target">
        Hello From Portal
      </Teleport>
      </div>`
  }).mount('#app')

另外,对于teleport 这个特性,依据贡献者**LinusBorg** 的说法,还是有些脆,若是将目标元素的父元素从DOM上移除,portal内容也会随之从DOm移除,然而,源组件并不会收到通知,在它的vdom中,它假定元素仍然在DOM中。当源组件稍后进行更新时,这可能会导致更新错误。

image-20210414142809647.png

  1. 在上述的实践还发现了一个问题,在vite构建的项目中,若在main.js 写入下面这段代码会告警

    const app = {
      template: `
      <div>
        <h1>App</h1>
          <!-- Teleport目标元素 -->
        <div id="dest"></div>
          <!-- comp 包含Teleport -->
        <comp />
      </div>`
    }
    
    
    createApp(app).component('comp', {
      template: `
      <div>
        A component
        <Teleport to="#dest">
          Hello From Portal
        </Teleport>
      </div>`
    }).mount('#app')
    

    告警信息:组件提供模板选项,但是在Vue的这个构建中不支持运行时编译,配置你的bundler别名 vue: vue/dist/vue.esm-bundler.js

    runtime-core.esm-bundler.js:38 [Vue warn]: Component provided template option but runtime compilation is not supported in this build of Vue. Configure your bundler to alias "vue" to "vue/dist/vue.esm-bundler.js". 
      at <App>
    

    原因:

    开发环境下:

    • 如果是vue2的话,需要依赖构建工具,如webpack, glup 等, 流程是 先使用对应的构建工具来进行构建编译生成一个一个的bundle, 然后才是运行。
    • 如果是vue3的话,有两种方式,一种是沿用vue2的开发模式,另一种是 使用 vite这个构建工具,流程是 基于现代浏览器的特点, 先查找相关的引用,然后在编译,在运行

    解决:

    vue3

    • 使用vite 构建: 项目根目录下面建立 vite.config.js配置别名

      alias: {
        'vue': 'vue/dist/vue.esm-bundler.js' // 定义vue的别名,如果使用其他的插件,可能会用到别名
      },
      
    • 使用vue-cli 进行构建,项目根目录下面建立 vue.config.js 配置一个属性

      module.exports = { runtimeCompiler: true } // 确定是运行时候编译
      

    vue2 项目中建立对应的vue.config.js

    module.exports = {
        // ...
        resolve: {
        alias: {
          'vue$': 'vue/dist/vue.esm.js' // 用 webpack 1 时需用 'vue/dist/vue.common.js'
        }
        }
    }
    

    参考:github.com/vuejs/vue-n…