手把手教你来写一个useModal的弹窗组件😎

1,674 阅读4分钟

为什么要通过API创建Modal


在大多数基于Vue的UI库中Modal组件的使用一般是类似下面的写法

<v-modal :visiable="showModal">

我们通过控制showModal字段就可以来控制modal窗口的显示.

但是这样写的问题一般有如下:

  • 代码会入侵当前组件DOM
  • 需要创建单独的状态变量来控制显示
  • 关闭打开弹窗需要去手动重置组件内部数据
  • 嵌套弹窗是一般需要进行一些特殊处理,如$nextTick

以上这些问题都会使我们的代码变得复杂,我们希望理想的写法可能是如下:

onClick(){
    modal.open(组件)
}

这样操作的行为可以和我们理解的更一致,弹窗代码只存在于对应的操作函数中,而不会如何dom,已经为它创建专门的控制变量.

但是具体应该如何做到通过函数调用而不用入侵DOM来显示弹窗呢?

通过创建Vue实例来创建Modal


在Vue如何需要显示组件一般需要在Template区域或Render函数添加改组件,而不能仅仅通过Api来添加组件.

而在全局Api中一般通过mount来主动地进行vue实例的挂载,所有在Vue 2中如果需要通过Api方式来创建modal一般的方法就是通过创建一个新的vue实例来操作.

// 创建modal的容器
const container = document.createElement('div')
const el = document.createElement('div')
container.appendChild(el)
document.body.appendChild(container)

modal = new Vue({el,...})

这样modal就可以自动追加到body的结尾位置,如果需要关闭modal则可以通过destoryremoveChild即可

modal.$destroy()
container.parentNode.removeChild(container)

可以既可以将添加的modaldom上删除.

而组件可以通过Vuerender进行渲染显示即可.

但是这样做得方式也存在弊端,因为重新创建了vue实例,所以和原有的vue实例实际是两个单独的对象,所以storeroutermixedinject需要重新注入新的实例中,否则在弹窗组件中无法正常使用部分功能,

具体实例代码如下:

import Vue from 'vue'
import { Modal } from 'ant-design-vue'
import { Observable } from 'rxjs'

export class ModalService {
    /**
     * 创建Modal容器
     */
    private createModalContainer() {
        const container = document.createElement('div')
        const el = document.createElement('div')
        container.appendChild(el)
        document.body.appendChild(container)
        return {
            container,
            el
        }
    }

    /**
     * 创建Modal组件
     * @param options
     */
    private renderModelComponent(Component, data, options) {
        const { container, el } = this.createModalContainer()
        let modalInstance
        const modalClose = () => {
            if (modalInstance && container.parentNode) {
                modalInstance.$destroy()
                container.parentNode.removeChild(container)
            }
        }

        return new Observable<any>(subject => {
            modalInstance = new Vue({
                el,
                render(h) {
                    return h(
                        Modal,
                        {
                            props: {
                                centered: true,
                                header: false,
                                ...options,
                                visible: true,
                                footer: false
                            },
                            on: {
                                cancel: () => {
                                    subject.complete()
                                    modalClose()
                                }
                            }
                        },
                        [
                            h(Component, {
                                props: data,
                                on: {
                                    'modal.submit': data => {
                                        subject.next(data)
                                        subject.complete()
                                        modalClose()
                                    },
                                    'modal.cancel': () => {
                                        subject.complete()
                                        modalClose()
                                    }
                                }
                            })
                        ]
                    )
                }
            })
        })
    }

    /**
     * 弹出组件页面
     * @param options
     */
    public open(Component, data?, options?) {
        return this.renderModelComponent(Component, data, options)
    }
}

通过ModalServiceopen方法就可以打开弹窗组件了

const modal = new ModalService()
modal.open(Component)

通过使用Teleport来创建Modal


Vue 3中我们还有另一种选择,就是通过Teleport可以将内容指定挂载到对应的位置

<teleport to="body">
    ...

不过相对于创建vue实例来创建modal的方式,这种方式我们需要提前安装容器来支持我们组件的显示,我们需要显式的将Teleport写入模板才可以将其加载.

<template>
    <modal-provider>
        <router-view>
    </modal-provider>
</template>

这样做得目的主要是为了一个是作为挂载modal的容器,一个是可以向下传递provide的内容,子组件可以通过inject('modal')来获取对应的操作.

然后我们就可以将弹窗全部作为modal-provider的子组件,然后通过动态组件来渲染需要弹窗的组件了

下来我们在modal-provider组件可以通过teleport来加载对应的modal-container

<template lang="pug">
slot
.modal-teleport
  teleport(
      to="body"
  )
    transition-group(name="modal-fade")
      modal-container(
          v-for="(modal,index) in modals"
          :key="modal.id"
          :id="modal.id"
          :component="modal.component"
          :params="modal.props"
          :title="modal.config.title"
          :closable="modal.config.closable"
          :maskClosable="modal.config.maskClosable"
          :min-width="minWidth"
          :width="modal.config.width"
      )
</template>

可以看到我们通过teleport将所有弹窗挂载到了body元素上,然后通过transition-group来实现弹窗的显示动画效果,而modal-container是我们用来显示内容的弹窗组件.

下来我们需要准备openModal方法来讲来传入需要弹窗的组件和配置.

const modals = shallowRef<IModal[]>([]);

async function openModal(option: IModalOption) {
  const component = defineAsyncComponent(() =>
    Promise.resolve(option.component)
  );
  return new Promise((resolve) => {
    modals.value.push({
      id: Math.random().toString(32).slice(2),
      component,
      props: option.props,
      resolve,
      config: option,
    });
    triggerRef(modals);
  });
}

modals就是我们用来保存所有弹窗的数组,为了通过<component :is="...">来显示动态显示传入的组件,我们通过defineAsyncComponent来处理进行处理.

因为我们创建modal实际返回了promise对象,这样我们可以在关闭窗口通过执行Promise.resolve来做数据回传的功能.

另外在modal-container中,主要的工作就是负责显示组件

<template>
.modal-container
  .modal-wrapper(@click.self="maskClosable&&onCloseModal()")
      .modal-content(:style="contentStyle")
          .modal-header(v-if="header")
              .title {{title}}
              .action
                  img.close-button(
                    v-if="closable"
                    :src="closeSVG"
                    @click="onCloseModal"
                  )
          .modal-body
            component(:is="component" v-bind="params")
</template>

就这样基本完成了通过teleport实现modal的核心功能我进可以通过open打开新的弹窗.

const modal = useModal()
modal.open(ModalOption)

对了还有一个问题是teleportSSR中并不能很好的工作,会获得mismatch的警告,如果要消除这个警告我们也需要做一些工作.

<template>
.modal-teleport(v-if="clientMounted")
  teleport(
      to="body"
  )
</template>

<script lang="ts">
const clientMounted = ref(false)

onMounted(() => {
  clientMounted.value = true;
});
</script>

这样做的目的是为了在mounted我们并不加载teleport组件,这样在SSR中也就不会产生警告了,达到了client-only效果.

具体实例代码可以查看如下地址: Github

如果你有好的方法,也希望能告诉我.