史诗级 ⚡ 宇宙最强 🏆 vue3 函数式弹窗 🚀

10,434 阅读5分钟

前言

前几天分享了一个优雅展示大量弹窗组件的方式,原文: 假如你的页面上有几十个弹窗,你会怎样优雅地展示它们?🧐 评论区也有一些讨论:比如不支持插槽,还有就是虽然 template 变得简洁了,但是还是要引入一个动态组件 unityModal.vue ,具有一定的心智负担。那我们今天就一不做二不休,封装一个宇宙最强级别函数式弹窗方法, 使用时只需调用即可。
我希望这个方法满足以下条件:

  • 支持懒加载(异步加载函数)
  • props传参
  • 事件绑定
  • provide inject 注入数据
  • 各种自定义插槽
  • 暴露内部方法

接下来让我们一步一步实现它。

一、基本实现

在实际开发中,最基本的弹窗组件需要满足以下几点要求:

  • 能够接收父组件传进来的参数
  • 能够绑定自定义事件接收子组件的通知

弹窗组件

以下是一个基本弹窗组件:

<template>
  <a-modal v-model:open="bindVisible" :title="title" @ok="handleOk">
    <div>父组件传入的 prop:{{ prop }}</div>
  </a-modal>
</template>
<script setup lang="ts">
import { useVModel } from '@vueuse/core'

const props = defineProps<{
  visible: boolean
  title: string
  prop: string
}>()

const emit = defineEmits(['update:visible', 'loadList'])
const bindVisible = useVModel(props, 'visible', emit)

const handleOk = () => {
  // 假装请求业务接口...
  bindVisible.value = false
  emit('loadList')
}
</script>

使用方式

我想要在页面中这样使用它:仅需要通过调用函数的形式让弹窗显示,其他什么都不用写,这样开发时把注意力都聚集在这个函数上,逻辑高度内聚。

<template>
  <div>
    <a-button type="primary" @click="onClick">弹窗按钮</a-button>
  </div>
</template>
<script setup lang="ts">
import { message } from 'ant-design-vue'

const onClick = () => {
  showModal({
    modalComponent: () => import('@/pages/home/components/modal.vue'),
    title: '弹窗标题',
    prop: '弹窗组件需要的参数',
    onLoadList: () => message.success('表单已经提交,刷新列表')
  })
}
</script>

showModal 具体实现

确定了 showModal 的使用方式之后,我们着手去实现它:

  1. 首先函数必须接收一个弹窗组件,这个组件可以是懒加载的(一个异步加载函数),如果懒加载,我们会用 defineAsyncComponent 把它定义为一个异步组件。
  2. 可以通过 appendTo 参数指定挂载的位置(默认为 document.body)
  3. 拿到传进来的参数、事件。
  4. 使用 h 函数创建虚拟节点,并且通过 render 函数把节点渲染到需要挂载的DOM上。
import { defineAsyncComponent, h, nextTick, render } from 'vue'
import type { Component } from 'vue'

interface IModalOptions {
  modalComponent: Component | any
  appendTo?: HTMLElement | string
  [name: string]: unknown
}

const getAppendToElement = (appendTo: IModalOptions['appendTo']): HTMLElement => {
  let appendToEL: HTMLElement | null = document.body
  if (appendTo) {
    if (typeof appendTo === 'string') {
      appendToEL = document.querySelector<HTMLElement>(appendTo)
    }
    if (appendTo instanceof HTMLElement) {
      appendToEL = appendTo
    }
    if (!(appendToEL instanceof HTMLElement)) {
      appendToEL = document.body
    }
  }
  return appendToEL
}

export default function showModal(options: IModalOptions) {
  const container = document.createElement('div')
  const isAsync = typeof options.modalComponent === 'function'

  const modalComponent = isAsync
    ? defineAsyncComponent(options.modalComponent)
    : options.modalComponent

  const props: Record<string, any> = {}
  for (const key in options) {
    if (!['modalComponent', 'appendTo'].includes(key)) props[key] = options[key]
  }

  const vNode = h(modalComponent, {
    visible: true,
    ...props,
    'onUpdate:visible': () => {
      nextTick(() => {
        close()
      })
    }
  })

  render(vNode, container)
  getAppendToElement(options.appendTo).appendChild(container)

  function close() {
    render(null, container)
    container.parentNode?.removeChild(container)
  }
}

