Logo
Published on

VUE3封装消息对话框组件

Authors
  • avatar
    Name
    Monster Cone
    Twitter

因为要满足 UI 设计,所以直接使用库的 Dialog 需要调整的 style 太多了,还要增加项目大小,因此直接抛弃了 UI 库,自己封装。

效果图

image Image

组件代码

Template

<template>
  <div
    ref="MessageBoxRef"
    v-show="visible"
    class="message-mask"
    :class="{ [`${customClass}`]: true }"
    :style="{ zIndex: zIndex }"
    @click.self="handleClickModal"
  >
    <div class="box center">
      <div class="content">
        <h1 class="title">{{ title }}</h1>
        <p class="message">{{ message }}</p>
      </div>
      <div class="btn__group">
        <button
          v-if="showCancalButton"
          class="btn btn__cancal"
          :style="{ '--color': cancalColor }"
          @click="handleAction('cancel')"
        >
          {{ cancalText }}
        </button>
        <button
          class="btn btn__confirm"
          :style="{ '--color': confirmColor }"
          @click="handleAction('confirm')"
        >
          {{ confirmText }}
        </button>
      </div>
    </div>
  </div>
</template>

Script

Props

const props = defineProps({
  title: {
    // 标题
    type: String,
    default: () => '',
  },
  message: {
    // 提示
    type: String,
    default: () => '',
  },
  confirmText: {
    // 确认按钮文案
    type: String,
    default: () => '确认',
  },
  cancalText: {
    // 取消按钮文案
    type: String,
    default: () => '取消',
  },
  confirmColor: {
    // 确认按钮颜色
    type: String,
    default: () => '#6a7fdb',
  },
  cancalColor: {
    // 取消按钮颜色
    type: String,
    default: () => '',
  },
  closeOnClickModal: {
    // 是否点击遮罩层关闭
    type: Boolean,
    default: () => true,
  },
  customClass: {
    // 自定义class
    type: String,
    default: () => '',
  },
  showCancalButton: {
    // 是否显示取消按钮
    type: Boolean,
    default: () => true,
  },
  useAnimation: {
    // 是否使用动画
    type: Boolean,
    default: () => true,
  },
})

自定义 zIndex 递增 Hook

// useIndex.ts
import { ref, computed } from 'vue'

const initialZIndex = 2e3
const zIndex = ref(0)
const useZIndex = () => {
  const currentZIndex = computed(() => initialZIndex + zIndex.value)
  const nextZIndex = () => {
    zIndex.value++
    return currentZIndex.value
  }
  return {
    nextZIndex,
  }
}

export default useZIndex

初始 zIndex 为 2000,每次调用 useZIndex 方法都会自增再返回新的 zIndex。保证多个 Message 时不会重叠。

组件挂载

const zIndex = ref(0)
const { nextZIndex } = useZIndex()

const hideBody = () => {
  document.body.classList.add('message-overflow-hidden')
}

const fadeIn = async () => {
  return new Promise((resolve) => {
    MessageBoxRef?.value.classList.add('fade-in')
    setTimeout(() => {
      MessageBoxRef?.value.classList.remove('fade-in')
      resolve(null)
    }, 300)
  })
}

onMounted(() => {
  zIndex.value = nextZIndex()
  nextTick().then(() => ((visible.value = true), hideBody(), props.useAnimation && fadeIn()))
})

先获取新的 zIndex,在元素已经挂载到目标元素上后显示组件,同时禁止滚动,如果使用动画,添加 animation class。

message-overflow-hidden class 不能定义在 scoped 作用域的 style 标签。

事件交互

const fadeOut = async () => {
	return new Promise(resolve => {
		MessageBoxRef.value?.classList.add('fade-out')
		setTimeout(() => {
			MessageBoxRef.value?.classList.remove('fade-out')
			resolve(null)
		}, 250)
	})
}

const showBody = () => {
	document.body.classList.remove('message-overflow-hidden')
}

const handleClickModal = () => {
	if (props.closeOnClickModal) handleAction('close')
}

const handleAction = (type: any) => {
	action.value = type
	doClose()
}

const doClose = async () => {
	if (!visible.value) return
	if (props.useAnimation) await fadeOut()
	visible.value = false
	showBody()
	nextTick(() => {
		if (action.value) emits('action', action.value)
	})
}

