你需要更优雅的 Dialog/Modal 使用姿势!弹窗组件如何实现业务自驱!

你好,我是泰罗凹凸曼,弹窗组件是我们平时开发中使用的最多的组件之一,那么如何更优雅的使用弹窗组件,怎么样封装弹窗组件才更利于业务,怎么样让代码结构变得更清晰,已经是一个势在必行的问题了。

想得多不如做的多,做得多不如看得多,我的思路不代表最优秀的设计,仅是我日常工作中的一些缩影,希望可以帮助到你!

常见的弹窗

鉴于Vue的状态驱动的模型,我们的大部分弹窗都是状态驱动的,这是很完美的,也是很舒服的一种使用方式,但是我们很容易就会写出类似于下面的代码:

<template>
  <button @click="openDialog"></button>
  
  <some-dialog v-model:visible="showDialog" @success="handleSuccess" @close="closeDialog" />
</template>

<script setup>
const showDialog = ref(false)

function openDialog() {
    showDialog.value = true
}

function handleSuccess(value) {
  something.value = value
  showDialog.value = false
}

function closeDialog() {
    showDialog.value = false
}
</script>

这种写法是没问题的,但是这只是最简单的一种使用方式,基于上面的状态驱动的方式,我们很容易就可以写出很糟糕的代码,看看吧:

<template>
  <button @click="openDialogA"></button>
  <button @click="openDialogB"></button>
  <button @click="openDialogC"></button>
  <button @click="openDialogD"></button>
  <button @click="openDialogE"></button>
  
  <some-dialog-a v-model:visible="showDialogA" @success="handleSuccessA" @close="closeDialogA" />
  <some-dialog-b v-model:visible="showDialogB" @success="handleSuccessB" @close="closeDialogB" />
  <some-dialog-c v-model:visible="showDialogC" @success="handleSuccessC" @close="closeDialogC" />
  <some-dialog-d v-model:visible="showDialogD" @success="handleSuccessD" @close="closeDialogD" />
  <some-dialog-e v-model:visible="showDialogE" @success="handleSuccessE" @close="closeDialogE" />
</template>

<script setup>
const showDialogA = ref(false)

function openDialogA() {...}
function handleSuccessA(value) {...}
function closeDialogA() {...}

const showDialogB = ref(false)

function openDialogB() {...}
function handleSuccessB(value) {...}
function closeDialogB() {...}
  
// ...
</script>

这样的代码,我们很容易就会发现,我们的代码逻辑就变得异常的复杂,这样的代码,如果有人要维护,那么就会变得异常的痛苦,因为代码的耦合度太高了,代码的可维护性就变得异常的差。

业务耦合

我们再看看,如果我们的业务逻辑变得更复杂的话,我们的代码会变成什么样子呢?

<template>
  <button @click="openDialog"></button>
  
  <some-dialog v-model:visible="showDialog" :isEdit="isEdit" :isCreate="isCreate" :isDetail="isDetail" @success="handleSuccess" @close="closeDialog" />
</template>

<script setup>
const showDialog = ref(false)
const isEdit = ref(false)
const isCreate = ref(false)
const isDetail = ref(false)

function openDialog() {
  showDialog.value = true
  isEdit.value = true
  isCreate.value = false
  isDetail.value = false
}

function handleSuccess(value) {
  something.value = value
  showDialog.value = false
}

function closeDialog() {
  showDialog.value = false
  isEdit.value = false
  isCreate.value = false
  isDetail.value = false
}
</script>

相信诸位应该很容易在公司的业务代码中看到如上的代码片段,一个部分的改动有可能牵扯到全局的改动,大家可以想一想,如果,我又需要在弹窗中加一个编辑功能 isEdit,我需要修改的内容到底有多少。

如果再夹杂一下其他的业务逻辑的话,那么这些弹窗的事件和状态将让整个逻辑变得异常臃肿!

所以我们就从组件使用者的角度出发,看看如何才能更优雅的去封装组件,我们今天的目标是:消除逻辑耦合,组件业务自驱!

从需求出发

“能力在修炼在产出结果之后,思维的修炼在产出结果之前”,我们先准备一个简单的小需求,一步步带大家了解业务的复杂度是如何一步一步提高的。

