Skip to content

封装命令式弹窗组件(v2/v3)

命令式弹窗组件封装时跟一般的组件封装不一样,在现有的UI组件库中已经很常见了,但是命令式组件封装起来还是有点难度的。

一般的组件:封装较容易,使用比较难;在使用时需要大量的代码,导致代码冗余

命令式组件:封装较难,使用比较容易;使用时需要简单的调用就行,方便高效

效果:

image-20250214103638528

vue2版

编写组件的结构components/MessageBox.vue弹窗组件

已折叠 点击查看组件代码
vue
<template>
  <transition name="message-box-fade">
    <div class="message-box-mask" v-if="visible" @click.self="handleMaskClick">
      <transition name="message-box-bounce">
        <div class="message-box" v-if="visible">
          <div class="message-box-header">
            <span class="title">{{ title }}</span>
            <span class="close" @click="handleCancel">×</span>
          </div>
          <div class="message-box-content">
            <div class="message-box-message">{{ message }}</div>
          </div>
          <div class="message-box-btns">
            <button class="btn cancel" @click="handleCancel">{{ cancelButtonText }}</button>
            <button class="btn confirm" :class="type" @click="handleConfirm">{{ confirmButtonText }}</button>
          </div>
        </div>
      </transition>
    </div>
  </transition>
</template>

<script>
export default {
  name: 'MessageBox',
  data() {
    return {
      visible: false,
      title: '',
      message: '',
      confirmButtonText: '确定',
      cancelButtonText: '取消',
      type: 'primary',
      resolve: null,
      reject: null
    }
  },
  methods: {
    handleConfirm() {
      this.visible = false
      this.resolve('confirm')
    },
    handleCancel() {
      this.visible = false
      this.reject('cancel')
    },
    handleMaskClick() {
      this.handleCancel()
    },
    showMessage(message, title, options = {}) {
      this.visible = true
      this.message = message
      this.title = title
      if (options.confirmButtonText) {
        this.confirmButtonText = options.confirmButtonText
      }
      if (options.cancelButtonText) {
        this.cancelButtonText = options.cancelButtonText
      }
      if (options.type) {
        this.type = options.type
      }
      return new Promise((resolve, reject) => {
        this.resolve = resolve
        this.reject = reject
      })
    }
  }
}
</script>

<style scoped lang="less">
.message-box-mask {
  position: fixed;
  top: 0;
  left: 0;
  width: 100%;
  height: 100%;
  background: rgba(0, 0, 0, 0.5);
  display: flex;
  justify-content: center;
  align-items: center;
  z-index: 2000;
}

.message-box {
  background: #fff;
  border-radius: 4px;
  width: 420px;
  padding: 20px;
  box-shadow: 0 2px 12px 0 rgba(0,0,0,0.1);
}

.message-box-header {
  display: flex;
  justify-content: space-between;
  align-items: center;
  margin-bottom: 20px;

  .title {
    font-size: 18px;
    font-weight: 500;
    color: #303133;
  }

  .close {
    font-size: 20px;
    color: #909399;
    cursor: pointer;
    &:hover {
      color: #409EFF;
    }
  }
}

.message-box-content {
  padding: 10px 0;
  color: #606266;
  font-size: 14px;
}

.message-box-btns {
  display: flex;
  justify-content: flex-end;
  margin-top: 20px;
  gap: 10px;

  .btn {
    padding: 8px 20px;
    border-radius: 4px;
    border: 1px solid #dcdfe6;
    font-size: 14px;
    cursor: pointer;
    
    &.cancel {
      background: #fff;
      color: #606266;
      &:hover {
        color: #409EFF;
        border-color: #c6e2ff;
        background-color: #ecf5ff;
      }
    }
    
    &.confirm {
      color: #fff;
      border-color: #409EFF;
      background-color: #409EFF;
      &:hover {
        background: #66b1ff;
        border-color: #66b1ff;
      }
      
      &.warning {
        background-color: #E6A23C;
        border-color: #E6A23C;
        &:hover {
          background: #ebb563;
          border-color: #ebb563;
        }
      }
    }
  }
}

