一文教你优雅地使用Vue函数式调用可处理异步逻辑的弹窗

98 阅读4分钟

为什么要使用函数式调用弹窗?

常规方式使用弹窗的痛点

对于最基本的确定提示框弹窗,使用通常需要以下几步

  • 创建visible变量,为按钮创建点击事件,点击事件设置更改visible
  • 引入组件,v-model绑定visible,组件绑定弹框确认取消事件

上述方式的弊端很明显,主要在于

  • 需要声明一个visible变量去管理,当一个页面挂载多个弹窗时会有一大堆visible,容易混淆
  • 由于没有状态,链式调用多个弹窗和管理弹窗状态时会比较恶心,往往写着写着就忘记哪个地方需要给visible设为true/false
  • 将弹窗移动到另一个页面时需要额外写的东西很多

使用该版本函数式弹窗的优点

  • 使用Promise进行弹窗管理,很适配多步骤多弹窗的需求
  • visible变量内置在组件内部,既可自动管理也可手动管理,相较于Element-plus的 ElMessageBox.confirm,能够在点击确认后异步关闭弹窗
  • 只需要一行引入组件代码,一行调用代码即可直接生成弹窗,弹窗复用成本大幅降低

代码详情

调用示例

主体分为两个api:

  • useDialogControl 用于在弹窗组件中初始化弹窗
  • useDialogShow 用于弹窗的调用

常规使用(点击确认后关闭)

useDialogShow('ConfirmDlg', {
      title: '确定删除?',
      subTitle: '删除后不可恢复,请谨慎操作'
    }).then(() => {
      ...
    })

异步关闭弹窗

useDialogShow('ConfirmDlg', {
    // ...任意组件需要的参数
    onConfirm: (res: any, exposed: any) => {
      // 此处添加await异步逻辑
      exposed.emit('resolve')
    }
})

以上弹窗调用不仅仅局限于确认弹窗,包括表单弹窗,进度条弹窗等任意模态弹窗都可以套用该弹窗调用模版

要使弹窗能够支持以上调用,由于js调用组件时会丢失上下文,需要先挂载Vue的上下文到全局以便获取(个人使用是封装在组件库里的,直接在install里面获取)

const app = createApp(App)
window.lsuiApp = app

弹窗组件中需要调用useDialogControl进行初始化,使其能够被useDialogShow调用

import { useDialogControl } from '@/hooks/useDialog'
const { visible } = useDialogControl(getCurrentInstance())

完整子组件代码

<template>
  <el-dialog
    :model-value="visible"
    :close-on-click-modal="false"
    :before-close="onBeforeClose"
    :width="width"
  >
    <template #header>
      <span class="el-dialog__title">
        <fs-icon icon="error" color="#F8721D" style="margin-right: 0.5rem" />
        {{ title }}
      </span>
    </template>
    <div class="sub-title">
      {{ subTitle }}
    </div>
    <slot />
    <LsConfirmFooter @confirm="confirm" @cancel="visible = false" />
  </el-dialog>
</template><script setup lang='ts'>
defineOptions({ name: 'ConfirmDlg' })
withDefaults(defineProps<{
    title: string;
    subTitle: string;
    width: string;
    icon: string;
  }>(), {
  title: '',
  subTitle: '',
  width: '28rem',
  icon: 'error'
})
const emit = defineEmits(['confirm'])
const confirm = () => {
  emit('confirm', { visible })
}
import { useDialogControl } from '@/hooks/useDialog'
const { visible, onBeforeClose } = useDialogControl(getCurrentInstance())
</script>
<style lang="less" scoped>
.sub-title {
    line-height: 1.375rem;
    min-height: 3rem;
    font-size: 1rem;
    margin-left: 1rem;
    word-break: break-all;
    white-space: pre-line;
}
</style>

完整代码