任何事件都先修改 action 值,再调用关闭方法。如果使用动画,等待动画调用完成再隐藏。然后放开滚动,抛出事件。

因为动画时长默认是 300 毫秒,因此移除 fade-out 需要小于 300 毫秒,否则会导致样式闪烁

组件实例封装

底层实现

import { createVNode, render } from 'vue'
import Message from './Message.vue'
import { MessageOptions, ActionType } from './types'

const messageInstance = new Map()

const getContainer = (): HTMLElement => {
	return document.createElement('div')
}

const initInstance = (props: MessageOptions, container: HTMLElement) => {
	const vnode = createVNode(Message, props)
	render(vnode, container)
	document.body.appendChild(container.firstElementChild)
	return vnode.component
}

const showMessage = (options: MessageOptions) => {
	const container = getContainer()

	options.onAction = (action: ActionType) => {
		const curInstance = messageInstance.get(vm)
		setTimeout(() => {
			render(null, container)
			messageInstance.delete(vm)
		}, 0)
		switch (action) {
			case 'confirm':
				return curInstance.resolve(curInstance.resolve)
			case 'cancel':
				return curInstance.reject('cancal')
			case 'close':
				return curInstance.reject('close')
		}
	}

	const instance = initInstance(options, container)

	const vm = instance.proxy

	for (const prop in options) {
		if (options.hasOwnProperty(prop) && !vm.$props.hasOwnProperty(prop)) {
			vm[prop] = options[prop]
		}
	}

	return vm
}

const MessageBox: MessageBoxInstance = async (options: MessageOptions): Promise => {
	const container = document.createElement('div')
	return new Promise((resolve, reject) => {
		const vm = showMessage(options)
		messageInstance.set(vm, {
			options,
			resolve,
			reject
		})
	})
}

此处参考了 element-plus 的封装方式。定义 MessageBox 方法,返回 Promise 回调,同时将 Promise 的回调方法添加到 Map 对象中。showMessage 方法在 props 上 添加 onAction 方法监听 action 回调。然后将组件挂载到目标元素上,将参数赋值给组件实例,并返回。

onAction 方法可以接收组件通过 defineEmits 定义的事件。从 Map 对象中获取当前组件的 Promise 回调,判断事件调用对应的回调方法。在完成对话的时候我们还需要销毁元素,通过 setTimeout 将 null 渲染到目标元素上,同时删除 Map 中的组件实例。

方法扩展

const MESSAGE_BOX_FUNC: string[] = ['confirm', 'alert']
const MESSAGE_INIT_PROPS = {
  confirm: { showCancalButton: true },
  alert: { showCancalButton: false, closeOnClickModal: false },
}

const MessageBoxInit: MessageBoxInstance = (type: string) => {
  return (title: string, message: string, options: MessageOptions) => {
    return MessageBox({
      title,
      message,
      type,
      ...options,
      ...MESSAGE_INIT_PROPS[type],
    })
  }
}

MESSAGE_BOX_FUNC.forEach((type: string) => {
  MessageBox[type] = MessageBoxInit(type)
})

MessageBox.close = () => {
  messageInstance.forEach((_, vm) => {
    vm.doClose()
  })

  messageInstance.clear()
}

MESSAGE_BOX_FUNC 定义了两个对话框类型,MESSAGE_INIT_PROPS 定义他们的相关参数,MessageBoxInit 方法扩展参数便于调用,最后再整合参数调用 MessageBox 方法。遍历 MESSAGE_BOX_FUNC,将 type 挂载到 MessageBox 的原型上。在 MessageBox 原型上再添加一个 close 方法清空所有的对话框。

组件使用

<template>
  <button @click="handleConfirm">点我试试Confirm</button>
  <button @click="handleAlert">点我试试Alert</button>
</template>
<script setup lang="ts">
import Message from './XX/MessageBox'
const handleConfirm = () => {
  Message.confirm('标题', '内容')
    .then((_) => {
      // do something
    })
    .catch((_) => {
      // do something
    })
}

const handleAlert = () => {
  Message.alert('标题', '内容')
    .then((_) => {
      // do something
    })
    .catch((_) => {
      // do something
    })
}
</script>

完整代码