我们先来看看,我们的需求是什么样子的?

  • 准备一个学生名单,存在三个按钮,编辑,详情,删除
  • 再准备一个新增学生的按钮
  • 点击新增按钮。弹出新增学生的弹窗
  • 点击编辑按钮,弹出新增学生的弹窗,不过当前的状态是编辑状态
  • 点击详情按钮,弹出学生信息的弹出,就是新增弹窗的复用,内部的字段 disabled 或者是将表单字段替换为纯文本字段
  • 点击删除按钮,弹出删除确认的弹窗,需要输入删除原因

相信大家做这个需求还是游刃有余的,但是随着需求的越来越多,我们来看看具体会变成什么样子!

特意为大家准备了小需求的实现代码:戳这里

我们先看最基本的一个版本,也是状态驱动最常见的一个版本!我删除了一些不必要的代码,只保留了核心的代码,完整的代码可以戳这里

<script setup lang="ts">
  const studentList = ref<Student[]>(mockStudent)

  const showDetail = ref(false)
  const isForDetail = ref(false)
  const editStudent = ref<Student>()

  const toEdit = (stu?: Student, isDetail = false) => {
    showDetail.value = true
    editStudent.value = stu
    isForDetail.value = isDetail
  }

  const handleStudentSave = (stu: Student) => {
    // 如果 editStudent 中的 id 不为空,则为编辑,否则为新增
    if (stu.id != null) {
      const existIndex = studentList.value.findIndex(es => es.id === stu.id)
      if (existIndex >= 0) {
        studentList.value[existIndex] = {
          ...stu,
        }
      }
    }
    else {
      studentList.value.push({
        ...stu,
        id: studentList.value.length,
      })
    }
  }
</script>

<template>
  <div class="student-list">
    <el-button type="primary" @click="toEdit()">
      新增学生
    </el-button>
    <div class="list-wrap">
      <div v-for="stu in studentList" :key="stu.id" class="list-item">
        <div>{{ stu.name }}</div>
        <div>{{ stu.age }}</div>
        <div>{{ stu.gender === 'F' ? '女' : '男' }}</div>
        <div>
          <el-button type="primary" size="small" @click="toEdit(stu)">
            编辑
          </el-button>
          <el-button type="info" size="small" @click="toEdit(stu, true)">
            详情
          </el-button>
          <el-button type="danger" size="small">
            删除
          </el-button>
        </div>
      </div>
    </div>
    <StudentDetail v-model="showDetail" :edit-student="editStudent" :is-for-detail="isForDetail" @save="handleStudentSave" />
  </div>
</template> 

这是这个需求最最最基础的版本,我们还没有添加众多的迭代,那么一个详情弹窗对我们代码的侵入性有多强?

  • 我们添加了 showDetail 控制弹窗的显隐
  • 我们添加了 toEdit 来实现打开弹窗,控制其是新增,编辑或者详情
  • 我们添加了 handleStudentSave 方法来处理弹窗中的返回值
  • 我们还添加了 isForDetail 来控制是不是显示详情而非编辑

这一系列的状态变更,仅仅是我们需要一个学生的编辑弹窗,如果,我需要该页面再添加一个新增班级的弹窗呢?再添加一个新增老师的弹窗呢?复杂的业务级需求在实际工作中并不少见!

到如此,我们还没有实现删除功能,也就是说,我的状态又要多加一层!

可预见的是,当需求越来越复杂的时候,不可避免的我们的代码中会出现各种各样的状态声明,如 showXXXhandleXXXtoXXXisXXX,难道没办法了吗?当然有的!

useDialog

我们知道,在Vue3里面,可以使用 Composition API 来完成组件逻辑的封装,使用方式也类似于 React hooks,所以一般来说,我一般喜欢称之为 Vue hooks,如何使用 hooks 来封装弹窗逻辑?

简单来讲,我们学生弹窗的业务属于学生弹窗的部分,不属于列表的业务需求,那这么多和列表页面耦合的状态我们能忍吗,当然不能忍!

hooks 在拆分业务逻辑上是有非常好的优势的,我们来看看如果使用了 hooks 的话,我们的代码会变成什么样子!

我们将业务逻辑一股脑的交给 useStudentDetail 去处理:

完整代码 戳这里

<script setup lang="ts">
  const {
    showDetail,
    isForDetail,
    editStudent,
    toEdit,
    handleStudentSave,
  } = useStudentDetail(studentList)
</script>

<template>
  <StudentDetail
    v-model="showDetail"
    :edit-student="editStudent"
    :is-for-detail="isForDetail"
    @save="handleStudentSave"
  />
</template>

