2024-07-21 17:49:03

在业务开发里,弹窗几乎是最高频的交互之一。但一旦数量多起来,代码也很容易开始重复。比如这样:

<el-dialog v-model="visible1">content 1</el-dialog>
<el-dialog v-model="visible2">content 2</el-dialog>
<el-dialog v-model="visible3">content 3</el-dialog>
const visible1 = ref(false)
function open1() {
  visible1.value = true
}

const visible2 = ref(false)
function open2() {
  visible2.value = true
}

const visible3 = ref(false)
function open3() {
  visible3.value = true
}

如果弹窗只有一两个,问题还不明显;但一旦数量多起来,即使拆成组件,还是会留下不少重复的状态管理和打开逻辑,连命名都会变得很痛苦。

所以我想把弹窗的打开和关闭逻辑再往上收一层,尽量做成函数调用的形式,而不是先维护一堆 visible 状态。

我期望的使用方式是:

useModal(component, props, modalProps)

这篇文章就简单记录一下 useModal 的实现。

实现目标

  • 通过函数直接打开弹窗
  • 支持 props 的传递和弹窗的配置
  • 调用方可以主动关闭
  • 可以调用到组件实例暴露的方法
  • 自动销毁

1:弹窗动态挂载

要通过函数调用,就不能再直接依赖页面里预先写好的 <el-dialog />。比较直接的做法是在调用时临时创建一个 vue 应用,再挂载到 document.body 上。

export function useModal(component, props, modalProps) {
  const open = ref(false)

  const dialog = () => {
    const { slots: modalSlots, ...restModalProps } = modalProps ?? {}

    const dialogProps = {
      ...restModalProps,
      modelValue: open.value,
      beforeClose(done: () => void) {
        done()
      }
    }

    const slots = {
      default: () => h(component, props),
      ...modalSlots
    }

    return h(ElDialog, dialogProps, slots)
  }

  const app = createApp(dialog)
  const div = document.createElement('div')
  document.body.appendChild(div)
  app.mount(div)
}

这样一来,弹窗内容就能在运行时动态渲染出来。

不过这里只是第一步,还有两个明显的问题:

  1. 每次打开都会创建实例,但没有销毁逻辑。
  2. 外部无法主动关闭弹窗。

2:补充关闭和销毁

因为是通过 createElement 动态创建节点的,所以关闭时不能只改 modelValue,还要把挂载出来的内容真正移除掉,不然只是视觉上关了。

所以补一个 unmount 方法,同时对外暴露 close

export function useModal(component, props, modalProps) {
  function unmount() {
    if (!open.value)
      return

    open.value = false
    app.unmount()
    document.body.removeChild(div)
  }

  return {
    close: unmount,
    isOpen: open
  }
}

这样虽然能正常销毁,但在测试后会发现一个新问题:关闭动画没了

原因也很直接:open.value = false 之后立刻执行了 app.unmount(),动画还没走完,节点就已经被卸载了。

3:延迟销毁, 保留动画

问题既然出在卸载过早,那就把销毁往后放一点,等关闭动画结束之后再执行。

function unmount() {
  if (!open.value)
    return

  open.value = false

  setTimeout(() => {
    app.unmount()
    document.body.removeChild(div)
  }, 1000)
}

4:调用弹窗内容组件的方法

在业务里,弹窗通常不只是展示内容,很多时候还会带着表单、校验、确认之类的逻辑。

defineExpose({
  validate() {
    // ...
  }
})

处理方式也不复杂,给弹窗内容组件挂一个 ref 就够了。

export function useModal(component, props, modalProps) {
  const componentInstance = ref<ComponentPublicInstance | null>(null)

  const slots = {
    default: () =>
      h(component, {
        ...(props ?? {}) as Record<string, unknown>,
        ref: componentInstance
      }),
    ...modalSlots
  }

  return {
    close: unmount,
    instance: componentInstance,
    isOpen: open
  }
}

完整代码

import type { DialogProps } from 'element-plus'
import type { Component, ComponentPublicInstance } from 'vue'
import type { ExtractComponentProps, ModalInstance, ModalProps } from './types'
import { ElDialog } from 'element-plus'
import { createApp, h, ref } from 'vue'
import { loadPlugins } from '@/plugins'

const ANIMATION_DURATION = 1000

const DEFAULT_MODAL_PROPS: Partial<DialogProps> = {
  width: '50%',
  top: '15vh',
  closeOnClickModal: false,
  closeOnPressEscape: true,
  destroyOnClose: true,
  draggable: true,
  lockScroll: true
} as const

export function useModal(
  component: Component,
  props?: ExtractComponentProps,
  modalProps?: ModalProps
): ModalInstance {
  const componentInstance = ref<ComponentPublicInstance | null>(null)
  const open = ref(true)

  const dialog = () => {
    const { slots: modalSlots, ...restModalProps } = modalProps ?? {}

    const dialogProps = {
      ...DEFAULT_MODAL_PROPS,
      ...restModalProps,
      modelValue: open.value,
      beforeClose(done: () => void) {
        done()
        unmount()
      }
    }

    const slots = {
      default: () =>
        h(component, {
          ...(props ?? {}) as Record<string, unknown>,
          ref: componentInstance
        }),
      ...modalSlots
    }

    return h(ElDialog, dialogProps, slots)
  }

  const app = createApp(dialog)
  const div = document.createElement('div')

  loadPlugins(app)
  document.body.appendChild(div)
  app.mount(div)

  function unmount() {
    if (!open.value)
      return

    open.value = false
    setTimeout(() => {
      app.unmount()
      document.body.removeChild(div)
    }, ANIMATION_DURATION)
  }

  return {
    close: unmount,
    instance: componentInstance,
    isOpen: open
  }
}