Element的MessageBox组件Message Box 信息弹窗框可以让我们以编程式调用一个即用即走的弹窗,免去了在<template>中定义一个el-doalog再通过一堆变量去控制的麻烦。
有时候,UI同学需要一个有自己系统风格的MessageBox,要是在每处用到的地方都在<template>中写一套弹窗,那也未免太麻烦了,接下来让我们来实现一个自己的可以编程式调用的Vue组件。
1. 准备一个Vue弹窗组件MyMessageBox.vue
MessageBoxProp只是一个类型定义,第二节中有,这里先不关注
<script setup lang="ts">
import {ref} from "vue";
import {MessageBoxProp} from "./index";
const emit = defineEmits<{
confirm: [], // 点击确认按钮
closed: [],// 当组件被关闭后
}>()
withDefaults(defineProps<MessageBoxProp>(), {
title: '提示',
})
// 使默认值为true,这样只要该组件被挂载到页面中就会显示出来
const visible = ref(true)
</script>
<template>
<el-dialog v-model="visible" :title="title" @close="emit('closed')">
<h3>{{ message }}</h3>
<template #footer>
<el-button type="primary" @click="emit('confirm')">确认</el-button>
<el-button @click="visible = false">取消</el-button>
</template>
</el-dialog>
</template>
现在这个组件只要引入到<template>中就会被展示出来。但我们想要的是JS调用方式。
2. 写一个函数来实现当函数调用时渲染该组件到页面中
index.ts
import {createApp} from "vue";
import MyMessageBox from './MyMessageBox.vue'
export interface MessageBoxProp {
title?: string;
message: string;
}
export const confirmMessageBox = (props: MessageBoxProp) => {
// 创建一个容器,用来装vue渲染的内容
const div = document.createElement('div')
// 创建App实例
// 第二个参数是将传递给实例的props参数
const app = createApp(MyMessageBox, {
title: props.title,
message: props.message,
})
// 将实例渲染到容器中
app.mount(div)
// 将渲染出的结果追加到页面
document.body.appendChild(div)
}
需要注意的是: 由于我们创建了一个新的app,此时这个app中并没有那些在项目入口处全局挂载的内容,所以直接调用此函数将报错:无法找到el-dialog组件等等.
所以需要把那些在编程式调用的组件中用到的全局的组件/指令等也挂载到这个app中,比如:app.use(ElementPlus)但是, 这样未免太重了些,更好的方式是直接在我们的组件中单独引用它们:
import {ElDialog, ElButton} from "element-plus";
知识点
createApp()的第二个参数可以为根组件提供props。- 每一个app的context都是独立的,需要单独挂载全局内容。
3. 在JS代码中调用该函数
然后编写代码在组件App.vue中调用这个函数
<script setup lang="ts">
import {confirmMessageBox} from "./components/message-box/index";
const remove = () => {
confirmMessageBox({title: '确认删除?', message: '您确定要删除该数据?'})
}
</script>
<template>
<el-button @click="remove">删除</el-button>
</template>
点击按钮:
可以看到组件成功被渲染并插入到body中,但是当关闭弹窗后dom并没有被卸载,并且无法在点击了
确认按钮后执行调用API等事件。
4.关闭弹窗时移除dom、点击确认时执行外部传入的回调
只需要改造下createApp的第二个参数,并且给函数参数添加confirm声明:
export const confirmMessageBox = (props: MessageBoxProp & { confirm: () => void }) => {
// 创建一个容器,用来装vue渲染的内容
const div = document.createElement('div')
// 创建App实例
// 第二个参数是将传递给实例的props参数
const app = createApp(MyMessageBox, {
title: props.title,
message: props.message,
onConfirm: () => {
// 外部传入的确认回调
props.confirm()
},
onClosed: () => {
document.body.removeChild(div)
}
})
// 将实例渲染到容器中
app.mount(div)
// 将渲染出的结果追加到页面
document.body.appendChild(div)
}
知识点:
createApp()中的根组件的props可以在第二个参数中直接传递,根组件的emit回调事件则需要改变为onXxx的形式。
然后在调用的地方添加confirm处理函数:
index.ts
<script setup lang="ts">
import {confirmMessageBox} from "./components/message-box/index";
const remove = () => {
confirmMessageBox({
title: '确认删除?',
message: '您确定要删除该数据?',
confirm: () => {
console.log('删掉它!')
}
})
}
</script>
<template>
<el-button @click="remove">删除</el-button>
</template>
现在组件就会在关闭时移除dom,点击确认也会执行传入的处理函数。
你可能已经注意到,弹窗在出现时是有动画的,但是移除时立即就被删除了,这太不优雅。
- 方式一: 简单的解决办法是直接添加一个延时:
const app = createApp(MyMessageBox, { // ... onClosed: () => { setTimeout(() => { document.body.removeChild(div) }, 300) } })
- 方式二:更好的办法是直接使用
el-dialog的closed事件而不是close事件:
closed会在弹窗完全关闭、动画也执行完成后调用。close会在弹窗开始关闭时调用,此时动画还没执行完毕。修改MyMessageBox.vue:
<template> <!-- 使用@closed来保证弹窗关闭动画结束后再移除dom --> <el-dialog v-model="visible" :title="title" @closed="emit('closed')"> <h3>{{ message }}</h3> <template #footer> <el-button type="primary" @click="emit('confirm')">确认</el-button> <el-button @click="visible = false">取消</el-button> </template> </el-dialog> </template>
现在组件的加载、关闭、确认回调都已经完成了。
5. 弹窗的关闭时机
现在,当点击取消或X时弹窗就会关闭并从dom中移除。
点击确认按钮就会打印"删掉它!",现在还没有后续处理,所以点击确认后弹窗还不会关闭。
可以在onConfirm中直接关闭弹窗,不过多数场景下我们不应该在onConfirm中关闭弹窗,考虑这种场景: 点击确认后调用删除API,API成功了则关闭弹窗,失败了则保持弹窗的打开状态以方便用户重试。
所以需要将弹窗的关闭回调交给调用它的函数,由外部的函数来决定何时关闭弹窗:
- 首先在MyMessageBox.vue中对外暴露关闭弹窗的方法
defineExpose({
hide: () => {
visible.value = false
}
})
- 然后将一个调用组件
hide方法的回调提供给外部调用者:
export const confirmMessageBox = (props: MessageBoxProp & { confirm: (done: () => void) => void }) => {
// 创建一个容器,用来装vue渲染的内容
const div = document.createElement('div')
// 根组件的实例
let instance
// 创建App实例
// 第二个参数是将传递给实例的props参数
const app = createApp(MyMessageBox, {
title: props.title,
message: props.message,
onConfirm: () => {
// !!!!变化在这里 !!!!
// 外部传入的确认回调
props.confirm(() => {
instance?.hide()
})
},
onClosed: () => {
document.body.removeChild(div)
}
})
// 将实例渲染到容器中
instance = app.mount(div)
// 将渲染出的结果追加到页面
document.body.appendChild(div)
}
知识点:
app.mount()会返回根组件的实例,等同于对该组件的模板引用ref="xxx"。通过这个实例可以和组件进行通信,传递值或使用组件暴露的方法。
- 调用者自由决定在合适的时机关闭弹窗
const remove = () => {
confirmMessageBox({
title: '确认删除?',
message: '您确定要删除该数据?',
confirm: (done) => {
console.log('删掉它!')
// 调用API或其他什么事,使用confirm的回调参数在合适的时机关闭弹窗
done()
}
})
}
完成!
上述代码可以满足大多数场景。 不过还可以让代码更清晰些。
完整代码在最后。
追加: 将函数改写为一个可以链式调用的方法
一般的使用回调方式就够了,但也可以将编程式调用改为链式调用,代码结构更好清晰些。
tips
也可以是一个Promise,不过这时then代码块中就不能执行异步代码了
confirmMessageBox() .then(done=>{ // 执行异步逻辑 done() }) .catch(()=>{ // ... })因为点击确认后进入then代码块,如果then块中的异步失败了需要重试, 此时由于promise已经完成,代码不可能再次执行then中的异步了。
所以这个场景还是使用链式调用方式好些。
组件代码不用动,函数改为:
type ConfirmCallback = (done: () => void) => void // 确认回调
type CancelCallback = () => void // 取消回调
type MessageBox = {
confirm: (cb: ConfirmCallback) => MessageBox,
cancel: (cb: CancelCallback) => MessageBox
}
export const confirmMessageBox = (props: MessageBoxProp): MessageBox => {
// 回调事件池
const confirmEvents: ConfirmCallback[] = []
let cancelEvents: CancelCallback[] = []
// 创建一个容器,用来装vue渲染的内容
const div = document.createElement('div')
// 根组件的实例
let instance
// 创建App实例
// 第二个参数是将传递给实例的props参数
const app = createApp(MyMessageBox, {
title: props.title,
message: props.message,
onConfirm: () => {
// 执行确认事件池
confirmEvents.forEach(cb => {
cb(() => {
instance?.hide()
// 清空取消事件池,已经确认了,不需要取消事件了
cancelEvents = []
})
})
},
onClosed: () => {
document.body.removeChild(div)
// 执行取消事件池
cancelEvents.forEach(cb => {
cb()
})
}
})
// 将实例渲染到容器中
instance = app.mount(div)
// 将渲染出的结果追加到页面
document.body.appendChild(div)
return {
confirm(cb: ConfirmCallback) {
confirmEvents.push(cb)
return this
},
cancel(cb: CancelCallback) {
cancelEvents.push(cb)
return this
}
}
}
函数调用改为:
confirmMessageBox({title: '确认删除?', message: '您确定要删除该数据?'}).confirm((done) => {
console.log('删掉它!')
if (time !== 0) {
// 调用API或其他什么事,在合适的时机关闭弹窗
done()
ElMessage.success('API重试成功,弹窗关闭')
} else {
ElMessage.error('模拟API失败')
}
time++
}).cancel(() => {
console.log('取消了')
})
完整代码
MyMessageBox.vue
<script setup lang="ts">
import {ref} from "vue";
import {MessageBoxProp} from "./index";
import {ElDialog, ElButton} from "element-plus";
const emit = defineEmits<{
confirm: [], // 点击确认按钮
closed: [],// 当组件被关闭后
}>()
withDefaults(defineProps<MessageBoxProp>(), {
title: '提示',
})
// 使默认值为true,这样只要该组件被挂载到页面中就会显示出来
const visible = ref(true)
defineExpose({
hide: () => {
visible.value = false
}
})
</script>
<template>
<el-dialog v-model="visible" :title="title" @closed="emit('closed')">
<h3>{{ message }}</h3>
<template #footer>
<el-button type="primary" @click="emit('confirm')">确认</el-button>
<el-button @click="visible = false">取消</el-button>
</template>
</el-dialog>
</template>
index.ts
import {createApp} from "vue";
import MyMessageBox from './MyMessageBox.vue'
export interface MessageBoxProp {
title?: string;
message: string;
}
type ConfirmCallback = (done: () => void) => void // 确认回调
type CancelCallback = () => void // 取消回调
type MessageBox = {
confirm: (cb: ConfirmCallback) => MessageBox,
cancel: (cb: CancelCallback) => MessageBox
}
/**
* @example
* confirmMessageBox({title: '确认删除?', message: '您确定要删除该数据?'})
* .confirm(done => {
* // 在合适的时机关闭弹窗
* done()
* })
* .confirm(done => {
* // confirm可以注册多个
* })
* .cancel(() => {
* // confirm也可以注册多个
* })
*
*/
export const confirmMessageBox = (props: MessageBoxProp): MessageBox => {
// 回调事件池
const confirmEvents: ConfirmCallback[] = []
let cancelEvents: CancelCallback[] = []
// 创建一个容器,用来装vue渲染的内容
const div = document.createElement('div')
// 根组件的实例
let instance
// 创建App实例
// 第二个参数是将传递给实例的props参数
const app = createApp(MyMessageBox, {
title: props.title,
message: props.message,
onConfirm: () => {
// 执行确认事件池
confirmEvents.forEach(cb => {
cb(() => {
instance?.hide()
// 清空取消事件池,已经确认了,不需要取消事件了
cancelEvents = []
})
})
},
onClosed: () => {
document.body.removeChild(div)
// 执行取消事件池
cancelEvents.forEach(cb => {
cb()
})
}
})
// 将实例渲染到容器中
instance = app.mount(div)
// 将渲染出的结果追加到页面
document.body.appendChild(div)
return {
confirm(cb: ConfirmCallback) {
confirmEvents.push(cb)
return this
},
cancel(cb: CancelCallback) {
cancelEvents.push(cb)
return this
}
}
}
App.vue
<script setup lang="ts">
import {confirmMessageBox} from "./components/message-box/index";
import {ElMessage} from "element-plus";
const remove = () => {
let time = 0
confirmMessageBox({title: '确认删除?', message: '您确定要删除该数据?'}).confirm((done) => {
console.log('删掉它!')
if (time !== 0) {
// 调用API或其他什么事,在合适的时机关闭弹窗
done()
ElMessage.success('API重试成功,弹窗关闭')
} else {
ElMessage.error('模拟API失败')
}
time++
}).cancel(() => {
console.log('取消了')
})
}
</script>
<template>
<el-button @click="remove">删除</el-button>
</template>
最后
这个示例简单的介绍了如何与App实例进行通信。通过通信手段,可以在js中给组件传数据、接受vue组件的事件、调用vue组件的方法。
除了二次确认弹窗,这也可以应用于在Echarts的Tooltip功能中使用Vue组件。请移步: 在ECharts的tooltip中使用Vue组件,这里面还介绍了用customElement的方式来实现编程式调用。
与组件通信的方式还有不少,比如用vuex/pinia、用esm的特性等等