// 遮罩层淡入淡出
.message-box-fade-enter-active,
.message-box-fade-leave-active {
  transition: opacity 0.3s;
}

.message-box-fade-enter,
.message-box-fade-leave-to {
  opacity: 0;
}

// 弹窗弹跳效果
.message-box-bounce-enter-active {
  animation: bounce-in 0.3s;
}

.message-box-bounce-leave-active {
  animation: bounce-in 0.3s reverse;
}

@keyframes bounce-in {
  0% {
    transform: scale(0.8);
    opacity: 0;
  }
  50% {
    transform: scale(1.05);
  }
  100% {
    transform: scale(1);
    opacity: 1;
  }
}
</style>
<template>
  <transition name="message-box-fade">
    <div class="message-box-mask" v-if="visible" @click.self="handleMaskClick">
      <transition name="message-box-bounce">
        <div class="message-box" v-if="visible">
          <div class="message-box-header">
            <span class="title">{{ title }}</span>
            <span class="close" @click="handleCancel">×</span>
          </div>
          <div class="message-box-content">
            <div class="message-box-message">{{ message }}</div>
          </div>
          <div class="message-box-btns">
            <button class="btn cancel" @click="handleCancel">{{ cancelButtonText }}</button>
            <button class="btn confirm" :class="type" @click="handleConfirm">{{ confirmButtonText }}</button>
          </div>
        </div>
      </transition>
    </div>
  </transition>
</template>

<script>
export default {
  name: 'MessageBox',
  data() {
    return {
      visible: false,
      title: '',
      message: '',
      confirmButtonText: '确定',
      cancelButtonText: '取消',
      type: 'primary',
      resolve: null,
      reject: null
    }
  },
  methods: {
    handleConfirm() {
      this.visible = false
      this.resolve('confirm')
    },
    handleCancel() {
      this.visible = false
      this.reject('cancel')
    },
    handleMaskClick() {
      this.handleCancel()
    },
    showMessage(message, title, options = {}) {
      this.visible = true
      this.message = message
      this.title = title
      if (options.confirmButtonText) {
        this.confirmButtonText = options.confirmButtonText
      }
      if (options.cancelButtonText) {
        this.cancelButtonText = options.cancelButtonText
      }
      if (options.type) {
        this.type = options.type
      }
      return new Promise((resolve, reject) => {
        this.resolve = resolve
        this.reject = reject
      })
    }
  }
}
</script>

<style scoped lang="less">
.message-box-mask {
  position: fixed;
  top: 0;
  left: 0;
  width: 100%;
  height: 100%;
  background: rgba(0, 0, 0, 0.5);
  display: flex;
  justify-content: center;
  align-items: center;
  z-index: 2000;
}

.message-box {
  background: #fff;
  border-radius: 4px;
  width: 420px;
  padding: 20px;
  box-shadow: 0 2px 12px 0 rgba(0,0,0,0.1);
}

.message-box-header {
  display: flex;
  justify-content: space-between;
  align-items: center;
  margin-bottom: 20px;

  .title {
    font-size: 18px;
    font-weight: 500;
    color: #303133;
  }

  .close {
    font-size: 20px;
    color: #909399;
    cursor: pointer;
    &:hover {
      color: #409EFF;
    }
  }
}

.message-box-content {
  padding: 10px 0;
  color: #606266;
  font-size: 14px;
}

.message-box-btns {
  display: flex;
  justify-content: flex-end;
  margin-top: 20px;
  gap: 10px;

  .btn {
    padding: 8px 20px;
    border-radius: 4px;
    border: 1px solid #dcdfe6;
    font-size: 14px;
    cursor: pointer;
    
    &.cancel {
      background: #fff;
      color: #606266;
      &:hover {
        color: #409EFF;
        border-color: #c6e2ff;
        background-color: #ecf5ff;
      }
    }
    
    &.confirm {
      color: #fff;
      border-color: #409EFF;
      background-color: #409EFF;
      &:hover {
        background: #66b1ff;
        border-color: #66b1ff;
      }
      
      &.warning {
        background-color: #E6A23C;
        border-color: #E6A23C;
        &:hover {
          background: #ebb563;
          border-color: #ebb563;
        }
      }
    }
  }
}

