用户实现方式
import {CHMessage} from "@/components/message";
CHMessage({
message: '你',
type: 'error',
})
疑惑
组件如何挂载,如何销毁
实现ui层面
<template>
<transition
name="message-animation"
>
<div
role="alert"
:id="props.id"
:class="['message',classNameType,{message_padding_right:showClose}]"
:style="{top:props.offset+'px'}"
v-show="isShowMessage"
>
<slot>
<p
v-if="!props.dangerouslyUseHTMLString"
:class="[{message_p_padding_right:props.showClose}]"
>
{{ props.message }}
</p>
<p
v-else
:class="[{message_p_padding_right:props.showClose}]"
v-html="props.message"
></p>
</slot>
<div class="message-icon" v-if="props.showClose" @click="showMessage(false)"></div>
</div>
</transition>
</template>
<script lang="ts" setup name="CHMessage">
import {computed, onMounted, ref, VNode} from "vue";
import {messageType} from "@/components/message/type";
/**组件是否显示*/
const isShowMessage = ref<boolean>(false)
interface IProps {
/**消息类型*/
type?: messageType,
/**消息内容*/
message?: string | VNode | Object,
/**显示时间,单位为毫秒。 设为 0 则不会自动关闭*/
duration?: number
/**设置组件的根元素*/
appendTo?: string | HTMLElement,
/**Message 距离窗口顶部的偏移量*/
offset?: number
/**组件id*/
id: string
/**关闭回调*/
onClose?: () => void
/**当前是否渲染,html字符串*/
dangerouslyUseHTMLString?: boolean
/**是否显示关闭按钮*/
showClose?: boolean
}
/**消息类型 - 计算属性*/
const classNameType = computed(() => {
return props.type
})
const props = withDefaults(defineProps<IProps>(), {
type: 'info',
duration: 3000,
message: '',
offset: 20,
id: '',
dangerouslyUseHTMLString: false,
showClose: false
})
</script>
<style lang="scss" scoped>
.message {
width: 100%;
min-width: 380px;
max-width: 50%;
height: auto;
font-size: 14px;
box-sizing: border-box;
border-radius: 4px;
padding: 15px;
position: fixed;
left: 50%;
transform: translateX(-50%);
display: flex;
transition: opacity .3s, transform .4s, top .4s;
top: 20px;
p {
margin: 0;
padding: 0;
}
.message_p_padding_right {
padding-right: 32px;
}
.message_padding_right {
padding-left: 20px;
}
&.info {
color: #909399;
background-color: #f4f4f5;
}
&.success {
color: #67c23a;
background-color: #f0f9eb;
}
&.warning {
color: #e6a23c;
background-color: #fdf6ec;
}
&.error {
color: #f56c6c;
background-color: #fef0f0;
}
//关闭图标
.message-icon {
position: absolute;
right: 15px;
top: 50%;
transform: translateY(-50%);
width: 15px;
height: 15px;
background-color: #222222;
}
}
.message-animation-enter-from,
.message-animation-leave-to, {
transform: translate(-50%, -100%);
opacity: 0;
}
.message-animation-enter-active {
transition: all .3s ease-in;
}
</style>
挂载ui
- 用户是通过函数的方式,调用显示ui层,所以我们返回一个
CHMessage函数 - 判断用户有没有传
appendTo(设置组件的根元素),没有传递,默认就是body,如果是字符串,还得先获取节点,如果传递进来的就是节点,也得判断一下是否是正常节点 - 需要为
message赋予一个唯一id - 创建一个
props对象,传递给message组件 - 创建一个
div节点,同时赋值一个className - 通过vue3的
createVNode函数,创建一个VNode(虚拟节点) - 在通过vue3的
render函数,把VNode节点挂载到div节点上 - 往
appendTo节点追加VNode节点,这时组件挂载到了真实节点上 - 往
messageVms数组中追加VNode节点,保存当前节点,用于后续的销毁 - 当前创建
VNode节点的el实例可能是text类型,在这强行的把el实例指向我们的message组件vm.el = document.getElementById(id) - 方便开发者可以快速实现
Message类型,比如:CHMessage.error({message: '你好'}),往CHMessage函数定义:info、success、warning、errormessageType.forEach((type) => { CHMessage[type] = (options: IPropsMessage = {}) => { options.type = type return CHMessage(options) } })
import {render, createVNode, VNode, ComponentPublicInstance} from "vue";
import Message from './message.vue'
import {IMessage, IPropsMessage, MessageFunc, MessageQueue} from "@/components/message/type";
const messageType = ['info', 'success', 'warning', 'error'] as const
let messageVms: MessageQueue = [] // 节点数组
let seed = 1 // 组件id值
export const CHMessage: MessageFunc = (options?: IPropsMessage): IMessage => {
// 节点挂载位置,body
let appendTo: HTMLElement | null = document.body;
if (typeof options?.appendTo === 'string') {
appendTo = document.querySelector(options.appendTo)
} else if (isElement(options?.appendTo)) {
appendTo = options?.appendTo || null
}
// 判断 appendTo 是否是 html节点
if (!isElement(appendTo)) {
appendTo = document.body
}
// 创建props
let id = `message_${seed++}`
let userOnClose = options?.onClose
let props = {
...options,
id,
}
// 创建节点
const container = document.createElement('div')
container.className = `container_${id}`
const vm = createVNode(
Message,
props,
typeof options?.message === 'function'
? {default: options.message}
: typeof options?.message === 'object'
? {default: () => options?.message}
: null
)
render(vm, container)
messageVms.push({vm}) //往节点数组中追加节点
appendTo.appendChild(container.firstElementChild!) //渲染dom
// 获取当前组件的dom节点覆盖,vue实例上的el节点信息
vm.el = document.getElementById(id)
}
//给 messageType 添加属性
messageType.forEach((type) => {
CHMessage[type] = (options: IPropsMessage = {}) => {
options.type = type
return CHMessage(options)
}
})
//判断节点
const isElement = (e: unknown): e is Element => {
if (typeof Element === 'undefined') return false
return e instanceof Element
}
显示MessageUi层
虽然Messageui组已经挂载到了真实节点上,但还是看不到Message组件,现在来实现组件内部逻辑,让组件显示出来。
组件已经挂载到了真实节点上,这个时候会触发vue的生命周期钩子onMounted,通过生命周期钩子,实现组件的显示。
同时实现一个倒计时函数,倒计时完成,销毁当前组件,需要判断props.duration不为零,才执行倒计时,如props.duration为零说明,当前组件不需要自动关闭。
onMounted(() => {
// 显示组件
showMessage(true)
// 开始计时
timer()
})
/**
* 是否现在组件
* @param isShow
*/
const showMessage = (isShow: boolean = true) => {
isShowMessage.value = isShow;
}
/**
* 计时器 props.duration 后关闭
*/
let setTimeoutVariable: NodeJS.Timeout | null;
const timer = () => {
props.duration && (
setTimeoutVariable = setTimeout(() => {
showMessage(false)
clearTimeout(setTimeoutVariable as NodeJS.Timeout)
setTimeoutVariable = null
}, props.duration)
)
}
销毁组件
虽然倒计时函数关闭了组件,至少在界面上已经看不到当前组件了,但是组件实例还在,而且真实的节点上也是存在当前组件的。
可以借助,vue的动画组件transition的@after-leave事件回调,动画完成,且元素已从DOM中移除给发送事件emit('destroy'),来告知销毁真实节点。
销毁还没有完成,只是销毁了真实节点,在messageVms数组中还存在着组件实例,找到数组中对应的实例,进行删除操作,@before-leave调用props.onClose函数,来销毁VNode
- ui
<transition
name="message-animation"
@before-leave="props.onClose"
@after-leave="emit('destroy')"
></transition>
- CHMessage函数 销毁真实节点
// 清除消息元素以防止内存泄漏 销毁组件
vm.props!.onDestroy = () => {
render(null, container)
// 由于元素被销毁,那么 VNode 也应该被 GC 收集
// 我们不希望导致任何内存泄漏,因为我们已返回 vm 作为对用户的引用
// 以便我们手动将其设置为 false。
}
- 新建销毁函数
const closure = (id: string, appendTo: HTMLElement, userOnClose?: (vm: VNode) => void) => {
const idIndex = messageVms.findIndex(({vm}) => id === vm.component!.props.id) //当前组件的索引
if (idIndex === -1) return
const {vm} = messageVms[idIndex];//取出当前组件
if (!vm) return;
userOnClose?.(vm)
}
- 在给
Message组件传递参数的时候传递一个关闭方法
let props = {
...options,
id,
offset: verticalOffset,
onClose: () => {
closure(id, userOnClose)
}
}
计算组件头部位置
Message组件能实现创建和销毁,但位置不对,多次调用,会在同一个位置,计算一下每次创建组件事的高度
- CHMessage函数,循环数组,获取每个组件的高度,最后传递给Message组件
// 设置头部距离
let verticalOffset = options?.offset || 20
messageVms.forEach(({vm}) => {
verticalOffset += (vm.el?.offsetHeight || 0) + 16
})
verticalOffset += 16
// 创建props
let id = `message_${seed++}`
let userOnClose = options?.onClose
let props = {
...options,
id,
offset: verticalOffset,
onClose: () => {
closure(id, userOnClose)
}
}
- closure 销毁函数,组件在销毁时,要重新计算每个组件的头部距离
const closure = (id: string, userOnClose?: (vm: VNode) => void) => {
const idIndex = messageVms.findIndex(({vm}) => id === vm.component!.props.id) //当前组件的索引
if (idIndex === -1) return
const {vm} = messageVms[idIndex];//取出当前组件
if (!vm) return;
userOnClose?.(vm)
const removedHeight = vm.el!.offsetHeight //保存当前要删除的组件高度
messageVms.splice(idIndex, 1)
const len = messageVms.length
if (len < 1) return;
for (let i = 0; i < len; i++) {
const pos = Number.parseInt(messageVms[i].vm.el!.style['top'], 10) - removedHeight - 16
messageVms[i].vm.component!.props.offset = pos
}
}
完整代码
- vue
<template>
<transition
name="message-animation"
@before-leave="props.onClose"
@after-leave="emit('destroy')"
>
<div
role="alert"
:id="props.id"
:class="['message',classNameType,{message_padding_right:showClose}]"
:style="{top:props.offset+'px'}"
v-show="isShowMessage"
@mouseenter="clearTimer"
@mouseleave="startTimer"
>
<slot>
<p
v-if="!props.dangerouslyUseHTMLString"
:class="[{message_p_padding_right:props.showClose}]"
>
{{ props.message }}
</p>
<p
v-else
:class="[{message_p_padding_right:props.showClose}]"
v-html="props.message"
></p>
</slot>
<div class="message-icon" v-if="props.showClose" @click="showMessage(false)"></div>
</div>
</transition>
</template>
<script lang="ts" setup name="CHMessage">
import {computed, onMounted, ref, VNode} from "vue";
import {messageType} from "@/components/message/type";
/**组件是否显示*/
const isShowMessage = ref<boolean>(false)
interface IProps {
/**消息类型*/
type?: messageType,
/**消息内容*/
message?: string | VNode | Object,
/**显示时间,单位为毫秒。 设为 0 则不会自动关闭*/
duration?: number
/**设置组件的根元素*/
appendTo?: string | HTMLElement,
/**Message 距离窗口顶部的偏移量*/
offset?: number
/**组件id*/
id: string
/**关闭回调*/
onClose?: () => void
/**当前是否渲染,html字符串*/
dangerouslyUseHTMLString?: boolean
/**是否显示关闭按钮*/
showClose?: boolean
}
const props = withDefaults(defineProps<IProps>(), {
type: 'info',
duration: 3000,
message: '',
offset: 20,
id: '',
dangerouslyUseHTMLString: false,
showClose: false
})
onMounted(() => {
// 显示组件
showMessage(true)
// 开始计时
timer()
})
/**消息类型 - 计算属性*/
const classNameType = computed(() => {
return props.type
})
/**
* 是否现在组件
* @param isShow
*/
const showMessage = (isShow: boolean = true) => {
isShowMessage.value = isShow;
}
/**
* 计时器 props.duration 后关闭
*/
let setTimeoutVariable: NodeJS.Timeout | null;
const timer = () => {
props.duration && (
setTimeoutVariable = setTimeout(() => {
showMessage(false)
clearTimeout(setTimeoutVariable as NodeJS.Timeout)
setTimeoutVariable = null
}, props.duration)
)
}
/**
* 鼠标移入
*/
const clearTimer = () => {
clearTimeout(setTimeoutVariable as NodeJS.Timeout)
setTimeoutVariable = null
}
/**
* 鼠标移出
*/
const startTimer = () => {
timer()
}
const emit = defineEmits<{
(e: 'destroy'): void
}>()
</script>
<style lang="scss" scoped>
.message {
width: 100%;
min-width: 380px;
max-width: 50%;
height: auto;
font-size: 14px;
box-sizing: border-box;
border-radius: 4px;
padding: 15px;
position: fixed;
left: 50%;
transform: translateX(-50%);
display: flex;
transition: opacity .3s, transform .4s, top .4s;
top: 20px;
p {
margin: 0;
padding: 0;
}
.message_p_padding_right {
padding-right: 32px;
}
.message_padding_right {
padding-left: 20px;
}
&.info {
color: #909399;
background-color: #f4f4f5;
}
&.success {
color: #67c23a;
background-color: #f0f9eb;
}
&.warning {
color: #e6a23c;
background-color: #fdf6ec;
}
&.error {
color: #f56c6c;
background-color: #fef0f0;
}
//关闭图标
.message-icon {
position: absolute;
right: 15px;
top: 50%;
transform: translateY(-50%);
width: 15px;
height: 15px;
background-color: #222222;
}
}
.message-animation-enter-from,
.message-animation-leave-to, {
transform: translate(-50%, -100%);
opacity: 0;
}
.message-animation-enter-active {
transition: all .3s ease-in;
}
</style>
- js
import {render, createVNode, VNode, ComponentPublicInstance} from "vue";
import Message from './message.vue'
import {IMessage, IPropsMessage, MessageFn, MessageFunc, MessageQueue} from "@/components/message/type";
const messageType = ['info', 'success', 'warning', 'error'] as const
let messageVms: MessageQueue = [] // 节点数组
let seed = 1 // 组件id值
// @ts-ignore
export const CHMessage: MessageFn & MessageFunc = (options?: IPropsMessage): IMessage => {
// 节点挂载位置,body
let appendTo: HTMLElement | null = document.body;
if (typeof options?.appendTo === 'string') {
appendTo = document.querySelector(options.appendTo)
} else if (isElement(options?.appendTo)) {
appendTo = options?.appendTo || null
}
// 判断 appendTo 是否是 html节点
if (!isElement(appendTo)) {
appendTo = document.body
}
// 设置头部距离
let verticalOffset = options?.offset || 20
messageVms.forEach(({vm}) => {
verticalOffset += (vm.el?.offsetHeight || 0) + 16
})
verticalOffset += 16
// 创建props
let id = `message_${seed++}`
let userOnClose = options?.onClose
let props = {
...options,
id,
offset: verticalOffset,
onClose: () => {
closure(id, userOnClose)
}
}
// 创建节点
const container = document.createElement('div')
container.className = `container_${id}`
const vm = createVNode(
Message,
props,
typeof options?.message === 'function'
? {default: options.message}
: typeof options?.message === 'object'
? {default: () => options?.message}
: null
)
// 清除消息元素以防止内存泄漏 销毁组件
vm.props!.onDestroy = () => {
render(null, container)
// 由于元素被销毁,那么 VNode 也应该被 GC 收集
// 我们不希望导致任何内存泄漏,因为我们已返回 vm 作为对用户的引用
// 以便我们手动将其设置为 false。
}
render(vm, container)
messageVms.push({vm}) //往节点数组中追加节点
appendTo.appendChild(container.firstElementChild!) //渲染dom
// 获取当前组件的dom节点覆盖,vue实例上的el节点信息
vm.el = document.getElementById(id)
return {
close: () => {
(vm.component!.proxy as ComponentPublicInstance<{ isShowMessage: boolean }>).isShowMessage = false
}
}
}
//给 messageType 添加属性
messageType.forEach((type) => {
CHMessage[type] = (options: IPropsMessage = {}) => {
options.type = type
return CHMessage(options)
}
})
const closure = (id: string, userOnClose?: (vm: VNode) => void) => {
const idIndex = messageVms.findIndex(({vm}) => id === vm.component!.props.id) //当前组件的索引
if (idIndex === -1) return
const {vm} = messageVms[idIndex];//取出当前组件
if (!vm) return;
userOnClose?.(vm)
const removedHeight = vm.el!.offsetHeight //保存当前要删除的组件高度
messageVms.splice(idIndex, 1)
const len = messageVms.length
if (len < 1) return;
for (let i = idIndex; i < len; i++) {
const pos = Number.parseInt(messageVms[i].vm.el!.style['top'], 10) - removedHeight - 16
messageVms[i].vm.component!.props.offset = pos
}
}
const isElement = (e: unknown): e is Element => {
if (typeof Element === 'undefined') return false
return e instanceof Element
}