用Vue3和TS封装一个还行的命令式弹窗

1,725 阅读3分钟

用Vue3和TS封装一个还行的命令式弹窗

背景

最近在做toB项目,弹窗+表单提交的使用场景非常多。每次都要去写差不多的 <el-dialog> 模板,传差不多的参数,重复维护它的状态,十分繁琐,所以想实现一个类似element的 $confirm('xxx') 组件那样用法的通用弹窗,来减少写template和维护状态的重复代码。这里记录下我的实现思路,水平有限,感谢大佬们的批评指正。

需求

  1. 支持命令式调用,并且可以使用thencatch来执行弹窗关闭后的回调任务
    import { createDialog } from './common-dialog'
    import Content from './Content.vue'
    
    createDialog({
      title: '窗口标题',
      content: Content
    }).then(() => {
      console.log('确认回调')
    }).catch(() => {
      console.log('取消回调')
    })
    
  2. 支持弹窗组件内部维护弹窗状态,同时需要暴露接口以通知弹窗组件做一些操作
  3. 支持多弹窗嵌套调用

设计与实现

概览

根据需求大概需要的流程如下

image.png

  1. 使用方 Page.vue 通过调用 createDialog(Content) 函数来创建一个弹窗,调用时将内容组件 Content.vue 作为参数传递进去
  2. createDialog 创建一个窗体,将接收的内容组件插入到窗体中,同时将控制窗体的 hide toggleLoading callback 等函数注入到内容组件中供其调用
  3. Content.vue 中实现原本弹窗需要实现的业务,例如填写并提交表单,完成流程后可以调用被注入的 hide 来关闭窗体,调用 callback 来进入调用方 createDialog 的回调流程进行刷新列表等操作

命令式

createDialog() 如何创建窗体?在vue2的时候用的是 new Vue 来创建一个新的实例,这里用到的则是vue3的 createApp

// basic-dialog.tsx
import { createApp, App } from 'vue'
import { ElDialog } from 'element-plus'

let instance: App
export const createDialog = (content) => {
  // 创建实例
  instance = createApp({
    setup() {
      const ContentComponent = () => h(content)
      return () => (
        <ElDialog><ContentComponent /></ElDialog>
      )
    }
  })
  const elem = document.createElement('div')
  elem.id = 'basic-dialog'
  document.body.append(elem)
  // 挂载
  instance.mount(elem)
}

状态维护

维护窗体状态是不太优雅的事情,原本用 <template> 调用弹窗时我们必须至少维护一个窗体的开关状态,如果是表单则需要多一个确认按钮的loading状态,这些其实都不是业务内容却混在业务中,大致是这样

<template>
  <dialog v-model="visible">
    <content />
    <footer>
      <button :loading="loading" @click="handleSumbit">确认</button>
    </footer>
  </dialog>
</template>
<script setup lang="ts">
const visible = ref(false) // 窗体状态
const loading = ref(false) // 确认按钮的loading状态
// 按下按钮需要手动开启按钮loading
const handleSumbit = () => {
  loading.value = true
  // do something ...
  visible.value = false
  loading.value = false
}
</script>

使用命令式弹窗的时候,可以简化这个流程,业务方只需要调用 createDialog 来创建一个窗体,不再关心窗体相关状态,让内容组件去接力流程。

因为命令式弹窗调用的时候实际上已经将窗体的状态 visible 设为 true,我们需要做的是让内容组件可以控制窗体以及loading状态的能力。

有一种实现的方式是约定好确认按钮的函数名(假设叫submit),外层窗体通过 $children[0].submit({ callback, hide, toggleLoading }) 去调用内容组件中约定好的函数,并把控制函数通过参数传入。但这样并不太优雅,而且在vue3中实现会比较曲折。

更好的方式是可以利用 provide 将控制函数注入到内容组件中。

// basic-dialog.tsx

// 规范一下注入函数的类型
export const IHide: InjectionKey<() => void> = Symbol('IHide')
export const IToggleLoading: InjectionKey<(status?: boolean) => void> = Symbol('IToggleLoading')
export const ICallback: InjectionKey<(value?: unknown) => void> = Symbol('ICallback')