import { createVNode, render } from 'vue' import Message from './Message.vue' import {
MessageOptions, ActionType } from './types' const messageInstance = new Map() const getContainer =
(): HTMLElement => { return document.createElement('div') } const initInstance = (props:
MessageOptions, container: HTMLElement) => { const vnode = createVNode(Message, props) render(vnode,
container) document.body.appendChild(container.firstElementChild) return vnode.component } const
showMessage = (options: MessageOptions) => { const container = getContainer() options.onAction =
(action: ActionType) => { const curInstance = messageInstance.get(vm) setTimeout(() => {
render(null, container) messageInstance.delete(vm) }, 0) switch (action) { case 'confirm': return
curInstance.resolve(curInstance.resolve) case 'cancel': return curInstance.reject('cancal') case
'close': return curInstance.reject('close') } } const instance = initInstance(options, container)
const vm = instance.proxy for (const prop in options) { if (options.hasOwnProperty(prop) &&
!vm.$props.hasOwnProperty(prop)) { vm[prop] = options[prop] } } return vm } const MessageBox:
MessageBoxInstance = async (options: MessageOptions): Promise => { const container =
document.createElement('div') return new Promise((resolve, reject) => { const vm =
showMessage(options) messageInstance.set(vm, { options, resolve, reject }) }) } const
MESSAGE_BOX_FUNC: string[] = ['confirm', 'alert'] const MESSAGE_INIT_PROPS = { confirm: {
showCancalButton: true }, alert: { showCancalButton: false, closeOnClickModal: false } } const
MessageBoxInit: MessageBoxInstance = (type: string) => { return (title: string, message: string,
options: MessageOptions) => { return MessageBox({ title, message, type, ...options,
...MESSAGE_INIT_PROPS[type] }) } } MESSAGE_BOX_FUNC.forEach((type: string) => { MessageBox[type] =
MessageBoxInit(type) }) MessageBox.close = () => { messageInstance.forEach((\_, vm) => {
vm.doClose() }) messageInstance.clear() } export default MessageBox
<template>
  <div
    ref="MessageBoxRef"
    v-show="visible"
    class="message-mask"
    :class="{ [`${customClass}`]: true }"
    :style="{ zIndex: zIndex }"
    @click.self="handleClickModal"
  >
    <div class="box center">
      <div class="content">
        <h1 class="title">{{ title }}</h1>
        <p class="message">{{ message }}</p>
      </div>
      <div class="btn__group">
        <button
          v-if="showCancalButton"
          class="btn btn__cancal"
          :style="{ '--color': cancalColor }"
          @click="handleAction('cancel')"
        >
          {{ cancalText }}
        </button>
        <button
          class="btn btn__confirm"
          :style="{ '--color': confirmColor }"
          @click="handleAction('confirm')"
        >
          {{ confirmText }}
        </button>
      </div>
    </div>
  </div>
</template>

<script lang="ts" setup>
import { ref, defineProps, defineEmits, nextTick, onMounted, watch } from 'vue'
import useZIndex from './useIndex'

const emits = defineEmits(['confirm', 'cancel', 'action'])

const props = defineProps({
  title: {
    type: String,
    default: () => '',
  },
  message: {
    type: String,
    default: () => '',
  },
  confirmText: {
    type: String,
    default: () => '确认',
  },
  cancalText: {
    type: String,
    default: () => '取消',
  },
  confirmColor: {
    type: String,
    default: () => '#6a7fdb',
  },
  cancalColor: {
    type: String,
    default: () => '',
  },
  closeOnClickModal: {
    type: Boolean,
    default: () => true,
  },
  customClass: {
    type: String,
    default: () => '',
  },
  showCancalButton: {
    type: Boolean,
    default: () => true,
  },
  useAnimation: {
    type: Boolean,
    default: () => true,
  },
})

const MessageBoxRef = ref<any>(null)
const visible = ref<boolean>(false)
const action = ref<string>('')
const zIndex = ref<number>(0)
const { nextZIndex } = useZIndex()

onMounted(() => {
  zIndex.value = nextZIndex()
  nextTick().then(() => ((visible.value = true), hideBody(), props.useAnimation && fadeIn()))
})

const handleClickModal = () => {
  if (props.closeOnClickModal) handleAction('close')
}

const handleAction = (type: any) => {
  action.value = type
  doClose()
}

const doClose = async () => {
  if (!visible.value) return
  if (props.useAnimation) await fadeOut()
  visible.value = false
  showBody()
  nextTick(() => {
    if (action.value) emits('action', action.value)
  })
}

