场景: 在大屏项目中经常需要用到弹窗的需求,通常一个项目内弹窗的样式是一致的,如果不封装一个弹窗组件hook的话,每一个弹窗都需要单独封装为一个组件然后再放到项目layout的最外层,再通过store全局判断哪个弹窗显示,这样显然很麻烦,所以现在我封装一个通过tsx及render渲染弹窗组件的hooks,需要使用弹窗就调用hooks传入弹窗组件就行
思路: 首先封装一个弹窗基础框架组件(包括弹窗外观样式及关闭和底部操作等事件)提供插槽展示每个弹窗的内容部分,然后在需要用到一个弹窗时封装这个弹窗的内容组件,然后调用hooks传入这个内容组件就可以打开弹窗(在封装的hooks中会通过render将弹窗基础组件和内容组件合并渲染出来生成一个元素节点,再通过document.body.appendChild将生成的元素节点挂载到页面中以实现弹窗的展示效果)
代码步骤:
(1)封装弹窗组件框架
<template>
<el-dialog class="models-default" v-bind="props" :model-value="visible">
<slot></slot>
<!-- 弹窗头部插槽 -->
<template v-if="$slots.header" #header>
<slot name="header"></slot>
</template>
<!-- 弹窗尾部插槽 -->
<template v-if="isFooter" #footer>
<!-- 自定义尾部插槽组件显示 -->
<template v-if="$slots.footer">
<slot name="footer"></slot>
</template>
<!-- 默认尾部按钮列显示 -->
<template v-else>
<el-button @click="visible = false">{{ props.cancelBtnText }}</el-button>
<el-button v-for="(item, index) in footerButtons" :key="index" :icon="item.icon" :type="item.type"
@click="() => btnClickHandle(item)">{{ item.name }}</el-button>
<el-button type="primary" @click="confirmHandle">{{ props.confirmBtnText }}</el-button>
</template>
</template>
</el-dialog>
</template>
<script lang="ts" setup>
import { ref, getCurrentInstance } from 'vue'
import { ElDialog, ElButton, type DialogProps, type ButtonProps } from 'element-plus'
const emits = defineEmits(['update:modelValue', 'confirm', 'beforeClose'])
// 传入的弹窗props
const props = withDefaults(defineProps<BProps>(), {
footerButtons: () => [], //底部按钮
confirmBtnText: '确认', //底部确认按钮文字
cancelBtnText: '取消', //底部取消按钮文字
isFooter: true, //是否展示底部按钮
showClose: true, //是否展示关闭图标
title: '弹窗名称', //弹窗标题
})
const visible = ref(true)
const instance = getCurrentInstance()
const closeModel = (value = false) => {
visible.value = value
}
const confirmHandle = () => {
emits('confirm', instance?.proxy?.$refs[props.contentRef], closeModel)
}
const btnClickHandle = (item: FooterButtons) => {
// item.onClick && item?.onClick(instance?.proxy?.$refs[props.contentRef], closeModel)
item.onClick?.(instance?.proxy?.$refs[props.contentRef], closeModel)
}
</script>
<script lang="ts">
// 约束底部按钮
export interface FooterButtons {
icon?: string
name?: string
type?: ButtonProps['type']
onClick?: (contentInstance: any, done: () => void) => void
}
// ts接口约束弹窗接收外部的props
interface BProps extends Partial<DialogProps> {
footerButtons?: FooterButtons[]
confirmBtnText?: string
cancelBtnText?: string
isFooter?: boolean
contentRef: string
}
</script>
<style lang="scss">
// 省略样式
</style>
(2)封装tsx渲染弹窗组件的hook(通过tsx将弹窗框架以及传入的弹窗内容组件结合并渲染到页面上)
import type { ComponentInternalInstance, Ref } from 'vue'
import { h, render, onUnmounted, ref } from 'vue'
import { type DialogProps } from 'element-plus'
import type { JSX } from 'vue/jsx-runtime'
// 弹窗框架组件
import Model, { type FooterButtons } from '@/components/models/index.vue'
import { ElConfigProvider } from 'element-plus'
import zhCn from 'element-plus/es/locale/lang/zh-cn'
type Content = Parameters<typeof h>[0] | string | JSX.Element
interface UseModelProps extends Partial<DialogProps> {
isFooter?: boolean // 是否展示底部
footerButtons?: Array<FooterButtons> // 底部按钮(除取消和保存按钮之外的按钮)
onConfirm?: (contentInstance: any) => void
onClosed?: () => void
onOpened?: () => void
}
// 弹窗插槽暴露
interface ElDialogSlots {
header?: () => JSX.Element
footer?: () => JSX.Element
}
interface ContentPropsType {
ref: string
[key: string]: any
}
interface Options {
props: UseModelProps // model props
slots?: ElDialogSlots // 弹窗插槽对象
contentProps?: ContentPropsType // 弹窗主体插入逐渐props
}
/**
* 窗体模块hooks
* @param {Object} content 设置弹窗主体组件对象
* @param {Object} options 配置信息
* @param {Object} options.props 窗体组件props,继承element-puls dialog组件的所有props
* @param {Boolean} options.props.isFooter 是否显示底部内容
* @param {Array} options.props.footerButtons 底部按钮添加
* @param {Function} options.props.onConfirm 确认按钮回调
* @param {Function} options.props.onClosed 弹窗关闭回调
* @param {Function} options.props.onOpened 弹窗打开回调
* @param {Function} options.slots 弹窗插槽对象
* @param {Object} options.contentProps 弹窗主体插入组件props
* @returns
*/
export function useModel (content: Content, options?: Options) {
// 弹窗组件实例
const modelInstance: Ref<ComponentInternalInstance | null> = ref(null)
// 弹窗的元素节点
let fragment: Element | null = null
// 关闭并卸载组件
const closeAfter = () => {
if (fragment) {
render(null, fragment as unknown as Element) // 卸载组件
fragment.textContent = '' // 清空文档片段
fragment = null
}
modelInstance.value = null
}
// 关闭弹窗
function close () {
if (modelInstance.value) modelInstance.value.props.modelValue = false
}
// 核心代码
function open () {
// 打开弹窗前判断如果存在当前弹窗实例就销毁掉重新创建
if (modelInstance.value) {
close()
closeAfter()
}
const { props = {}, slots = {}, contentProps = { ref: 'content' } } = options ?? {}
// 创建元素节点
fragment = document.createDocumentFragment() as unknown as Element
// tsx组件内容(将弹窗框架组件和传入的内容组件整合)
const vNode = (
<ElConfigProvider locale={zhCn} size="small" zIndex={3000}>
{/* 将传入的props弹窗配置信息都传入到弹窗框架组件中 */}
<Model align-center {...props} modelValue={true} contentRef={contentProps.ref}>
{{
{/* 弹窗内容组件 */}
default: () => <content {...contentProps}></content>,
...slots,
}}
</Model>
</ElConfigProvider>
)
// render将tsx生成的组件vNode渲染到元素节点上
render(vNode, fragment)
// 弹窗实例
modelInstance.value = vNode.component
// 将弹窗的元素节点挂载到页面上
document.body.appendChild(fragment)
}
onUnmounted(() => {
close()
})
// 将外部需要用到的方法和变量暴露出去
return {
open, // 打开弹窗
close, // 关闭弹窗
closeAfter, // 销毁弹窗
modelInstance, // 弹窗实例
}
}
(3)在需要用弹窗组件的地方使用封装的hooks
// 弹窗hooks
import { useModel } from '@/hooks/useModel'
// 弹窗内容组件
import ShopDetail from '../detailDialog/ShopDetail.vue';
const showDetail = () => {
// 传入弹窗内容组件及弹窗组件需要用到的props参数
const { open, closeAfter } = useModel(ShopDetail, {
// 弹窗框架组件props
props: {
title: '店铺详情',
width: '900px',
isFooter: false,
// 关闭弹窗回调
onClosed: () => {
closeAfter()
}
},
// 弹窗内容组件props
contentProps: {
ref: 'detail',
dataInfo: props.markerData
},
})
// 打开弹窗
open()
}