// 遮罩层淡入淡出
.message-box-fade-enter-active,
.message-box-fade-leave-active {
  transition: opacity 0.3s;
}

.message-box-fade-enter,
.message-box-fade-leave-to {
  opacity: 0;
}

// 弹窗弹跳效果
.message-box-bounce-enter-active {
  animation: bounce-in 0.3s;
}

.message-box-bounce-leave-active {
  animation: bounce-in 0.3s reverse;
}

@keyframes bounce-in {
  0% {
    transform: scale(0.8);
    opacity: 0;
  }
  50% {
    transform: scale(1.05);
  }
  100% {
    transform: scale(1);
    opacity: 1;
  }
}
</style>

plugins/messageBox.js挂载组件

js
import Vue from 'vue'
import MessageBox from '../components/MessageBox.vue'

const MessageBoxConstructor = Vue.extend(MessageBox)
const messageBoxInstance = new MessageBoxConstructor()
messageBoxInstance.$mount(document.createElement('div'))
document.body.appendChild(messageBoxInstance.$el)

export default {
  install(Vue) {
    Vue.prototype.$confirm = (message, title, options) => {
      return messageBoxInstance.showMessage(message, title, options)
    }
  }
}
import Vue from 'vue'
import MessageBox from '../components/MessageBox.vue'

const MessageBoxConstructor = Vue.extend(MessageBox)
const messageBoxInstance = new MessageBoxConstructor()
messageBoxInstance.$mount(document.createElement('div'))
document.body.appendChild(messageBoxInstance.$el)

export default {
  install(Vue) {
    Vue.prototype.$confirm = (message, title, options) => {
      return messageBoxInstance.showMessage(message, title, options)
    }
  }
}

main.js注册组件

js
import MessageBox from './plugins/messageBox'
Vue.use(MessageBox)
import MessageBox from './plugins/messageBox'
Vue.use(MessageBox)

调用命令式组件

vue
<template>
  <div class="hello">
    <button class="test-btn" @click="handleDelete">删除文件</button>
  </div>
</template>

<script>
export default {
  name: 'HelloWorld',
  props: {
    msg: String
  },
  methods: {
    handleDelete() {
      this.$confirm('此操作将永久删除该文件, 是否继续?', '提示', {
        confirmButtonText: '确定',
        cancelButtonText: '取消',
        type: 'warning'
      }).then(() => {
        alert("删除成功")
      }).catch(() => {
        alert("删除失败")
      })
    }
  }
}
</script>

<style scoped lang="less">
.test-btn {
  padding: 8px 20px;
  color: #409EFF;
  border: 1px solid #dcdfe6;
  border-radius: 4px;
  cursor: pointer;
  background: #fff;
  margin-top: 20px;
  
  &:hover {
    color: #66b1ff;
    border-color: #c6e2ff;
    background-color: #ecf5ff;
  }
}
</style>
<template>
  <div class="hello">
    <button class="test-btn" @click="handleDelete">删除文件</button>
  </div>
</template>

<script>
export default {
  name: 'HelloWorld',
  props: {
    msg: String
  },
  methods: {
    handleDelete() {
      this.$confirm('此操作将永久删除该文件, 是否继续?', '提示', {
        confirmButtonText: '确定',
        cancelButtonText: '取消',
        type: 'warning'
      }).then(() => {
        alert("删除成功")
      }).catch(() => {
        alert("删除失败")
      })
    }
  }
}
</script>

<style scoped lang="less">
.test-btn {
  padding: 8px 20px;
  color: #409EFF;
  border: 1px solid #dcdfe6;
  border-radius: 4px;
  cursor: pointer;
  background: #fff;
  margin-top: 20px;
  
  &:hover {
    color: #66b1ff;
    border-color: #c6e2ff;
    background-color: #ecf5ff;
  }
}
</style>

vue3版

编写组件的结构components/MessageBox.vue弹窗组件