二、注入数据

在实际开发中,我们很有可能会使用 provide 给下级组件提供数据。从 showModal 函数的实现中我们可以看到弹窗组件默认是挂载到 document.body 下的,这会使弹窗组件脱离原有组件树,内部就无法通过 inject 拿到上级组件 provide 的数据了。所以我们需要改造一下我们的 showModal 函数。

import { defineAsyncComponent, getCurrentInstance, h, nextTick, render, createVNode } from 'vue'
import type { Component } from 'vue'

// 省略部分代码...

// start
function getProvides(instance: any) {
  let provides = instance?.provides || {}
  if (instance.parent) {
    provides = { ...provides, ...getProvides(instance.parent) }
  }
  return provides
}
// end

// 修改后的 useShowModal
export default function useShowModal() {
  // start
  const currentInstance = getCurrentInstance() as any
  const provides = getProvides(currentInstance)
  // end
  
  function showModal(options: IModalOptions) {
    const container = document.createElement('div')
    const isAsync = typeof options.modalComponent === 'function'

    const modalComponent = isAsync
      ? defineAsyncComponent(options.modalComponent)
      : options.modalComponent

    const props: Record<string, any> = {}
    for (const key in options) {
      if (!['modalComponent', 'appendTo'].includes(key)) props[key] = options[key]
    }

    // start
    const vNode = createVNode({
      setup() {
        const instance = getCurrentInstance() as any
        if (instance) {
          instance.provides = { ...instance.provides, ...provides }
        }
      },
      render: () =>
        h(modalComponent, {
          visible: true,
          ...props,
          'onUpdate:visible': () => {
            nextTick(() => {
              close()
            })
          }
        })
    })
    // end

    render(vNode, container)
    getAppendToElement(options.appendTo).appendChild(container)

    function close() {
      render(null, container)
      container.parentNode?.removeChild(container)
    }
  }

  return showModal
}

可以看出我们的 showModal 核心实现几乎没有变化。改动的只有两点:

  1. 把 showModal 放到了 useShowModal 这个组合式函数里面并且返回给使用者。目的是当使用者在组件内调用 useShowModal 的时候,可以通过 getCurrentInstance 获取当前组件实例,并把所有上级组件 provide 的数据全部取出来,添加到弹窗组件的实例上。
  2. 使用 createVNode 创建虚拟节点,这样可以在 setup 函数中获取到弹窗组件实例,就可以添加上级组件 provide 的数据了。

这样不管是在根组件 App.vue 里 provide 的数据,还是父组件 provide 的数据(只要是上级组件都可以),弹窗内部统统都能拿到,解决了弹窗脱离组件树引起的数据丢失问题。

三、实现插槽

这个很好解决,因为在 vue 中,我们在 template 里写的插槽,最终都会被编译成一个个函数。刚好 h 函数的第三个参数是可以接收插槽函数的。

// 完整参数签名
function h(
  type: string | Component,
  props?: object | null,
  children?: Children | Slot | Slots
): VNode

// 省略 props
function h(type: string | Component, children?: Children | Slot): VNode

type Children = string | number | boolean | VNode | null | Children[]

type Slot = () => Children

type Slots = { [name: string]: Slot }

那我们只需要给我们的 showModal 函数再添加一个 slots 参数即可。

import { defineAsyncComponent, getCurrentInstance, h, nextTick, render, createVNode } from 'vue'
import type { Component } from 'vue'

// 新增
type RawSlots = {
  [name: string]: unknown
  $stable?: boolean
}
interface IModalOptions {
  modalComponent: Component | any
  appendTo?: HTMLElement | string
  slots?: RawSlots  // 新增
  [name: string]: unknown
}

export default function useShowModal() {
  // ...
  const vNode = createVNode({
      setup() {
        const instance = getCurrentInstance() as any
        if (instance) {
          instance.provides = { ...instance.provides, ...provides }
        }
      },
      render: () =>
        h(
          modalComponent,
          {
            visible: true,
            ...props,
            'onUpdate:visible': () => {
              nextTick(() => {
                close()
              })
            }
          },
          options.slots // 新增
        )
    })
  // ...
}

