使用 unoverlay-vue 封装 Vue3 模态(Modal) 类组件

2,631 阅读4分钟

反馈型组件,是前端经常涉及的组件,它们允许向用户提供提醒、提供更多选项或添加额外信息,而不会弄乱主要内容。

该文章可能已经过时,unoverlay-vue 已废弃,由 @unoverlays/vue 代替,具体查看 Unified Overlays 文档

在 Vue 中 Modal 类组件的实现通常分为两种模式:

  • 组件声明模式(declarative
<template>
    <dialog v-model="show" @ok="onOk" @close="onClose">
        <div class="model__title">title</div>
        <div class="model__conent">content</div>
    </dialog>
</template>
<script>
import { defineComponent } from 'vue'
export default defineComponent({
    setup() {
        const show = ref(false)
        const onOk = () => {
            // ...
        }
        const onClose = () => {
            // ...
        }
        return { show, onOk, onClose }
    }
})
</script>

优点:定制化程度高、限制低,可以最大程度的发挥主观设计想法,状态流程可控性强,便于调试。

缺点:复用性差、使用频繁时需要定义多份 { show, onOk, onClose },返回数据获取得通过事件,代码冗余。

  • 函数调用模式(imperative
Modal.confirm({
  title: '...',
  contnet: '...'
})
  .then(() => { /* ok */ })
  .catch(() => { /* close */ })

优点:数据独立与页面独立、复用性、通用性强、业务代码流程易懂、无法继承上下文

缺点:涉及到定制化将很难处理,在第三方组件库中往往需要编写 template 字符串 / jsx 代码。

弹出层组件涉及思路

在前端发展至今为止,这类组件的封装已经有了广泛的累积。

其中函数调用(imperative)无非就是利用了 vuerender 能力,将一个组件直接渲染至 html 当中,

可实际实现还是相当麻烦,需要处理 rendervanish 的时机,还得想办法将处理函数传递给组件自身,且无法和组件声明模式相结合使用,这也导致在很多旧项目无法正常迁移升级这类组件。

并且考虑在实际项目开发当中,我们可能并不会考虑重头实现一个 dialog 组件,而是使用第三方组件库自带的去更改,久而久之就会产生大量的声明式组件(declarative)导致多个页面的逻辑冗余。

综上考虑成本还是过高,但并没有“万策尽きた”,我们可以先明确以下几点要求:

  • 组件可支持组件声明、函数调用两种模式(declarative and imperative
  • 定制化能力强、开发成本低,以最简单的方式制作模态类组件
  • 使用现有组件库(如 element-plus)集成和定制化功能
  • 支持组件继承全局应用上下文
  • 支持 Typescript,类型健全完整

の,听起来很困难,笔者经过长时间的积累,将这套逻辑抽离了出来,形成了一套相对完整的 Vue3 弹出层解决方案 unoverlay-vue,它的体积很小,并且可以满足我们上述的所有要求。

关于实现底层原理方面,本文就不作过多展示了,可以查看笔者以往文章 Vue3 模态(Modal) 模态框封装方案@unoverlays/vue 源码

而 unoverlay-vue 本质上是一个弹出层工具,所以你并不会被 unoverlay-vue 限制任何想做的事情。

首先我们先安装:

pnpm add unoverlay-vue
# Or Yarn
yarn add unoverlay-vue

在 main.js 中全局安装可以使所有弹出层继承上下文

// main.js
import { createApp } from 'vue'
import unoverlay from 'unoverlay-vue'
import App from './App.vue'

const app = createApp(App)
app.use(unoverlay)
app.mount('#app')

实现基础的 Modal 功能

定义 Model 组件,这里以最简案例实现,不包含动画逻辑(可以使用 <Transition> 实现)

<!-- Model.vue -->
<template>
  <div v-show="visible">
    <div>{{ title }}</div>
    <button @click="confirm(`${title}:confirmed`)"> confirm </button>
    <button @click="cancel()"> cancel </button>
  </div>
</template>
<script setup>
import { defineEmits, defineProps } from 'vue'
import { useOverlayMeta } from 'unoverlay-vue'
const props = defineProps({
  title: String,
  // 如果您想将其用作 template 中的组件使用,
  // 你需要在 props 中定义 visible 字段
  visible: Boolean
})

// 定义组件中使用的事件(可选)
// 在组件中使用会有事件提示
defineEmits(['cancel', 'confirm'])

// 从 useOverlayMeta 获取 Overlay 信息
const { visible, confirm, cancel } = useOverlayMeta({
  // 弹出层动画的持续时间, 可以避免组件过早被销毁
  // 仅在 template 中使用则不需要定义
  animation: 1000
})
</script>

创建函数调用回调(imperative), 在 Javascript / Typescript 中调用

import { createOverlay } from 'unoverlay-vue'
import Model from './Model.vue'

// 转换函数调用模式(imperative)
const ModelCallbck = createOverlay(Model)
// 调用并获取 confirm 回调的值
const value = await ModelCallbck({ title: 'callbackOverlay' })
// value === "callbackOverlay:confirmed"

或者任意地方直接调起组件

import { executeOverlay } from 'unoverlay-vue'
import Model from './Model.vue'

const value = await executeOverlay(Model, {
  props: { title: 'useOverlay' }
})
// value === "useOverlay:confirmed"

或者在 template 中以组件声明模式(declarative)使用

<template>
  <Model
    v-model:visible="visible"
    title="useTemplate"
    @confirm="confirm"
    @cancel="cancel"
  />
<script setup>
import Model from './Model.vue'
const visible = ref(false)
const confirm = (value) => {/* value === 'useTemplate:confirmed' */}
const cancel = () => {/*...*/}
</script>

我们可以看到,实际创建和转换的过程都较为平滑,实际上 unoverlay-vue 做了很多简化和处理,我们进行往下来了解 unoverlay-vue 的具体实现机理,加深我们对实际渲染转换的理解。


具体机理

useOverlayMetaunoverlay-vue 的核心函数,它基于 Vue 的依赖注入实现

  • createOverlay 转换后,内部使用 Provider 携带的具有销毁组件自身、调取 Propmise 的功能
  • template 中使用,内部使用组件的 model,调用组件自身的 emits (confirm,cancel)

createOverlay 可以将使用 useOverlayMeta 的组件转换为具有 Providerrender 函数

executeOverlaycreateOverlay 的变体写法,直接作用就是直接调取命令回调。

实际函数式组件运作流程:

image.png

而声明式组件则十分简单,useOverlayMeta 仅仅做了一层 model|emits 的封装:

function useOverlayMeta(options) {
// ...
 if (declarative) {
  const instance = getCurrentInstance()
  const visible = useVModel(instance.props, ...)
  function confirm(...) {
      instance.emit(...)
  }
  return {...}
 }
}

而内部则会自动判断当前运行环境。


第三方组件库定制化

我们还可以使用 unoverlay-vue 来定制化现有的组件库,我们这里以element-plus@2.15.7(dialog)来举例(当然你也可以使用其他组件库)

<!-- MyDialog.vue -->
<template>
  <el-dialog v-model:visible="visible" :title="title" @close="cancel()">
    <!-- 你的定制化内容 -->
    <button @click="confirm(`${title}:confirmed`)" />
  </el-dialog>
</template>
<script setup>
import { defineEmits, defineProps } from 'vue'
import { useOverlayMeta } from 'unoverlay-vue'
const props = defineProps({
  title: String,
})

const { visible, confirm, cancel } = useOverlayMeta({
  animation: 2000
})
</script>

创建函数调用回调(imperative), 在 Javascript / Typescript 中调用

import { createOverlay } from 'unoverlay-vue'
import MyDialog from './MyDialog.vue'

const MyDialogCallback = createOverlay(MyDialog)
const value = await MyDialogCallback({ title: 'myElDialog' })
// value === "myElDialog:confirmed"

直接调起、组件声明(declarative)的使用与上述案例相同,就不过多阐述。


外部控制流程

有时候,如果把控制权都交给 Component,会在一些使用场景时收到限制,Unoverlay Vue 转换的组件还允许在外部控制组件的流程。

Model 的返回值的功能不仅仅包括 Promise 在此基础还有 confirmcancel

const Model = createOverlay(MyComponent)
const promiser = Model({/* you props */})

function close() {
  promiser.cancel()
}
function yes() {
  promiser.confirm({/* you resolved value */})
}

由于渲染需要等待, promiser 中的 cancel / confirm 不能立即调用,一般建议在回调函数内部中使用。

更多用法(Typescript, ...)可以参考 unoverlay-vue 中文文档

具体参数

useOverlayMeta 的参数列表:

字段描述默认值
animation模态框的过渡动画时长,以毫秒为单位,避免过早销毁组件导致组件闪烁0
immediate是否立即将 visible 设置为 true 得以显示组件true
modelv-model 使用的字段,仅使用于在 template 中调起的组件'visible'
cancelcancel 的事件名称,仅使用于在 template 中调起的组件'cancel'
confirmconfirm 的事件名称,仅使用于在 template 中调起的组件'confirm'
automatic是否在 visible 变化后自动根据动画时长销毁组件true

总结与思考

作为一名优秀的前端工程师, 面对各种繁琐而重复的工作,我们不应该按部就班的去"辛勤劳动",而是要根据已有前端的开发经验,总结出一套的高效开发的方法,这样才能继续愉快的摸鱼(咳咳)

另外,如果觉得插件好用不妨点一个 star 激励作者继续维护吧!!