import { createVNode, render, ref, Component } from 'vue'
import type { Ref, ComponentInternalInstance } from 'vue'
import { watch } from 'vue'
// 当前由useDialog创建的全部弹窗
const dialogList: {id: string, visible: Ref<boolean>}[] = []
// 注册的弹窗
const diaLogMap: Record<string, Component> = {}
// 弹窗控制器初始化export function useDialogShow<T = Record<string, any>>(dlg: string | Component, options: {[key:string]: any} = {}) {
  options?.dlgId && clearDialog(options.dlgId)
  return new Promise<T & {
    visible: Ref<boolean>,
    changed: Ref<boolean>
  }>((resolve, reject) => {
    const container = document.createElement('div')
    if (options?.dlgId) {
      container.id = options?.dlgId
    }
    const vNode = createVNode(
      typeof dlg === 'string' ? diaLogMap[dlg] : dlg,
      {
        onDestroy: () => {
          setTimeout(() => {
            render(null, container)
            container.remove()
          }, 1000)
          reject()
        },
        // 用于在confirm里面手动回调触发promise的resolve
        onResolve: () => {
          exposed.visible.value = false
          resolve(exposed)
        },
        ...options,
        onConfirm: (emitParam: any) => {
          if (options?.onConfirm) {
            options.onConfirm(emitParam, exposed)
          } else {
            exposed.visible.value = false
            resolve(exposed)
          }
        }
      },
      null
    )
    // 更新节点上下文以引用全局组件
    // @ts-ignore
    vNode.appContext = window.lsuiApp._context
    render(vNode, container)
    document.body.appendChild(container)
    const instance = vNode.component as any
    const exposed = instance?.exposed
    if (exposed) {
      exposed.visible.value = true
    }
  })
}
export function useDialogControl(contextData: ComponentInternalInstance | null, id = 'fs-dialog' + Date.now() + Math.floor(Math.random() * 1000)) {
  const context = contextData as ComponentInternalInstance
​
  const visible = ref<boolean>(false) // 弹窗显示双向绑定
  const changed = ref<boolean>(false) // 标识数据是否发生了变化,外界回调获取更新
​
  if (!context.exposed) {
    context.exposed = {}
  }
  context.exposed.visible = visible
  context.exposed.changed = changed
  context.exposed.emit = context.emit
  const onBeforeClose = () => {
    visible.value = false
  }
  dialogList.push({ id: context.attrs.dlgId as string || id, visible })
​
  watch(() => visible.value, (value) => {
    if (!value) {
      context.emit('destroy')
      const listIndex = dialogList.findIndex(val => val.id === id)
      if (listIndex !== -1) {
        dialogList.splice(listIndex, 1)
      }
    }
  })
  return { visible, changed, onBeforeClose }
}
export function clearDialog(id?: string) {
  if (id) {
    for (const curDialog of dialogList.filter(val => val.id === id)) {
      curDialog.visible.value = false
    }
    document.querySelectorAll(`#${id}`).forEach(val => val.remove())
  } else {
    for (const dialog of dialogList) {
      dialog.visible.value = false
    }
  }
}
export function installDialog(dlgList: Component[]) {
  for (let comp of dlgList) {
    diaLogMap[comp.name as string] = comp
  }
}
​

代码分析

上述代码使用了vue的一个特性,即在组件中on-前缀的属性会被自动解析为事件监听器

  • 故只要在你想关闭弹窗的按钮上绑定emit confirm事件,点击后即可触发useDialogShow的.then事件

如果visible没有通过emit事件就触发了visible的关闭,则会触发useDialogShow的.catch事件

  • 而当传递了onConfirm事件后,默认的onConfirm则会被覆盖,这时你便可以在onConfirm的回调中对useDialogControl暴露出来的数据进行处理,无论是返回值/visible还是触发自定义的任意回调函数
  • 使用installDialog即可注册弹窗组件,根据name使用字符串调用

还存在的问题&结语

对于我个人最近一年的开发经历,这样管理弹窗基本覆盖100%的弹窗场景,但还是存在一些痛点

  • 这样调用后有时开发时会不触发热更新,还没找到好的解决方式
  • 由于是将弹窗过程化了,在外部比较难在不点击弹窗按钮的前提下去操作弹窗,不过对于模态弹窗来说,基本不存在这种场景

总的来说,自从使用该函数后,刚参加工作时最麻烦最恶心的各种弹窗交互现在用起来舒服了很多,体感可能还存在可优化的点以及不规范的地方,希望路过的大佬不吝赐教