【宇宙最强】 史诗级vue3 函数式弹窗 !!!
前言
前几天分享了一个优雅展示大量弹窗组件的方式,原文: 假如你的页面上有几十个弹窗,你会怎样优雅地展示它们?🧐 评论区也有一些讨论:比如不支持插槽,还有就是虽然 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: booleantitle: stringprop: string}>()const emit = defineEmits(['update:visible', 'loadList'])const bindVisible = useVModel(props, 'visible', emit)const handleOk = () => {// 假装请求业务接口...bindVisible.value = falseemit('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 的使用方式之后,我们着手去实现它:
首先函数必须接收一个弹窗组件,这个组件可以是懒加载的(一个异步加载函数),如果懒加载,我们会用 「defineAsyncComponent」 把它定义为一个异步组件。 可以通过 appendTo 参数指定挂载的位置(默认为 document.body) 拿到传进来的参数、事件。 使用 「h」 函数创建虚拟节点,并且通过 「render」 函数把节点渲染到需要挂载的DOM上。
import { defineAsyncComponent, h, nextTick, render } from 'vue'import type { Component } from 'vue'interface IModalOptions {modalComponent: Component | anyappendTo?: HTMLElement | string[name: string]: unknown}const getAppendToElement = (appendTo: IModalOptions['appendTo']): HTMLElement => {let appendToEL: HTMLElement | null = document.bodyif (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.modalComponentconst 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'// 省略部分代码...// startfunction getProvides(instance: any) {let provides = instance?.provides || {}if (instance.parent) {provides = { ...provides, ...getProvides(instance.parent) }}return provides}// end// 修改后的 useShowModalexport default function useShowModal() {// startconst currentInstance = getCurrentInstance() as anyconst provides = getProvides(currentInstance)// endfunction showModal(options: IModalOptions) {const container = document.createElement('div')const isAsync = typeof options.modalComponent === 'function'const modalComponent = isAsync? defineAsyncComponent(options.modalComponent): options.modalComponentconst props: Record<string, any> = {}for (const key in options) {if (!['modalComponent', 'appendTo'].includes(key)) props[key] = options[key]}// startconst vNode = createVNode({setup() {const instance = getCurrentInstance() as anyif (instance) {instance.provides = { ...instance.provides, ...provides }}},render: () =>h(modalComponent, {visible: true,...props,'onUpdate:visible': () => {nextTick(() => {close()})}})})// endrender(vNode, container)getAppendToElement(options.appendTo).appendChild(container)function close() {render(null, container)container.parentNode?.removeChild(container)}}return showModal}
可以看出我们的 showModal 核心实现几乎没有变化。改动的只有两点:
把 showModal 放到了 useShowModal 这个组合式函数里面并且返回给使用者。目的是当使用者在组件内调用 useShowModal 的时候,可以通过 「getCurrentInstance」 获取当前组件实例,并把所有上级组件 provide 的数据全部取出来,添加到弹窗组件的实例上。 使用 「createVNode」 创建虚拟节点,这样可以在 setup 函数中获取到弹窗组件实例,就可以添加上级组件 provide 的数据了。
这样不管是在根组件 App.vue 里 provide 的数据,还是父组件 provide 的数据(只要是上级组件都可以),弹窗内部统统都能拿到,解决了弹窗脱离组件树引起的数据丢失问题。
三、实现插槽
这个很好解决,因为在 vue 中,我们在 template 里写的插槽,最终都会被编译成一个个函数。刚好 h 函数的第三个参数是可以接收插槽函数的。
// 完整参数签名function h(type: string | Component,props?: object | null,children?: Children | Slot | Slots): VNode// 省略 propsfunction h(type: string | Component, children?: Children | Slot): VNodetype Children = string | number | boolean | VNode | null | Children[]type Slot = () => Childrentype 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 | anyappendTo?: HTMLElement | stringslots?: RawSlots // 新增[name: string]: unknown}export default function useShowModal() {// ...const vNode = createVNode({setup() {const instance = getCurrentInstance() as anyif (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: booleantitle: stringprop: 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 = falseemit('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('表单已经提交,刷新列表')})
效果:
四、暴露方法
这里暴露方法的方式其实与平时我们在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}
这里我区分了两种情况:
异步组件:h 函数创建虚拟节点时,如果是动态导入的组件,ref 的绑定是异步的(挂载需要时间),没法同步地获取组件实例,所以需要监听 ref 值的变化,当值发生变化时就是代表挂载完毕了,这个时候再 resolve 结果。 同步组件:直接返回组件实例。
使用方式:
如果你传入的组件是异步加载函数,请通过 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: booleantitle: stringprop: 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 = falseemit('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 | anyappendTo?: HTMLElement | stringslots?: RawSlots[name: string]: unknown}const getAppendToElement = (appendTo: IModalOptions['appendTo']): HTMLElement => {let appendToEL: HTMLElement | null = document.bodyif (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 anyconst 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.modalComponentconst 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 anyif (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}