使用的时候直接传入插槽函数:

<template>
  <a-modal v-model:open="bindVisible" :title="title" @ok="handleOk">
    <div>父组件传入的 prop:{{ prop }}</div>
    <!-- 注入的数据 -->
    <div>父组件 provide 的信息:{{ message }}</div>
    <div>App根组件 provide 的信息:{{ appMessage }}</div>
    <!-- 插槽 -->
    <slot v-bind="{ type: '默认插槽' }"></slot>
    <slot name="footer" v-bind="{ type: '具名插槽' }"></slot>
  </a-modal>
</template>
<script setup lang="ts">
import { inject } from 'vue'
import { useVModel } from '@vueuse/core'

const props = defineProps<{
  visible: boolean
  title: string
  prop: string
}>()

const emit = defineEmits(['update:visible', 'loadList'])
const bindVisible = useVModel(props, 'visible', emit)
const message = inject('message')
const appMessage = inject('appMessage')

const handleOk = () => {
  // 假装请求业务接口...
  bindVisible.value = false
  emit('loadList')
}
</script>
// 使用
 showModal2({
    modalComponent: () => import('@/pages/home/components/modal.vue'),
    title: '弹窗标题',
    prop: '弹窗组件需要的参数',
    slots: {
      default: (arg: any) => h('button', arg.type), // 默认插槽
      footer: (arg: any) => h('button', arg.type)  // 具名插槽
    },
    onLoadList: () => message.success('表单已经提交,刷新列表')
  })

效果:

image.png

四、暴露方法

这里暴露方法的方式其实与平时我们在vue组件中实现原理方式一样,都是给组件绑定 ref,然后通过 ref 拿到组件实例进而操作组件暴露的方法,来看一下具体实现:

// ...
export default function useShowModal() {
  function showModal(options: IModalOptions) {
    // ...
    const isAsync = typeof options.modalComponent === 'function'
    const innerRef = ref()  // 新增

    const vNode = createVNode({
      // ...
      render: () =>
        h(
          modalComponent,
          {
            visible: true,
            ...props,
            ref: innerRef,  // 新增
            'onUpdate:visible': () => {
              nextTick(() => {
                close()
              })
            }
          },
          options.slots
        )
    })

    // 新增
    if (!isAsync) {
      return innerRef.value
    } else {
      return new Promise((resolve) => {
        watch(
          innerRef,
          () => {
            resolve(innerRef.value)
          },
          {
            once: true
          }
        )
      })
    }
  }
  return showModal
}

这里我区分了两种情况:

  1. 异步组件:h 函数创建虚拟节点时,如果是动态导入的组件,ref 的绑定是异步的(挂载需要时间),没法同步地获取组件实例,所以需要监听 ref 值的变化,当值发生变化时就是代表挂载完毕了,这个时候再 resolve 结果。
  2. 同步组件:直接返回组件实例。

使用方式:

  • 如果你传入的组件是异步加载函数,请通过 await 去获取异步组件实例。
  • 如果同步组件,直接接收组件实例即可。
const Modal = await showModal({
    modalComponent: () => import('@/pages/home/components/modal.vue'),
    title: '弹窗标题',
    prop: '弹窗组件需要的参数',
    slots: {
      default: (arg: any) => h('button', arg.type),
      footer: (arg: any) => h('button', arg.type)
    },
    onLoadList: () => message.success('表单已经提交,刷新列表')
  })
Modal.getInfo()

五、总结

最终,我们实现了一个宇宙最强级别函数式弹窗组件的方法,它适应绝大多数使用场景:

  • 支持懒加载(异步加载函数)
  • props传参
  • 事件绑定
  • provide inject 注入数据
  • 各种自定义插槽
  • 暴露内部方法

六、源码

modal.vue

<template>
  <a-modal v-model:open="bindVisible" :title="title" @ok="handleOk">
    <div>父组件传入的 prop:{{ prop }}</div>
    <div>父组件 provide 的信息:{{ message }}</div>
    <div>App根组件 provide 的信息:{{ appMessage }}</div>
    <slot v-bind="{ type: '默认插槽' }"></slot>
    <slot name="footer" v-bind="{ type: '具名插槽' }"></slot>
  </a-modal>
