关于Vue2中自定义的Teleport组件如何实现继承provide

117 阅读2分钟

之前我写过一篇关于如何在Vue2中实现Teleport组件的文章,还没看的小伙伴可以去看看。最近我又发现,这样的实现无法继承全局定义的provide,接下来我们就来讨论下如何实现继承provide。

Demo

首先来看一个demo:

  1. 第一步定义 Provider 组件,提供一个全局的 provide 数据
<!-- Provider.vue -->
<template>
  <div>
    <slot></slot>
  </div>
</template>

<script lang="ts" setup>
import { provide } from 'vue'

provide('message', 'hello')
</script>
  1. 定义 Child 组件并消费 Provider 提供的数据
<!-- Child.vue -->
<template>
  <div>Child</div>
</template>

<script setup lang="ts">
import { inject } from 'vue'

const msg = inject('message')
console.log('child', msg)
</script>
  1. 定义 Parent 组件,在 Template 中将 Child 组件包裹在 Teleport 组件下
<!-- Parent.vue -->
<template>
  <Teleport to="body">
    <Child />
  </Teleport>
</template>

<script setup lang="ts">
import { inject } from 'vue'
import { Teleport } from './Teleport.tsx'
import Child from './Child.vue'

const msg = inject('message')
console.log('parent', msg)
</script>
  1. 定义 App.vue 将上面的组件组合起来
<!-- App.vue -->
<script setup lang="ts">
import Provider from './demo/teleport/Provider.vue'
import Parent from './demo/teleport/Parent.vue'
</script>

<template>
  <Provider>
    <Parent />
  </Provider>
</template>

<style>
table {
  background-color: #333;
  color: #fff;
}
</style>

最终在控制台你会发现 Child 组件无法正确的获取传递过来的 message。这是因为自定义的 Teleport 组件无法继承 provide 导致的,而同样的 demo 使用 Vue3 则同时会打印 parent hello child hello

如何解决

那么该如何解决呢?

首先可以排除在 Teleport 组件中使用 inject 来实现,因为作为组件你无法知道在应用中会提供哪些 key

虽然直接使用 inject 行不通,但是我们可以参考下 inject 的源码:

function inject(key, defaultValue, treatDefaultAsFactory) {
    if (treatDefaultAsFactory === void 0) { treatDefaultAsFactory = false; }
    // fallback to `currentRenderingInstance` so that this can be called in
    // a functional component
    var instance = currentInstance;
    if (instance) {
        // #2400
        // to support `app.use` plugins,
        // fallback to appContext's `provides` if the instance is at root
        var provides = instance.$parent && instance.$parent._provided;
        if (provides && key in provides) {
            // TS doesn't allow symbol as index type
            return provides[key];
        }
        else if (arguments.length > 1) {
            return treatDefaultAsFactory && isFunction(defaultValue)
                ? defaultValue.call(instance)
                : defaultValue;
        }
        else if (process.env.NODE_ENV !== 'production') {
            warn$2("injection \"".concat(String(key), "\" not found."));
        }
    }
    else if (process.env.NODE_ENV !== 'production') {
        warn$2("inject() can only be used inside setup() or functional components.");
    }
}

从上面的代码中不难看出 instance.$parent._provided 就是应用中 provide 传递的所有数据。那么我们只需要把这个对象再通过 provide 选项传递到 Teleport 组件内部的 new Vue 上就可以了。

直接在组件内部获取私有属性是比较丑陋的做法,而且每次需要获取时都要写这么一段丑陋的代码,显然并不合适。我们可以封装一个自定义hook:

import { getCurrentInstance } from 'vue'

export function useProvides<T>(): Record<symbol | string, T> {
  const vm = getCurrentInstance()?.proxy
  if (!vm || !vm.$parent) return {}

  return (vm.$parent as any)._provided || {}
}

ok!接下来我们只需要在 Teleport 组件内部加上这么一段就大功告成了!

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

export const Teleport = defineComponent({
  props: {
    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;
    // 获取 provide
    const provide = useProvides();

    onMounted(() => {
      if (div) {
        vm = new Vue({
          el: div,
          // 重新传递 provide
          provide: () => provide,
          render: () => {
            return slots.default?.()[0]
          }
        })
      }
    })

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

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

总结

总体来说要实现这个功能并不难,主要是要善于利用源码。