const hideBody = () => {
  document.body.classList.add('message-overflow-hidden')
}

const showBody = () => {
  document.body.classList.remove('message-overflow-hidden')
}

const fadeIn = async () => {
  return new Promise((resolve) => {
    MessageBoxRef?.value.classList.add('fade-in')
    setTimeout(() => {
      MessageBoxRef?.value.classList.remove('fade-in')
      resolve(null)
    }, 300)
  })
}

const fadeOut = async () => {
  return new Promise((resolve) => {
    MessageBoxRef.value?.classList.add('fade-out')
    setTimeout(() => {
      MessageBoxRef.value?.classList.remove('fade-out')
      resolve(null)
    }, 250)
  })
}
</script>
<style scoped>
* {
  box-sizing: border-box;
  margin: 0;
  padding: 0;
}
button {
  outline: none;
  border: none;
  background-color: transparent;
}
.message-mask {
  position: fixed;
  top: 0;
  left: 0;
  display: grid;
  place-items: center;
  padding: 0 42px;
  height: 100vh;
  width: 100%;
  background-color: rgba(0, 0, 0, 0.7);
}
.message-mask .box {
  width: 100%;
  max-width: 100%;
  max-height: 90vh;
  display: flex;
  flex-direction: column;
  border-radius: 16px;
  background-color: #fff;
}
.message-mask .box.center {
  text-align: center;
}
.message-mask .content {
  flex: 1;
  padding: 24px 0 20px;
  width: 100%;
  display: flex;
  flex-direction: column;
}
.message-mask .title {
  font-size: 17px;
  font-family:
    PingFangSC-Medium,
    PingFang SC;
  font-weight: 500;
  color: #000000;
  line-height: 26px;
  word-wrap: break-word;
}
.message-mask .message {
  flex: 1;
  max-height: 60vh;
  padding: 0 24px;
  margin-top: 6px;
  font-size: 14px;
  font-family:
    PingFangSC-Regular,
    PingFang SC;
  font-weight: 400;
  color: #000000;
  line-height: 22px;
  opacity: 0.6;
  word-wrap: break-word;
  overflow: auto;
}
.message-mask .btn__group {
  height: 44px;
  line-height: 44px;
  display: flex;
  flex-direction: row;
  border-top: 1px solid #f0f1f4;
}
.message-mask .btn__group > .btn {
  flex: 1;
  width: 100%;
  font-size: 14px;
  color: var(--color);
  text-align: center;
  cursor: pointer;
}
.message-mask .btn__group .btn:not(:first-child):last-child {
  border-left: 1px solid #f0f1f4;
}
.fade-in .box {
  animation: box-fade-in 0.3s ease-in-out;
}
.fade-out .box {
  animation: box-fade-out 0.3s ease-in-out;
}
.fade-in.message-mask {
  animation: mask-fade-in 0.3s ease-in-out;
}
.fade-out.message-mask {
  animation: mask-fade-out 0.3s ease-in-out;
}
@keyframes box-fade-in {
  0% {
    opacity: 0;
    transform: scale(0.5);
  }
  100% {
    opacity: 1;
    transform: scale(1);
  }
}

@keyframes box-fade-out {
  0% {
    opacity: 1;
  }
  100% {
    opacity: 0;
  }
}
@keyframes mask-fade-in {
  0% {
    background-color: rgba(0, 0, 0, 0);
  }
  100% {
    background-color: rgba(0, 0, 0, 0.7);
  }
}
@keyframes mask-fade-out {
  0% {
    background-color: rgba(0, 0, 0, 0.7);
  }
  100% {
    background-color: rgba(0, 0, 0, 0);
  }
}
</style>
<style>
.message-overflow-hidden {
  overflow: hidden !important;
}
</style>
export interface MessageOptions {
title?: String,
message?: String,
confirmText?: String;
cancalText?: String;
confirmColor?: String;
cancalColor?: String;
closeOnClickModal?: Boolean;
customClass?: String,
showCancalButton?: Boolean,
useAnimation?: Boolean,
}

export type ActionType = 'confirm' | 'cancel' | 'close';

export interface MessageBoxInstance {
close: (): void;
confirm: (reslove: function (params: string): void, reject: function (params: string): void)
alert: (reslove: function (params: string): void)
}
import { ref, computed } from 'vue' const initialZIndex = 2e3 const zIndex = ref
<number></number>