Vue3 中后台项目中如何封装一个好用的 Dialog

本文以 Vue3 开发中最常用的第三方组件库的 Element-Plus 为例,向大家介绍在 Vue3 中如何封装一个通用的中后台项目的弹窗。

为什么需要封装弹窗

以 Element-Plus 弹窗最基础的弹窗举例

JS 复制代码
<template>
  <el-button text @click="dialogVisible = true">
    click to open the Dialog
  </el-button>

  <el-dialog
    v-model="dialogVisible"
    title="Tips"
    width="30%"
    :before-close="handleClose"
  >
    <span>This is a message</span>
    <template #footer>
      <span class="dialog-footer">
        <el-button @click="dialogVisible = false">Cancel</el-button>
        <el-button type="primary" @click="dialogVisible = false">
          Confirm
        </el-button>
      </span>
    </template>
  </el-dialog>
</template>

<script lang="ts" setup>
import { ref } from 'vue'
import { ElMessageBox } from 'element-plus'

const dialogVisible = ref(false)

const handleClose = (done: () => void) => {
  ElMessageBox.confirm('Are you sure to close this dialog?')
    .then(() => {
      done()
    })
    .catch(() => {
      // catch error
    })
}
</script>
  • 开发者建立一个弹窗就需要维护一个 visible 状态去控制弹窗的显示,然后重复的进行状态绑定,较为繁琐
  • 中后台项目中弹窗多应用于表单提交以及业务反馈,因此点击事件具备可复用的业务逻辑
  • 声明式描述弹窗在会导致逻辑分散化,也就是说导致事件的绑定回调函数东一块,西一块。

引用弹窗的理想方式

声明式构建 dialog 对开发者来说确实不够友好,那么 Element-Plus 没有去解决么?

但事实上,主流的组件库 Element-Plus、Ant-Design 等已经提供了 Message-Box 的组件以及的 Modal confirm 去构建类似的消息提示窗。

JS 复制代码
<template>
  <el-button plain @click="open">Common VNode</el-button>
  <el-button plain @click="open1">Dynamic props</el-button>
</template>

<script lang="ts" setup>
import { h, ref } from 'vue'
import { ElMessageBox, ElSwitch } from 'element-plus'

const open = () => {
  ElMessageBox({
    title: 'Message',
    message: h('p', null, [
      h('span', null, 'Message can be '),
      h('i', { style: 'color: teal' }, 'VNode'),
    ]),
  })
}

const open1 = () => {
  const checked = ref<boolean | string | number>(false)
  ElMessageBox({
    title: 'Message',
    // Should pass a function if VNode contains dynamic props
    message: () =>
      h(ElSwitch, {
        modelValue: checked.value,
        'onUpdate:modelValue': (val: boolean | string | number) => {
          checked.value = val
        },
      }),
  })
}
</script>
JS 复制代码
import React from 'react';
import { ExclamationCircleOutlined } from '@ant-design/icons';
import { Button, Modal } from 'antd';

const { confirm } = Modal;

const destroyAll = () => {
  Modal.destroyAll();
};

const showConfirm = () => {
  for (let i = 0; i < 3; i += 1) {
    setTimeout(() => {
      confirm({
        icon: <ExclamationCircleOutlined />,
        content: <Button onClick={destroyAll}>Click to destroy all</Button>,
        onOk() {
          console.log('OK');
        },
        onCancel() {
          console.log('Cancel');
        },
      });
    }, i * 500);
  }
};

const App: React.FC = () => <Button onClick={showConfirm}>Confirm</Button>;

export default App;

这也是大部分开发者封装 Dialog 的思路,组件对外暴露的接口就是一个函数,开发者需要自定义内容需要修改传参,同时函数提供传参也提供了足够灵活的方式对外拓展。

但是实际上 Element-Plus 这种方式上在业务开发中并不好用,原因就是协同项目开发中,无论是 Vue 的 h 函数、还是 React 的 createElement ,都不应该作为构建大型组件的方式。

该种方式具备多种缺陷

  • 并不具备可读性
  • 对 Vue 开发者来说,需要增加协同开发者的学习成本
  • 不好维护
  • 本末倒置,函数本身就可以通过编译器解析出来,为什么还要让开发者不去写 DSL,而去实现编译后的函数代码

笔者更推崇的是 Ant-Design 的封装方式,但是在公司内部实践中, JSX、TSX 并不是推荐的实现方式,虽然 Vue3 在 JSX 的开发体验已经逐渐接近于模板,但是考虑到性能、协同开发以及目前 Vue 的主流方式仍是模板,编译器无法在同一个文件进行模板 TSX 混淆编译,所以,笔者看来,建立弹窗的最好方式仍是通过模板进行二次封装组件库的弹窗组件,然后进行复用。

如何封装一个好用的中后台 dialog

写代码前,我们考虑下封装目标,就以 confirm 为例,封装目标力求达到 Ant-Design 的 confirm 的同等开发体验

既然使用模板,我们就需要在使用文件模板内部使用弹窗组件,同时为了让组件更为灵活,我们同样的构建三个插槽对外使用

