前端开发中,弹窗是必不可少的重要组件,一般我们会选择使用流行的组件库提供的弹窗组件,比如Element Plus、iview UI、Ant Design Vue。
模板式弹窗
使用这些组件库提供的弹窗,通常我们需要在template中书写Modal的代码,以iview举例:
html
<template>
<Button type="primary" @click="modal1 = true">Display dialog box</Button>
<Modal
v-model="modal1"
title="Common Modal dialog box title"
@on-ok="ok"
@on-cancel="cancel">
<p>Content of dialog</p>
<p>Content of dialog</p>g
<p>Content of dialog</p>
</Modal>
</template>
<script>
export default {
data () {
return {
modal1: false
}
},
methods: {
ok () {
this.$Message.info('Clicked ok');
},
cancel () {
this.$Message.info('Clicked cancel');
}
}
}
</script>
模板式弹窗的缺陷
试想,如果我们一个页面需要很多弹窗,那就得写很多这样的模板,不利于阅读和维护。于是我们可能会将Modal抽离到单独的组件中,就变成了这样:
html
<template>
<Button type="primary" @click="showAdd = true">新增</Button>
<Button type="primary" @click="showEdit = true">编辑</Button>
<AddUserModal v-if="showAdd"></AddUserModal>
<EditUserModal v-if="showEdit"></EditUserModal>
</template>
<script>
export default {
data () {
return {
showAdd: false,
showEdit: false
}
},
}
</script>
这样看上去没问题,但是实际运行到时候会发现,弹窗关闭的时候,动画没了...
那我们不用v-if,自己实现一个v-model:show来控制弹窗的显示呗,那就变成这样:
html
<template>
<Button type="primary" @click="showAdd = true">新增</Button>
<Button type="primary" @click="showEdit = true">编辑</Button>
<AddUserModal v-model:show="showAdd"></AddUserModal>
<EditUserModal v-model:show="showEdit"></EditUserModal>
</template>
<script>
export default {
data () {
return {
showAdd: false,
showEdit: false
}
},
}
</script>
嗯,关闭的时候有动画了,但又会发现一个新的问题,弹窗组件的生命周期函数不太好用了,有时候我们可能会在每次弹窗打开的时候做一些初始化工作,比如从后台获取数据
js
onMounted(() => {
fetchData()
})
你会发现弹窗显示的时候,fetchData
并不会调用。原因是EditUserModal
组件已经跟随父组件一起挂载了,所以不会再触发onMounted
了,如果你的需求是每次弹窗显示的时候,都要调一次接口的话,就只能监听show
的变化了。
js
watch(show, (val) => {
if (val) {
fetchData()
}
}, { immediate: true})
模板式弹窗除了上面的问题以外,还有一个另我最不能忍受的痛点,就是如果某个弹窗组件需要在多个页面使用的时候,那你每一个页面都要在模板中声明它,并为它绑定show变量。这非常繁琐,而且有些时候,我们控制弹窗显示的逻辑不在vue组件内,去访问show变量可能不太方便。这时候,如果我们能通过函数调用创建弹窗就太好了,比如iview可以这样:
js
this.$Modal.info({
title: 'xxx用户协议',
content: 'xxx内容'
});
这样我们就能不依赖模板声明,直接在任何地方显示弹窗了。但可惜,它的自定义程度不高,仅能传入简单的文本或者render函数,对复杂的弹窗无能为力。Element UI甚至完全不支持这种使用方式。
所以我们需要自定义一个编程式弹窗。
编程式弹窗
编程式弹窗也叫命令式弹窗,即通过函数命令创建的弹窗。像这样:
html
<script setup lang="ts">
import UserAgreementComponent from './UserAgreementComponent.vue'
import Modal from '@/hooks/useModal'
const Modal = useModal()
function onClick() {
Modal.create({
title: '用户服务协议',
content: UserAgreementComponent,
})
}
</script>
这样就不受模板限制,可以在任何js执行的地方调用方法创建弹窗,不需要管理show变量,弹窗的复用也很方便,而且弹窗的挂载跟弹窗的显示是同步的。
怎么实现编程式弹窗
首先,我们需要封装一个Modal组件,作为基础的弹窗组件模板,这里可以完全自己实现,也可以使用第三方组件,我这里选择使用Antd的弹窗组件,减少一些代码编写。
html
<script setup lang="ts">
import { ref, computed } from 'vue'
const props = defineProps({
title: {
type: String,
},
content: {
type: Object,
},
footer: {
type: Array,
default: () => []
},
onClosed: {
type: Function,
}
})
const show = ref(true)
const contentIsText = computed(() => {
return typeof props.content === 'string'
})
function close() {
show.value = false
}
function closed() {
// 弹窗关闭后,通知Service销毁dom
props.onClosed?.()
}
// 暴露close方法,便于外部调用
defineExpose({
close
})
</script>
<template>
<a-modal v-model:open="show" :title="title" centered @cancel="closed">
<component v-if="!contentIsText" :is="content" v-bind="componentParams" @close="close"></component>
<div v-if="contentIsText" v-html="content"></div>
</a-modal>
</template>
然后,封装一个创建弹窗的服务,Vue3中我们可以用组合式函数
ts
import Modal from '@/components/modal.vue'
import { createVNode, getCurrentInstance, render } from 'vue'
export default function useModal() {
const appContext = getCurrentInstance()?.appContext
const create = (props: any) => {
// 创建弹窗所在的父容器
const container = document.createElement('div')
document.body.appendChild(container)
const destroy = () => {
// 延迟dom的销毁,不然会影响弹窗的关闭动画
setTimeout(() => {
render(null, container)
document.body.removeChild(container)
}, 300)
}
// 用Modal组件生成虚拟节点
const vnode = createVNode(Modal, {
...props,
onClosed: () => {
destroy()
}
})
// 关键:绑定当前上下文,否则无法在Modal中使用当前项目引入的插件、组件
vnode.appContext = appContext!
// 将虚拟节点插入dom
render(vnode, container)
// 提供一个主动关闭的方法
const close = () => {
// 先调用Modal组件暴露的close方法,关闭弹窗
vnode.component?.exposed?.close()
// 销毁dom
destroy()
}
return {
close
}
}
return {
create
}
}
最后,就可以在业务组件中使用了
ts
<script setup lang="ts">
import UserAgreementComponent from './UserAgreementComponent.vue'
import Modal from '@/hooks/useModal'
const Modal = useModal()
function onClick() {
const modal = Modal.create({
title: '用户服务协议',
content: UserAgreementComponent,
componentParams: {
id: 1,
name: '张三'
}
})
// 主动关闭
modal.close()
}
function onClick2() {
Modal.create({
title: '提醒',
content: '检查到新版本,请更新',
})
}
</script>
上面就实现了一个简单的编程式弹窗,在你的业务中,你可以为它提供更多的属性来进行更丰富的细节控制。比如用closable
来控制是否显示右上角关闭按钮,使用mask-closable
来控制是否允许点击遮罩层关闭弹窗等等。
其中,底部按钮是弹窗重要的组成部分,这里有两种实现方式。
一、在Modal模板中写死按钮,提供一些属性进行微调:
html
<template>
<a-modal v-model:open="show" :title="title" centered @cancel="closed">
<component v-if="!contentIsText" :is="content" v-bind="componentParams" @close="close"></component>
<div v-if="contentIsText" v-html="content"></div>
<template #footer>
<a-button @click="onCancel">{{ cancelText || '取消' }}</a-button>
<a-button type="primary" @click="onOk">{{ okText || '确定' }}</a-button>
</template>
</a-modal>
</template>
ts
<script setup lang="ts">
...
function onClick() {
const modal = Modal.create({
title: '用户服务协议',
content: UserAgreementComponent,
cancelText: '不同意',
onCancel: () => {
modal.close()
},
okText: '同意',
onOk: () => {
// do something
modal.close()
},
})
}
</script>
二、从外部传入按钮数组
html
<template>
<a-modal v-model:open="show" :title="title" centered @cancel="closed">
<component v-if="!contentIsText" :is="content" v-bind="componentParams" @close="close"></component>
<div v-if="contentIsText" v-html="content"></div>
<template #footer>
<a-button v-for="item in footer" :key="item.text" :type="item.type"
@click="item.onClick()">
{{ item.text }}
</a-button>
</template>
</a-modal>
</template>
ts
<script setup lang="ts">
...
function onClick() {
const modal = Modal.create({
title: '用户服务协议',
content: UserAgreementComponent,
footer: [
{
text: '不同意',
type: 'default',
onClick: () => {
modal.close()
},
},
{
text: '同意',
type: 'primary',
onClick: () => {
modal.close()
},
},
]
})
}
</script>
你还可以将footer设置为空数组,在UserAgreementComponent
组件内部自定义底部按钮。但是这样的话,你可能需要在UserAgreementComponent
内部进行弹窗的关闭。可以这样:
html
<script setup lang="ts">
const emit = defineEmits(['close'])
function onOk() {
// do something
close()
}
function close() {
emit('close')
}
</script>
<template>
<div>
<div>用户协议xxxx</div>
<div class="footer">
<a-button @click="close">不同意</a-button>
<a-button type="primary" @click="onOk">同意</a-button>
</div>
</div>
</template>
至此,我们就封装了一个支持高度自定义的弹窗组件,希望它能为你的开发提供一些便利。