已折叠 点击查看组件代码
vue
<template>
  <Transition name="fade" @after-leave="handleAfterLeave">
    <div v-if="visible" class="message-box-overlay" @click.self="handleCancel">
      <Transition name="zoom">
        <div v-if="visible" class="message-box">
          <div class="message-box-header">
            <span class="title">{{ title }}</span>
            <button class="close-btn" @click="handleCancel">×</button>
          </div>
          <div class="message-box-content">
            {{ message }}
          </div>
          <div class="message-box-footer">
            <button class="btn cancel" @click="handleCancel">{{ cancelText }}</button>
            <button class="btn confirm" @click="handleConfirm">{{ confirmText }}</button>
          </div>
        </div>
      </Transition>
    </div>
  </Transition>
</template>

<script setup>
import { ref } from 'vue'

const visible = ref(false)
const title = ref('')
const message = ref('')
const confirmText = ref('确定')
const cancelText = ref('取消')

let resolvePromise = null
let rejectPromise = null

const handleConfirm = () => {
  visible.value = false
  resolvePromise && resolvePromise(true)
}

const handleCancel = () => {
  visible.value = false
  rejectPromise && rejectPromise(false)
}

const confirm = (msg, ttl = '提示', config = {}) => {
  visible.value = true
  message.value = msg
  title.value = ttl
  if (config.confirmText) confirmText.value = config.confirmText
  if (config.cancelText) cancelText.value = config.cancelText

  return new Promise((resolve, reject) => {
    resolvePromise = resolve
    rejectPromise = reject
  })
}

// 添加动画完成后的处理函数
const handleAfterLeave = () => {
  // 可以在这里处理一些动画结束后的清理工作
  message.value = ''
  title.value = ''
  confirmText.value = '确定'
  cancelText.value = '取消'
}

defineExpose({
  confirm,
})
</script>

<style scoped>
.message-box-overlay {
  position: fixed;
  top: 0;
  left: 0;
  width: 100%;
  height: 100%;
  background-color: rgba(0, 0, 0, 0.5);
  display: flex;
  justify-content: center;
  align-items: center;
  z-index: 9999;
}

.message-box {
  background: white;
  border-radius: 8px;
  width: 420px;
  padding: 20px;
  box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.1);
  position: relative;
}

.message-box-header {
  margin-bottom: 20px;
  display: flex;
  justify-content: space-between;
  align-items: center;
}

.title {
  font-size: 18px;
  font-weight: bold;
  color: #303133;
}

.close-btn {
  border: none;
  background: none;
  font-size: 20px;
  color: #909399;
  cursor: pointer;
  padding: 0;
  line-height: 1;
  transition: color 0.3s;
}

.close-btn:hover {
  color: #303133;
}

.message-box-content {
  margin-bottom: 30px;
  color: #606266;
  font-size: 14px;
}

.message-box-footer {
  text-align: right;
}

.btn {
  padding: 8px 20px;
  margin-left: 10px;
  border-radius: 4px;
  border: none;
  cursor: pointer;
  font-size: 14px;
  transition: all 0.3s;
}

.cancel {
  background: #ffffff;
  border: 1px solid #dcdfe6;
  color: #606266;
}

.cancel:hover {
  color: #409eff;
  border-color: #c6e2ff;
  background-color: #ecf5ff;
}

.confirm {
  background: #409eff;
  color: white;
  border: 1px solid #409eff;
}

.confirm:hover {
  background: #66b1ff;
  border-color: #66b1ff;
}

/* 优化动画时序 */
.fade-enter-active {
  transition: opacity 0.3s ease;
}

.fade-leave-active {
  transition: opacity 0.2s ease;
}

.zoom-enter-active {
  transition: all 0.3s cubic-bezier(0.4, 0, 0, 1.5);
}

.zoom-leave-active {
  transition: all 0.2s ease;
}
</style>
<template>
  <Transition name="fade" @after-leave="handleAfterLeave">
    <div v-if="visible" class="message-box-overlay" @click.self="handleCancel">
      <Transition name="zoom">
        <div v-if="visible" class="message-box">
          <div class="message-box-header">
            <span class="title">{{ title }}</span>
            <button class="close-btn" @click="handleCancel">×</button>
          </div>
          <div class="message-box-content">
            {{ message }}
          </div>
          <div class="message-box-footer">
            <button class="btn cancel" @click="handleCancel">{{ cancelText }}</button>
            <button class="btn confirm" @click="handleConfirm">{{ confirmText }}</button>
          </div>
        </div>
      </Transition>
    </div>
  </Transition>