footer 的默认内容就按照最常用的业务模型进行构建,也就是一个取消、一个确认,右上角的 x ,笔者并没有配置,这里目的是对 dialog 之前的内部渲染模块进行覆盖,有业务需求再进行补充

组件对外使用时,为增强对 ElDialog 的灵活度,提供 v-bind="$attrs" 以供外部 prop 对 ElDialog 进行更小粒度的配置

js 复制代码
<template>
  <ElDialog
    :show-close="false"
    v-model="visible"
    title="dialog"
    width="30%"
    align-center
    v-bind="$attrs"
  >
    <template #header>
      <slot name="header"> </slot>
    </template>

    <template #footer>
      <slot name="footer">
        <ElButton
          @click="onCancel"
          >取消</ElButton
        >
        <ElButton
          @click="onConfirm"
          >确认</ElButton
        >
      </slot>
    </template>
  </ElDialog>
</template>

<script lang="ts">
import { defineComponent, ref } from 'vue'
export default defineComponent({
  interface DialogConfig {
  handleCancel: () => Promise<any>
  handleConfirm: () => Promise<any>
}


props: {
    dialogConfig: {
      type: Object as PropType<DialogConfig>,
      default: () => ({
        handleCancel: () => Promise.resolve(),
        handleConfirm: () => Promise.resolve(),
      })
    }
  },

  setup(props, ctx) {
    const visible = ref<boolean>(false)
    const openDialog = () => (visible.value = true)
    const closeDialog = () => (visible.value = false)
    const onCancel = async () => {

    }
    const onConfirm = async () => {
    }
    ctx.expose({
      openDialog,
      closeDialog
    })

    return {  visible, onConfirm, onCancel }
  }
})
</script>

这里笔者为组件设计了 4 个接口

  • openDialog 开启弹窗
  • closeDialog 关闭弹窗
  • handleConfirm 异步确认
  • handleCancel 异步取消

为什么需要异步确认和异步取消,因为同步的事件绑定并不能满足业务需求。

以表单提交为例,点击确认按钮后,外部组件将表单数据进行请求确认,确认按钮要处于 loading 状态避免重复点击, 按钮的 loading 状态请求的状态来确认,弹窗的关闭与否也是同理,表单数据不符合要求则进行重填,符合则进行弹窗关闭。实际上,Element-Plus 的 dialog 也提供了足够的 prop来确认弹窗关闭前后的回调函数

但是并不完整,且分散在多个 prop 属性跟事件,弹窗的开启到关闭对开发者来说是线性的,如点击按钮之后干什么,成功后做什么,失败后做什么,另一方面是分散在各处的 prop 并不有利于组件维护,这也是构造 dialogConfig 一个 prop 的目的。

如何使用组件呢,笔者这里是通过 ref 引用到实例,然后通过组件暴露的 expose 方法进行弹窗打开关闭的控制。

js 复制代码
<template>
  <div>
    <ElButton @click="handleClick1"> 点击触发 </ElButton>
    <DialogComponent  ref="dialogRef1"> </DialogComponent>


  </div>
</template>

<script setup lang="ts">
import DialogComponent from '@/components/modal/DialogComponent.vue'
import { ref } from 'vue'
const dialogRef1 = ref()

const handleClick1 = () => {
  if (dialogRef1.value) {
    dialogRef1.value.openDialog()
  }
}
</script>

之后就是对异步逻辑的实现,接口期望传入的 handleConfirm 和 handleCancel 为一个 Promise,然后在弹窗默认的确认和取消的绑定方法中进行调用,同时进行业务默认逻辑与传入异步逻辑的执行熟悉的确认,同时再为取消、确认按钮补充 prop 的传入,提高灵活性。

js 复制代码
<template>
  <ElDialog
    :show-close="false"
    v-model="visible"
    title="dialog"
    width="30%"
    align-center
    v-bind="$attrs"
  >
    <template #header>
      <slot name="header"> </slot>
    </template>

    <template #footer>
      <slot name="footer">
        <ElButton
          @click="onCancel"
          v-bind="$props.dialogConfig?.cancelProps"
          :loading="cancelLoading"
          >取消</ElButton
        >
        <ElButton
          v-bind="$props.dialogConfig?.confirmProps"
          @click="onConfirm"
          :loading="confirmLoading"
          >确认</ElButton
        >
      </slot>
    </template>
  </ElDialog>
</template>