</template>
<script setup lang="ts">
import { inject } from 'vue'
import { useVModel } from '@vueuse/core'

const props = defineProps<{
  visible: boolean
  title: string
  prop: string
}>()

const emit = defineEmits(['update:visible', 'loadList'])
const bindVisible = useVModel(props, 'visible', emit)
const message = inject('message')
const appMessage = inject('appMessage')

const handleOk = () => {
  // 假装请求业务接口...
  bindVisible.value = false
  emit('loadList')
}

defineExpose({
  getInfo() {
    return {
      message,
      appMessage
    }
  }
})
</script>

index.vue

<template>
  <div>
    <a-button type="primary" @click="onClick">弹窗</a-button>
  </div>
</template>
<script setup lang="ts">
import { provide, h } from 'vue'
import { message } from 'ant-design-vue'
import useShowModal from '@/utils/useShowModal'

provide('message', '我是父组件home页面')
const showModal = useShowModal()
const onClick = async () => {
  const Modal = await showModal({
    modalComponent: () => import('@/pages/home/components/modal.vue'),
    title: '弹窗标题',
    prop: '弹窗组件需要的参数',
    slots: {
      default: (arg: any) => h('button', arg.type),
      footer: (arg: any) => h('button', arg.type)
    },
    onLoadList: () => message.success('表单已经提交,刷新列表')
  })
  console.log(Modal.getInfo())
}
</script>

useShowModal.ts

import {
  defineAsyncComponent,
  getCurrentInstance,
  h,
  nextTick,
  render,
  createVNode,
  ref,
  watch
} from 'vue'
import type { Component } from 'vue'

type RawSlots = {
  [name: string]: unknown
  $stable?: boolean
}
interface IModalOptions {
  modalComponent: Component | any
  appendTo?: HTMLElement | string
  slots?: RawSlots
  [name: string]: unknown
}

const getAppendToElement = (appendTo: IModalOptions['appendTo']): HTMLElement => {
  let appendToEL: HTMLElement | null = document.body
  if (appendTo) {
    if (typeof appendTo === 'string') {
      appendToEL = document.querySelector<HTMLElement>(appendTo)
    }
    if (appendTo instanceof HTMLElement) {
      appendToEL = appendTo
    }
    if (!(appendToEL instanceof HTMLElement)) {
      appendToEL = document.body
    }
  }
  return appendToEL
}

function getProvides(instance: any) {
  let provides = instance?.provides || {}
  if (instance.parent) {
    provides = { ...provides, ...getProvides(instance.parent) }
  }
  return provides
}

export default function useShowModal() {
  const currentInstance = getCurrentInstance() as any
  const provides = getProvides(currentInstance)

  function showModal(options: IModalOptions) {
    const container = document.createElement('div')
    const isAsync = typeof options.modalComponent === 'function'
    const innerRef = ref()

    const modalComponent = isAsync
      ? defineAsyncComponent(options.modalComponent)
      : options.modalComponent

    const props: Record<string, any> = {}
    for (const key in options) {
      if (!['modalComponent', 'appendTo', 'slots'].includes(key)) props[key] = options[key]
    }

    const vNode = createVNode({
      setup() {
        const instance = getCurrentInstance() as any
        if (instance) {
          instance.provides = { ...instance.provides, ...provides }
        }
      },
      render: () =>
        h(
          modalComponent,
          {
            visible: true,
            ...props,
            ref: innerRef,
            'onUpdate:visible': () => {
              nextTick(() => {
                close()
              })
            }
          },
          options.slots
        )
    })

    render(vNode, container)
    getAppendToElement(options.appendTo).appendChild(container)

    function close() {
      render(null, container)
      container.parentNode?.removeChild(container)
    }

    if (!isAsync) {
      return innerRef.value
    } else {
      return new Promise((resolve) => {
        watch(
          innerRef,
          () => {
            resolve(innerRef.value)
          },
          {
            once: true
          }
        )
      })
    }
  }

  return showModal
}