</template>

<script setup>
import { ref } from 'vue'

const visible = ref(false)
const title = ref('')
const message = ref('')
const confirmText = ref('确定')
const cancelText = ref('取消')

let resolvePromise = null
let rejectPromise = null

const handleConfirm = () => {
  visible.value = false
  resolvePromise && resolvePromise(true)
}

const handleCancel = () => {
  visible.value = false
  rejectPromise && rejectPromise(false)
}

const confirm = (msg, ttl = '提示', config = {}) => {
  visible.value = true
  message.value = msg
  title.value = ttl
  if (config.confirmText) confirmText.value = config.confirmText
  if (config.cancelText) cancelText.value = config.cancelText

  return new Promise((resolve, reject) => {
    resolvePromise = resolve
    rejectPromise = reject
  })
}

// 添加动画完成后的处理函数
const handleAfterLeave = () => {
  // 可以在这里处理一些动画结束后的清理工作
  message.value = ''
  title.value = ''
  confirmText.value = '确定'
  cancelText.value = '取消'
}

defineExpose({
  confirm,
})
</script>

<style scoped>
.message-box-overlay {
  position: fixed;
  top: 0;
  left: 0;
  width: 100%;
  height: 100%;
  background-color: rgba(0, 0, 0, 0.5);
  display: flex;
  justify-content: center;
  align-items: center;
  z-index: 9999;
}

.message-box {
  background: white;
  border-radius: 8px;
  width: 420px;
  padding: 20px;
  box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.1);
  position: relative;
}

.message-box-header {
  margin-bottom: 20px;
  display: flex;
  justify-content: space-between;
  align-items: center;
}

.title {
  font-size: 18px;
  font-weight: bold;
  color: #303133;
}

.close-btn {
  border: none;
  background: none;
  font-size: 20px;
  color: #909399;
  cursor: pointer;
  padding: 0;
  line-height: 1;
  transition: color 0.3s;
}

.close-btn:hover {
  color: #303133;
}

.message-box-content {
  margin-bottom: 30px;
  color: #606266;
  font-size: 14px;
}

.message-box-footer {
  text-align: right;
}

.btn {
  padding: 8px 20px;
  margin-left: 10px;
  border-radius: 4px;
  border: none;
  cursor: pointer;
  font-size: 14px;
  transition: all 0.3s;
}

.cancel {
  background: #ffffff;
  border: 1px solid #dcdfe6;
  color: #606266;
}

.cancel:hover {
  color: #409eff;
  border-color: #c6e2ff;
  background-color: #ecf5ff;
}

.confirm {
  background: #409eff;
  color: white;
  border: 1px solid #409eff;
}

.confirm:hover {
  background: #66b1ff;
  border-color: #66b1ff;
}

/* 优化动画时序 */
.fade-enter-active {
  transition: opacity 0.3s ease;
}

.fade-leave-active {
  transition: opacity 0.2s ease;
}

.zoom-enter-active {
  transition: all 0.3s cubic-bezier(0.4, 0, 0, 1.5);
}

.zoom-leave-active {
  transition: all 0.2s ease;
}
</style>

plugins/messageBox.js挂载组件

js
import { createApp } from 'vue'
import MessageBox from '../components/MessageBox.vue'

export const showMessageBox = (message, title, config = {}) => {
  return new Promise((resolve, reject) => {
    const mountNode = document.createElement('div')
    document.body.appendChild(mountNode)

    const app = createApp(MessageBox)
    const instance = app.mount(mountNode)

    instance
      .confirm(message, title, config)
      .then((res) => {
        resolve(res)
        app.unmount()
        document.body.removeChild(mountNode)
      })
      .catch((err) => {
        reject(err)
        app.unmount()
        document.body.removeChild(mountNode)
      })
  })
}
import { createApp } from 'vue'
import MessageBox from '../components/MessageBox.vue'