<script lang="ts">
import { defineComponent, ref, type PropType, onMounted, onUnmounted } from 'vue'
interface DialogConfig {
  handleCancel: () => Promise<any>
  handleConfirm: () => Promise<any>
  handleError?: (e: unknown) => unknown
  confirmProps?: any
  cancelProps?: any
}
export default defineComponent({
  props: {
    dialogConfig: {
      type: Object as PropType<DialogConfig>,
      default: () => ({
        handleCancel: () => Promise.resolve(),
        handleConfirm: () => Promise.resolve(),
        confirmProps: {},
        cancelProps: {}
      })
    }
  },
  setup(props, ctx) {
    const visible = ref<boolean>(false)
    const confirmLoading = ref<boolean>(false)
    const cancelLoading = ref<boolean>(false)
    const openDialog = () => (visible.value = true)
    const closeDialog = () => (visible.value = false)
    const onCancel = async () => {
      cancelLoading.value = true
      try {
        await props.dialogConfig.handleCancel()
        closeDialog()
      } catch (error) {
        console.log('error: ', error)
      } finally {
        cancelLoading.value = false
      }
    }
    const onConfirm = async () => {
      confirmLoading.value = true
      try {
        await props.dialogConfig.handleConfirm()
        closeDialog()
      } catch (error) {
        console.log('error: ', error)
      } finally {
        confirmLoading.value = false
      }
    }
    ctx.expose({
      openDialog,
      closeDialog
    })

    const dialogRef = ref()

    return { cancelLoading, confirmLoading, visible, onConfirm, onCancel }
  }
})
</script>

<style lang="scss" scoped></style>

调用时我们模拟一个延迟返回的异步任务进行过测试

JS 复制代码
const dialogConfig1 = {
  handleConfirm: () => {
    return new Promise((resolve, reject) => {
      setTimeout(() => {
        reject(5)
      }, 5000)
    })
      .then((res) => {
        console.log('res11111: ', res)
      })
      .catch((error) => {
        throw error
      })
      .finally(() => {
        console.log('fina')
      })
  },
  handleCancel: () => Promise.resolve(),
  confirmProps: {
    type: 'primary'
  }
}

以上,我们的弹窗就封装完毕了

命令式弹窗的问题

因此,业务中笔者更倾向于使用命令式弹窗,但是开发过程中命令式弹窗就没有任何缺点么?

笔者在开发中,还是遇到不少命令式弹窗的问题的

  • 弹窗内部状态与外部组件的状态的割裂,由于分开书写,外部组件若想对内部弹窗的状态进行修改,需要内部弹窗进行方法的声明与暴露,较为繁琐
  • 不正确的封装导致弹窗的销毁,从而内存泄漏问题
  • 组件难点还有路由跳转未销毁

第一点属于命令式弹窗的短处,但是对于业务单调的中后台系统来说,已经足够使用

第二点需要开发者注意了,弹窗类组件尤其要注意组件的销毁

路由跳转未销毁的问题,我们就需要对弹窗统一管理,以封装的弹窗为例,我们可以维护一系列管理组件实例的方法进行维护

弹窗组件挂载时进行实例注册,销毁时数据也同时移除,然后在声明一个 destroyAll 方法可以根据现存的实例销毁视图上所有的弹窗

JS 复制代码
import type { ComponentPublicInstance, Ref } from 'vue'
interface DialogComponentExpose {
  openDialog: () => void
  closeDialog: () => void
}
type DialogComponentInstance = Ref<ComponentPublicInstance<DialogComponentExpose>>
const dialogSet = new Set<DialogComponentInstance>()

export const registerDialog = (dialog: DialogComponentInstance) => {
  dialogSet.add(dialog)
  console.log('dialogSet: ', dialogSet)
}
export const destroyAll = () => {
  dialogSet.forEach((item) => {
    if (item.value) {
      item.value.closeDialog()
    }
  })
  dialogSet.clear()
}
export const removeDialog = (dialog: DialogComponentInstance) => {
  dialogSet.delete(dialog)
}
JS 复制代码
    onMounted(() => {
      registerDialog(dialogRef)
    })
    onUnmounted(() => {
      console.log('销毁')

      removeDialog(dialogRef)
    })

感谢您阅读本文,希望对您有所帮助。如果您觉得本文对您有价值,请点赞并收藏,以便日后查阅。谢谢!

相关推荐
m0_7482552615 分钟前
easyExcel导出大数据量EXCEL文件,前端实现进度条或者遮罩层
前端·excel
web147862107231 小时前
C# .Net Web 路由相关配置
前端·c#·.net
m0_748247801 小时前
Flutter Intl包使用指南:实现国际化和本地化
前端·javascript·flutter
飞的肖1 小时前
前端使用 Element Plus架构vue3.0实现图片拖拉拽,后等比压缩,上传到Spring Boot后端
前端·spring boot·架构
青灯文案11 小时前
前端 HTTP 请求由 Nginx 反向代理和 API 网关到后端服务的流程
前端·nginx·http
m0_748254881 小时前
DataX3.0+DataX-Web部署分布式可视化ETL系统
前端·分布式·etl
ZJ_.1 小时前
WPSJS:让 WPS 办公与 JavaScript 完美联动
开发语言·前端·javascript·vscode·ecmascript·wps
GIS开发特训营1 小时前
Vue零基础教程|从前端框架到GIS开发系列课程(七)响应式系统介绍
前端·vue.js·前端框架·gis开发·webgis·三维gis
Cachel wood2 小时前
python round四舍五入和decimal库精确四舍五入
java·linux·前端·数据库·vue.js·python·前端框架
学代码的小前端2 小时前
0基础学前端-----CSS DAY9
前端·css