let instance: App
export const createDialog = (content) => {
  // 创建实例
  instance = createApp({
    setup() {
      // 控制相关
      const visible = ref<boolean>(true) // 窗体状态
      const [confirmLoading, toggleLoading] = useToggle() // confirm 按钮的 loading 状态
      const hide = () => { // 关闭 dialog 的函数
        toggleLoading(false)
        visible.value = false
      }
      const callback = () => {
        // do something
      }
    
      // 注入
      provide(IHide, hide)
      provide(IToggleLoading, toggleLoading)
      provide(ICallback, callback)
    
      const ContentComponent = () => h(content)
      return () => (
        <ElDialog modelValue={visible.value} onClose={hide}>
          <ContentComponent />
        </ElDialog>
      )
    }
  })
  // ...
}
<!-- Content.vue -->
<template>
  <form>whatever...</form>
</template>
<script setup lang="ts">
import { IHide, IToggleLoading, ICallback } from 'basic-dialog'
import { inject } from 'vue'

// do something...
inject(IToggleLoading)?.(false) // 关闭loading
inject(ICallback)?.('xx结果拿去') // 回调
inject(IHide)?.() // 关闭弹窗
</script>

回调

如何让内容组件 callback 的时候进入调用方的 then 函数呢?我们需要把原先的 createDialog 包装成一个 Promise,然后在注入的 callback 中调用一下 resolve 就可以了

// 原先的 createDialog
const _createDialog = (content, resolve, reject) => {
  // 创建实例
  instance = createApp({
    setup() {
      // ...
      const callback = () => {
        resolve() // 在注入的 callback 中调用一下 resolve
      }
      // ...
    }
  })
}

export const createDialog = (content) => {
  return new Promise((resolve, reject) => {
    _createDialog(content, resolve, reject)
  })
}

嵌套调用

在实际使用中经常有嵌套调用的情况,比如弹窗打开一个表单,在表单中的某项数据需要再次弹窗去选择参数等等,现在的实现是无法满足的。弹窗先创建先销毁的模式很容易想到栈,我们可以使用栈来维护弹窗实例

// 使用一个栈来维护弹窗实例
const dialogStack: { uid: number, instance: App }[] = []
const _createDialog = (content, resolve, reject) => {
  cosnt instance = createApp({
    // ...
  
    // 需要升级一下hide函数
    const hide = () => { // 关闭 dialog 的函数
      toggleLoading(false)
      visible.value = false
      dialogStack.pop() // 将实例取出
    }
    // ...
  })
  // 将实例压入栈中
  dialogStack.push({ uid: instance._uid, instance })
  const elem = document.createElement('div')
  elem.id = 'basic-dialog'
  document.body.append(elem)
  // 挂载
  instance.mount(elem)
}

扩展

封装组件的时候不能失去被封装组件(本案例中是 <ElDialog>)原先的能力,例如调用方需要更改 <ElDialog>width 属性,我们需要暴露修改的方式,这个实现也比较简单,从 createDialog 中传入,绑定到 render 的组件上就可以


const _createDialog = (options, resolve, reject) => {
  cosnt instance = createApp({
    setup() {
      // ...
      
      // 其他属性也可以用相同方式传入和绑定
      return () => (
        <ElDialog
          title={options.title} modelValue={visible.value} onClose={hide}
          { ...(options.dialogAttrs || {})} >
          <ContentComponent />
        </ElDialog>
      )
    }
  })
  // ...
}

// 更多参数
export type Options = {
  title: string
  content: any
  dialogAttrs?: Partial<InstanceType<typeof ElDialog>['props']> // 这样调用时会有类型推断
}
export const createDialog = (options: Options) => {
  return new Promise((resolve, reject) => {
    _createDialog(options, resolve, reject)
  })
}

效果

调用方

import Content from './Content.vue'

// 刷新列表
const refreshList = () => { /** whatever **/ }

// 创建弹窗
const toggleDialog = () => {
  createDialog({
    title: '标题',
    content: Content
  }).then((res) => {
    console.log(res) // ok
    refreshList()
  })
}

内容组件

<!-- Content.vue -->
<template>
  <div>haha</div>
</template>
<script>
import { inject } from 'vue'
import { ICallback, IHide } from 'basic-dialog'

const callback = inject(ICallback)
const hide = inject(IHide)
callback?.('ok')
hide?.()
</script>

不足

  1. 调用方在 createDialog 传入参数到内容组件时没有类型推断
  2. 内容组件为表单提交类型时,调用注入函数还是比较繁琐
  3. 欢迎补充