还是有点累赘,如果我在组件调用的时候需要知道useStudentDialog中的每一个参数与其含义,那么对使用者来说,是一种重大的心理负担,项目如果按照这种模式发展下去的话,那么就会越来越难以维护!

可不可以在使用 hooks 的时候,不需要知道每一个参数的含义,只需要调用即可:

如这样的形式呢?

<script setup lang="ts">
  const { component: StudentDetail, toEdit } = useStudentDetail(studentList)
</script>
 
<template>
  <StudentDetail />
</template>

那是必须可以的!

在我们的 useStudentDetail 中,声明 component,借助 Vue 提供的 h 函数去渲染主要的弹窗,并代理其 props:

const component = defineComponent(() => {
  return () => h(StudentDetail, {
    'modelValue': showDetail.value,
    'onUpdate:modelValue': (v: boolean) => (showDetail.value = v),
    'isForDetail': isForDetail.value,
    'editStudent': editStudent.value,
    'onSave': handleStudentSave,
  })
})

return {
  component,
  toEdit,
}

如上,我们就可以在组件中使用 component 来渲染弹窗了,而不需要知道 useStudentDetail 中的每一个参数的含义!这里是源码

不过,我们是不是满足于当前的现状呢?我们还可以做得更好!

useDialog Promise

如果我们不想显式的导出 toEdit 的方法,且需要完全拆分业务逻辑的话,那么显示学生弹窗、修改信息、应该是属于学生弹窗的业务逻辑,但是保存学生信息,修改列表是列表组件的业务逻辑,那么这两段逻辑需要完全独立

如果不想显式导出 toEdit 方法,我们可以这么做:

const component = defineComponent(() => {
  return () => h(StudentDetail, {
    'modelValue': showDetail.value,
    'onUpdate:modelValue': (v: boolean) => (showDetail.value = v),
    'isForDetail': isForDetail.value,
    'editStudent': editStudent.value,
    'onSave': callback,
  })
})

component.toEdit = toEdit
return component

这样子,我们在使用的时候就可以这样子使用了:

<script setup lang="ts">
  const StudentDetail = useStudentDetail(studentList)

  StudentDetail.toEdit(stu, true)
</script>

<template>
  <StudentDetail />
</template>

callback 从什么地方来呢?我们可以直接在 toEdit 方法中返回一个 Promise,用来回调修改后的学生信息,并且将保存学生的业务逻辑交给列表组件来处理:

const toEdit = (stu?: Student, isDetail = false): Promise<Student> => {
  showDetail.value = true
  editStudent.value = stu
  isForDetail.value = isDetail

  return new Promise((resolve) => {
    callback = resolve
  })
}

这样子,我们在列表组件中定义的 toEdit 函数用来完成调用弹窗和保存信息的作用:

async function toEdit(stu?: Student, isDetail = false) {
  const editedStudent = await StudentDetail.toEdit(stu, isDetail)
  // 如果 editStudent 中的 id 不为空,则为编辑,否则为新增
  ...
}

这也是 @antfu 大佬开源的 vue-template-promise 中的思想,本段源码可以戳这里

如果,我并不想导入任何 hooks, 也不想植入组件到当前页面,我想一句话直接使用,如 StudentDetail.toEdit({ ... }),如何做?

ElementUI 是如何实现的

在 Element 中,我们经常会这样子使用弹窗组件:

import { ElMessageBox, ElMessage, ElNotification } from 'element-plus'

ElMessageBox.confirm('Are you sure to close this dialog?')

ElMessage({
  type: 'info',
  message: `action`,
})

ElNotification({
  title: 'Warning',
  message: 'This is a warning message',
  type: 'warning',
})

如果我想直接在页面中导入 StudentDetail,然后如下使用:

import StudentDetail from '@/components/student-detail'

StudentDetail.toEdit(stu, true)

我们来看看 ElementUI 是如何实现的!

在 ElementUI 源码的这个位置,我们可以看到:

  • 创建了一个 vnode 对象,将 MessageBox 组件挂载到 vnode
  • vnode 挂载到 body 或者指定的 dom

可以简单理解的是,将我们的弹窗组件挂载到 vnode 上,然后将 vnode 挂载到 body 上,这样子就可以直接使用了!

我们尝试修改一下这个 toEdit 方法,让其可以自动实现挂载和卸载:

function toEdit(stu?: Student, isDetail?: boolean, appContext?: AppContext): Promise<Student> {
  return new Promise((resolve) => {
    const vnode = h(StudentDetail, {
      onSave(editStu: Student) {
        resolve(editStu)
      },
    })
    vnode.appContext = appContext!

    const container = document.createElement('div')
    // 进行渲染
    render(vnode, container)
    vnode.component?.exposed?.open(stu, isDetail)
  })
}

接着我们要修改一下 StudentDetail 组件,让其可以接受 open 方法:

const emit = defineEmits(['save'])
const showDialog = ref(false)
const student = ref<Student>({})
const isForDetail = ref(false)

function saveStudent() {
  emit('save', student.value)
  showDialog.value = false
}

defineExpose({
  open: (stu: Student, isDetail = false) => {
    showDialog.value = true
    student.value = { ...stu }
    isForDetail.value = isDetail
  },
})

我们把原属于 StudentDialog 的内容完全交于组件本身进行管理,将编辑后的内容,如编辑学生或者新增学生的操作交于外部组件进行处理,这样子就完全实现了业务自驱!

怎么使用呢?

// 在列表组件中定义方法,用于打开弹窗,编辑或新增等操作完全交给弹窗自己处理,处理后的学生信息由列表组件自己处理
async function toEdit(stu?: Student, isDetail = false) {
  const editedStudent = await StudentDetail.toEdit(stu, isDetail, instance!.appContext)
  // 如果 editStudent 中的 id 不为空,则为编辑,否则为新增
  ...
}

当然,缺点也很明显!我们需要在调用的时候传递 instance!.appContext 给组件,是因为我们使用了 vnode 去挂载组件的话,组件相当于重新创建了一个 Vue 实例,这样子会导致我们在组件内部的直接使用的 ElementUI 组件无法被识别,需要解决这个问题,我们一般有两种解决方案:

  • 传递本组件的 appContext 给子组件,让子组件使用 appContext 来创建 Vue 实例
  • 在子组件内部手动挂载使用到的相关组件

Element 的实现是兼容以上两种模式的,可以传递 appContext 或者不传递,感兴趣的同学可以自己了解一下!

源码可以戳这里

如何实现业务自驱

相信诸位看过上面的描述之后已经有了自己的答案!

我们认为,各组件的业务除非是强关联性,否则其业务都应该实现业务自驱,也就是说,组件内部应该实现自己的状态管理,而不是依赖于外部的状态管理,这样子的好处是:

  • 更容易的使用一个组件,只需要了解基本的功能而不用摸清楚其每个参数的含义
  • 一次定义,多处使用,因为其相对来说使用更加简单

此定义不适用于基础组件,如 ButtonInput 等,因为这些组件的功能是相对简单的,而且其功能也不是强关联性的,所以我们可以将其作为基础组件,而不是业务组件,业务组件都是具备一定的关联性的,如 StudentDetailStudentList 等,我们的目的就是解耦这些关联性,使其变得单一并好用!

借助于上方的Dialog示例,我们可以为弹窗抽离出一个简单好用的方法,这就是我们解决强关联组件的方法之一,这不代表着唯一答案,而是一种思路,我们可以根据自己的实际情况去选择合适的方案!

命令驱动 or 状态驱动 ?

状态驱动是 Vue 的优势之一,但如果项目变得很大,一个组件内的状态会随着需求越加越多,到最后就会变成”屎山”,这是我们对一个复杂难以维护的代码做出的一种评价,业内对于状态驱动或者命令驱动的讨论也是经久不衰,我们也不必须做更多的讨论,具体可以看看这里:www.zhihu.com/question/35…

我们拆分独立业务,做到业务自驱的方法是一直在践行着命令驱动,但这一定不是最优解,你需要不断地去探索,去追求!

结语

今天带大家了解了一下如何做到 Dialog/Modal 组件的业务自驱,讲的也不是很全面,希望能帮助到你,如果你有更好的解决方案,欢迎在评论区留言!

我们在工作的时候会遇到各种各样的问题,但是我希望大家可以静下心来,磨刀不误砍柴工,当一个项目已经进行到难以维护的地步的时候,想再做优化就为时已晚,继续加油吧!

去探索,不知道的东西还多着呢,我是泰罗凹凸曼,M78星云最爱写代码的,我们下一篇再会!

本文正在参加「金石计划」

原文链接:https://juejin.cn/post/7220410926561738809 作者:泰罗凹凸曼

(0)
上一篇 2023年4月11日 上午10:38
下一篇 2023年4月11日 上午10:48

相关推荐

发表回复

登录后才能评论