假如你的页面上有几十个弹窗,你会怎样优雅地展示它们?🧐

1,261 阅读6分钟

🥇 前言

最近在做的一个项目当中,一个页面少则几个弹窗,多则几十个,每个弹窗内部都是极其复杂的表单或者有很多的交互逻辑,相信每一位前端开发者面对这样的情况,都会选择把这些弹窗封装成一个个组件,然后引入到页面里去使用,这不仅能让主页面文件变得更加简洁清爽,而且还把关联性较强的代码逻辑和UI聚合到一块,可读性更强,后期更容易维护。
那做到这一点真的就万事大吉了嘛?如果页面中有几十个这样的弹窗呢。比如在一个大型电商后台中,一个订单涉及到商品、物流、账单、售后、运营、商家等等还有很多行业相关的信息,每一个业务需求下都有很多种交互,每一个交互都会有一个弹窗,组件数量很容易就膨胀到几十个。那如果仅仅是封装组件去引用它,代码将会是什么样子?

🥈 普通模式

接下来我用最普通的方式演示一下,我这里用 vite + vue3 + ant-design-vue 创建一个 demo 项目:

弹窗组件

这里我封装了三个弹窗组件:modal1.vue、modal2.vue、modal3.vue(为了演示,案例中这三个组件都是一样的,实际项目中肯定是不同的而且非常复杂的)

image.png

展示弹窗

然后分别在主页面文件中引入它们并且展示到页面上。

image.png

相信大家已经看出来弊端了,就这么简单的三个弹窗,它们的展示逻辑是完全重复的,template 也是重复的。每一个组件都要在 template 中写一遍,并且要给每一个弹窗绑定一个 visible,点击按钮把对应的 visible 改为 true。假如页面上有几十个弹窗,我们即便对组件进行了封装,我们还是要写这些完全重复的逻辑和 template。
在实际项目当中,页面里肯定还有其他逻辑和ui,这些弹窗逻辑很有可能会分散在文件的各个位置,导致你查看它们的时候在编辑器里跳来跳去很难受。
那我们是不是可以把这些逻辑和 template 也封装起来,接下来介绍另一种方式去展示我们的弹窗。

🥉 动态组件 + hook 模式

弹窗组件还是那三个,我们换一种方式去展示。

动态组件

首先创建一个动态组件,用来展示我们的弹窗组件,组件内部整合外部传入的属性和绑定的事件。

image.png

hook

然后再写一个hook,使用 vueuse 的 createGlobalState 来创建这些弹窗需要共享的状态。

image.png

为了使用时逻辑更加清晰,我们可以再封装一个组合式函数,把我们的组件导入逻辑从vue页面里抽离出来。(这一步可以省略,在组件中直接使用 onToggleComponent 切换要显示的弹窗组件即可)

image.png

展示弹窗

最后是展示组件,使用前面组合式函数暴露的 showModal 函数显示,核心是内部调用了 onToggleComponent 函数。

image.png

🏆 总结

从代码中可以看到我们的 template 中只有一个组件 UnityModal,无论未来我们页面中有多少个弹窗,我们的 template 都不会显得臃肿。只需要在 use-show-modal 中导入一下(或者忽略这一步,直接在页面里使用 onToggleComponent),然后在页面引入使用就行,并且按钮和弹窗逻辑聚合在一起,让 ui 和逻辑更加紧密清晰有条理,只需要看一下这个数组我就能知道我的页面是什么样子并且有哪些交互,一目了然,维护起来也非常方便。
这种封装并没有破坏弹窗组件的可扩展性,你想给组件传递任何属性、绑定任何事件都可以通过这个函数传参的形式去做到。
大家有没有觉得这种方式更加优雅一点呢?有什么不同意见或者更好的想法可以在评论区讨论一下。👇👇👇

ps:
2025-01-10 更新:史诗级 ⚡ 宇宙最强 🏆 vue3 函数式弹窗 🚀

⌨ 源码

unity-modal.vue

<template>
  <component :is="modalComponent?.default" v-model:visible="visible" v-bind="renderAttrs" />
</template>

<script setup lang="ts">
import { useUnityModal } from '@/hook/use-unity-modal'
import { computed, unref, useAttrs } from 'vue'
import { upperFirst, camelCase, kebabCase } from 'lodash-es'

defineOptions({ inheritAttrs: false })

const { visible, injectAttrs, modalComponent } = useUnityModal()

