如何在Vue2中实现Teleport组件

2,197 阅读3分钟

前言

Teleport是Vue3中的内建组件,它能够将它的children挂载到指定的目标节点上。通常在写modal dropdown这类组件时需要用到。而在Vue2并没有内置这个组件,本文我们就来探讨一下如何在Vue2中实现相同的功能。

思考

上面提到 Teleport 组件是将它的children挂载到指定的目标节点上。而正常情况下,元素挂载在哪里取决于你将它写在哪里,那么我们要怎么才能实现跨越层级的挂载呢?比较容易想到的就是 new Vue 这样的方式了,下面我们就以这种方式去实现一个 Teleport 组件。

实现

import Vue, { defineComponent, onMounted, onUnmounted, onUpdated } from 'vue';
import type { PropType, VueConstructor } from 'vue'

export const Teleport = defineComponent({
  props: {
    // 定义 to 属性以供用户可以指定挂载到哪个节点上
    to: {
      type: [String, Object] as PropType<string | HTMLElement>,
      default: () => document.body,
    }
  },
  setup(props, { slots }) {
    // 获取挂载目标节点
    const container = typeof props.to === 'string'
        ? document.querySelector(props.to)
        : props.to
    let div: HTMLDivElement | null = document.createElement('div')
    container?.appendChild(div)
    let vm: InstanceType<VueConstructor> | null = null;

    onMounted(() => {
      if (div) {
        // 跨层挂载的核心
        vm = new Vue({
          el: div,
          render: () => {
            // 只能挂载第一个元素,如需挂载多个可以使用div包裹一层,或者使用 vue-fragment
            return slots.default?.()[0]
          }
        })
      }
    })

    onUnmounted(() => {
      if (vm) {
        vm.$destroy()
        div && document.body.removeChild(div)
        div = null
        vm = null
      }
    })

    onUpdated(() => {
      vm?.$forceUpdate();
    })
    
    return () => null
  }
})

如上代码所示,首先我们需要定义一个props to 让用户可以自定义挂载到哪个节点上,默认挂载到 document.body。然后我们需要创建一个 div 并append到挂载目标元素上,接着通过 new Vue 的方式将需要被挂载的元素挂载到 div 上,这样就大致实现了跨层挂载组件了。但仅仅这样是不够的,别忘了还需要处理 onUpdated onUnmounted的情况。

又一个问题

如上所示大致实现了这样一个组件,但是我在使用中又遇到一个问题,请看下面代码:

export const Modal = defineComponent({
  setup() {
    return () => h(Teleport, [
      h('div', { class: 'modal' })
    ])
  }
})

以上代码看起来似乎没问题,可当你使用 Modal 组件并想为其容器加一些class或者style样式时,就会发现没有效果。原因是组件的根元素不是一个具体的元素了,取而代之的是 Teleport 组件,而vue只会将父元素传递的class和style属性集成给组件的根节点。因此目前这种情况是无法接收到外部传入的class与style的。

而我在尝试了 attrsprops 上查找class和style属性,发现都无法获取到。最终通过 getCurrentInstance找到了办法,请看下面代码:

export const Modal = defineComponent({
  setup() {
    const vm = getCurrentInstance()?.proxy;
  
    return () => h(Teleport, [
      h('div', {
        staticClass: vm?.$vnode.data?.staticClass ? `modal ${vm.$vnode.data.staticClass}` : 'modal',
	staticStyle: {
	  ...(vm?.$vnode.data?.staticStyle || {}),
	},
	style: vm?.$vnode.data?.style,
	class: vm?.$vnode.data?.class
      })
    ])
  }
})

这样就完美解决了无法继承传进来的class和style了。

结语

该组件的核心逻辑并不难,难得是处理边角情况和优化。目前这并不是完美版的,比如没有去考虑to属性动态更新的情况,需要销毁旧的Vue实例再重新创建一个实例。但在Vue3中并不是这么实现的,由于在Vue3中是内建组件,有渲染器的加持,当 to 发生变化时仅需将节点移动至新的目标节点即可,这样做极大的节省了性能消耗。但在Vue2中似乎很难做到这一点。不过这类需求毕竟是少数的,以目前的版本也足以应对大多数场景了。