export const showMessageBox = (message, title, config = {}) => {
  return new Promise((resolve, reject) => {
    const mountNode = document.createElement('div')
    document.body.appendChild(mountNode)

    const app = createApp(MessageBox)
    const instance = app.mount(mountNode)

    instance
      .confirm(message, title, config)
      .then((res) => {
        resolve(res)
        app.unmount()
        document.body.removeChild(mountNode)
      })
      .catch((err) => {
        reject(err)
        app.unmount()
        document.body.removeChild(mountNode)
      })
  })
}

main.js注册组件

js
import { showMessageBox } from './plugins/messageBox.js'
app.config.globalProperties.$messageBox = showMessageBox
import { showMessageBox } from './plugins/messageBox.js'
app.config.globalProperties.$messageBox = showMessageBox

页面调用命令式组件

vue
<template>
  <div class="home">
    <button @click="showConfirmDialog" class="custom-button">打开确认框</button>
  </div>
</template>

<script setup>
import { getCurrentInstance } from 'vue'
const instance = getCurrentInstance()
const messageBox = instance.appContext.config.globalProperties.$messageBox
const showConfirmDialog = async () => {
  messageBox('此操作将永久删除该文件, 是否继续?', '提示', {
    confirmText: '确定',
    cancelText: '取消',
  }).then(() => {
      alert('确定')
  }).catch(() => {
      alert('取消')
   })
}
</script>

<style scoped>
.custom-button {
  padding: 10px 20px;
  background: #409eff;
  color: white;
  border: none;
  border-radius: 4px;
  cursor: pointer;
  font-size: 14px;
  transition: all 0.3s;
}

.custom-button:hover {
  background: #66b1ff;
}
</style>
<template>
  <div class="home">
    <button @click="showConfirmDialog" class="custom-button">打开确认框</button>
  </div>
</template>

<script setup>
import { getCurrentInstance } from 'vue'
const instance = getCurrentInstance()
const messageBox = instance.appContext.config.globalProperties.$messageBox
const showConfirmDialog = async () => {
  messageBox('此操作将永久删除该文件, 是否继续?', '提示', {
    confirmText: '确定',
    cancelText: '取消',
  }).then(() => {
      alert('确定')
  }).catch(() => {
      alert('取消')
   })
}
</script>

<style scoped>
.custom-button {
  padding: 10px 20px;
  background: #409eff;
  color: white;
  border: none;
  border-radius: 4px;
  cursor: pointer;
  font-size: 14px;
  transition: all 0.3s;
}

.custom-button:hover {
  background: #66b1ff;
}
</style>

补一个vue3局部注册使用

直接页面调用命令式组件

vue
<template>
  <div class="home">
    <button @click="showConfirmDialog" class="custom-button">打开确认框</button>
  </div>
</template>

<script setup>
import { showMessageBox } from '../utils/messageBox'

const showConfirmDialog = async () => {
  showMessageBox('此操作将永久删除该文件, 是否继续?', '提示', {
    confirmText: '确定',
    cancelText: '取消',
  }).then(() => {
      alert('确定')
  }).catch(() => {
      alert('取消')
   })
}

</script>

<style scoped>
.custom-button {
  padding: 10px 20px;
  background: #409eff;
  color: white;
  border: none;
  border-radius: 4px;
  cursor: pointer;
  font-size: 14px;
  transition: all 0.3s;
}

.custom-button:hover {
  background: #66b1ff;
}
</style>
<template>
  <div class="home">
    <button @click="showConfirmDialog" class="custom-button">打开确认框</button>
  </div>
</template>

<script setup>
import { showMessageBox } from '../utils/messageBox'

const showConfirmDialog = async () => {
  showMessageBox('此操作将永久删除该文件, 是否继续?', '提示', {
    confirmText: '确定',
    cancelText: '取消',
  }).then(() => {
      alert('确定')
  }).catch(() => {
      alert('取消')
   })
}

</script>

<style scoped>
.custom-button {
  padding: 10px 20px;
  background: #409eff;
  color: white;
  border: none;
  border-radius: 4px;
  cursor: pointer;
  font-size: 14px;
  transition: all 0.3s;
}

.custom-button:hover {
  background: #66b1ff;
}
</style>

程序员小洛文档