const attrs = useAttrs()
/** 组件默认公共属性 */
const commonAttrs = computed(() => {
  const newAttrs: any = {}
  // 添加外面组件外传入的公共事件
  modalComponent.value?.default.emits?.forEach((key: any) => {
    // 事件名称转换大驼峰
    const emitKey = 'on' + upperFirst(camelCase(key))
    if (attrs[emitKey]) {
      newAttrs[emitKey] = attrs[emitKey]
    }
  })
  // 添加外面组件外传入的属性
  for (const key in modalComponent.value?.default.props || {}) {
    const propKey = kebabCase(key)
    if (attrs[propKey]) {
      newAttrs[propKey] = attrs[propKey]
    }
  }
  return newAttrs
})

/** 结合默认属性和注入的属性 */
const renderAttrs = computed(() => {
  const newAttrs = { ...commonAttrs.value }
  // 仅传入有值的属性
  for (const key in injectAttrs.value) {
    if (typeof injectAttrs.value[key] !== 'undefined') {
      newAttrs[key] = unref(injectAttrs.value[key]) // 支持ref数据转入
    }
  }
  return newAttrs
})
</script>

use-unity-modal.ts

import { ref } from 'vue'
import { createGlobalState } from '@vueuse/core'

export const useUnityModal = createGlobalState(() => {
  const visible = ref(false)
  const injectAttrs = ref<Record<string, any>>({})
  const modalComponent = ref()

  /**
   * 切换UnityModal组件渲染的弹窗
   * @param com 组件,可传入两种类型,1.直接函数格式返回import动态导入 2.包含component属性的对象类型
   * @param attrs 弹窗组件属性,可使用`on事件`方式添加事件方法,属性支持Ref类型进行绑定以实现动态变化
   * */
  async function onToggleComponent(com: any, attrs?: Record<string, any>) {
    try {
      // 兼容直接传入component对象格式与直接导入格式
      if (typeof com === 'function') {
        modalComponent.value = await com()
      } else if (typeof com === 'object' && com?.component) {
        modalComponent.value = await com.component()
      }
      injectAttrs.value = attrs || {}
      visible.value = true
    } catch (e) {
      console.error(e)
    }
  }

  return {
    visible,
    injectAttrs,
    modalComponent,
    onToggleComponent
  }
})

use-show-modal.ts

import { useUnityModal } from '@/hook/use-unity-modal'

const { onToggleComponent } = useUnityModal()

export default function useShowModal() {
  const modal = {
    modal1: {
      title: '弹窗1',
      component: () => import('../components/modal1.vue')
    },
    modal2: {
      title: '弹窗2',
      component: () => import('../components/modal2.vue')
    },
    modal3: {
      title: '弹窗2',
      component: () => import('../components/modal3.vue')
    }
  }

  function showModal<N extends keyof typeof modal>(modalName: N, attrs?: Record<string, any>) {
    onToggleComponent(modal[modalName], {
      title: modal[modalName].title,
      ...(attrs ? attrs : {})
    })
  }

  return { showModal }
}

index.vue

<template>
  <div>
    <a-button v-for="btn in buttons" type="primary" @click="btn.onClick">{{ btn.label }}</a-button>
    <UnityModal />
  </div>
</template>
<script setup lang="ts">
import UnityModal from '@/components/unity-modal.vue'
import useShowModal from './composables/use-show-modal'
import { message } from 'ant-design-vue'

const { showModal } = useShowModal()
const buttons = [
  {
    label: '按钮1',
    content: '弹窗1内容',
    onClick: () => {
      showModal('modal1', {
        content: '弹窗1内容',
        onConfirm: () => message.success('弹窗1点击了确定, 进行1业务相关操作')
      })
    }
  },
  {
    label: '按钮2',
    content: '弹窗2内容',
    onClick: () => {
      showModal('modal2', {
        content: '弹窗2内容',
        onConfirm: () => message.success('弹窗2点击了确定, 进行2业务相关操作')
      })
    }
  },
  {
    label: '按钮3',
    content: '弹窗3内容',
    onClick: () => {
      showModal('modal3', {
        content: '弹窗3内容',
        onConfirm: () => message.success('弹窗3点击了确定, 进行3业务相关操作')
      })
    }
  }
]
</script>

📜 参考文章

在做项目的过程中,发现 VueUse 一个很鸡肋的 hook 🧐
⚡ VueUse createGlobalState 之 effectScope 全面解读 🚀