在仿 ElementPlus 组件库的征程中,我们已经成功攻克了 Dropdown 组件的实现。接下来,让我们一同深入探索 Message 组件的实现。
一、什么是 Message 组件
Message 组件,即消息提示组件,以其轻量级、非阻塞的特性,在各类应用中频繁出现。无论是操作反馈、系统通知还是信息提醒,Message 组件都能及时且有效地将关键信息传达给用户,成为打造流畅、友好交互界面的重要基石。
二、实现 Message 组件
(一)组件目录
目录
components
├── Message
├── Message.vue
├── types.ts
├── method.ts
├── style.css
(二)实现 Message 组件基本功能
- 消息内容展示 :可以接收一个
message
属性,该属性可以是字符串或虚拟节点(VNode
),用于在组件中显示具体的消息内容。如果父组件没有传递内容,且message
属性有值,会通过RenderVnode
组件渲染该内容。 - 消息类型区分 :通过
type
属性来定义消息的类型,支持'success'
、'info'
、'warning'
、'danger'
四种类型。不同类型会为组件添加不同的 CSS 类名,从而实现不同的视觉样式,方便用户快速识别消息的性质。 - 显示时长控制 :
duration
属性用于设置消息显示的时长,单位为毫秒。当duration
为0
时,消息不会自动关闭;否则,在到达指定时间后消息会自动消失。 - 关闭按钮显示 :
showClose
属性控制是否显示关闭按钮。如果为true
,会在消息提示框中显示关闭按钮,用户点击关闭按钮可以手动关闭消息提示。 - 自动显示与隐藏 :在组件挂载到页面后,消息提示会自动显示(
visible
设为true
),并根据duration
的值启动定时器,在指定时间后自动隐藏(visible
设为false
)。
types.ts
import type { VNode } from 'vue'
export interface MessageProps {
message?: string | VNode
duration?: number
showClose?: boolean
type?: 'success' | 'info' | 'warning' | 'danger'
}
Message.vue
<template>
<div
class="yl-message"
v-show="visible"
:class="{
[`yl-message--${type}`]: type,
'is-close': showClose,
}"
role="alert"
>
<div class="yl-message__content">
<slot><RenderVnode :vNode="message" v-if="message" /> </slot>
</div>
<div class="yl-message__close" v-if="showClose">
<Icon @click.stop="visible = false" icon="xmark" />
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import type { MessageProps } from './types'
import RenderVnode from '../Common/RenderVnode'
import Icon from '../Icon/Icon.vue'
const props = withDefaults(defineProps<MessageProps>(), {
type: 'info',
duration: 3000,
})
const visible = ref(false)
function startTimer() {
if (props.duration === 0) return
setTimeout(() => {
visible.value = false
}, props.duration)
}
onMounted(() => {
visible.value = true
startTimer()
})
</script>
App.vue
import Message from './components/Message/Message.vue'
(三)将 Message 组件渲染到DOM节点
在之前代码基础上,将 Message
组件更简洁地渲染到 DOM 节点
- 渲染节点调整 :在
createMessage
函数中,从直接将包含Message
组件的container
元素添加到document.body
,改为添加container
的第一个子元素,即Message
组件渲染后生成的 DOM 节点,避免引入不必要的包裹元素,使页面结构更简洁。 - 非空断言使用 :使用非空断言操作符
!
来告诉 TypeScript,container.firstElementChild
不会是null
或undefined
,消除类型检查警告。
method.ts
import { render, h } from 'vue'
import type { MessageProps } from './types'
import MessageConstructor from './Message.vue'
export const createMessage = (props: MessageProps) => {
const container = document.createElement('div')
const vnode = h(MessageConstructor, props)
render(vnode, container)
//!非空断言操作符
document.body.appendChild(container.firstElementChild! )
}
App.vue
import { createMessage } from './components/Message/method'
(四)实现 Message 组件隐藏后的销毁功能
- 在原有的
Message
组件基础上,实现了组件隐藏后的销毁功能。 - 新增销毁函数 :在
createMessage
函数内部定义了destory
函数。该函数调用render(null, container)
,其作用是将null
渲染到之前创建的container
容器中,从而清空容器内的内容,实现组件的销毁。 - 传递销毁回调 :创建了一个新的属性对象
newProps
,它合并了原有的props
以及新添加的onDestory
属性,该属性的值为destory
函数。这样在创建Message
组件的虚拟节点时,就把销毁组件的回调函数传递给了Message
组件。 - 监听
visible
变化 :使用watch
函数监听visible
响应式变量的变化。visible
通常用于控制Message
组件的显示与隐藏状态。 - 触发销毁操作 :当
visible
的值变为false
(即组件隐藏)时,调用props.onDestory()
,也就是执行之前传递进来的destory
函数,从而实现组件隐藏后自动销毁的功能。
method.ts
export const createMessage = (props: MessageProps) => {
//...
const destory = () => {
render(null, container)
}
const newProps = {
...props,
onDestory: destory
}
//...
}
types.ts
import type { CreateMessageProps } from './types'
export const createMessage = (props: CreateMessageProps) => {
//...
const destory = () => {
render(null, container)
}
const newProps = {
...props,
onDestory: destory
}
const vnode = h(MessageConstructor, newProps)
//...
}
Message.vue
import { ref, onMounted, watch } from 'vue'
watch(visible, (newValue) => {
if (!newValue) {
props.onDestory()
}
})
(五)实现 Message 组件定位功能
通过维护一个 instances
数组来存储所有 Message
组件的实例信息,实现了获取不同实例内容的功能。同时,通过计算组件的偏移量,使得每个 Message
组件可以根据上一个组件的位置进行定位,避免组件之间的重叠。具体步骤如下:
- 为每个
Message
组件实例生成唯一的id
,并将实例信息存储在instances
数组中。 - 提供
getLastInstance
函数,方便获取最后一个组件实例。 - 定义
MessageProps
接口的offset
属性和MessageContext
接口的vm
属性,用于计算偏移量和存储组件内部实例。 - 实现
getLastBottomOffset
函数,用于获取上一个组件实例的底部偏移量。 - 在
Message.vue
组件中,通过计算lastOffset
、topOffset
和cssStyle
变量,实现组件的偏移定位。 - 在
onMounted
钩子中获取组件的高度,并通过defineExpose
暴露相关属性。
获取不同实例的内容
types.ts
export interface MessageContext {
id: string;
vnode: VNode;
props: MessageProps;
}
method.ts
import type { CreateMessageProps, MessageContext } from './types'
let seed = 1
const instances: MessageContext[] = []
export const createMessage = (props: CreateMessageProps) => {
const id = `message_${seed++}`
//...
const destory = () => {
//删除数组中的实例
const idx = instances.findIndex(instance => instance.id === id)
if (idx === -1) return
instances.splice(idx, 1)
render(null, container)
}
const instance = {
id,
vnode,
props: newProps,
}
instances.push(instance)
return instance
}
export const getLastInstance = () => {
return instances.length > 0 ? instances[instances.length - 1] : undefined
}
Message.vue
import { getLastInstance } from './method'
const prevInstance = getLastInstance()
console.log('prev',prevInstance);
计算偏移量以实现定位功能
types.ts
import type { VNode, ComponentInternalInstance } from 'vue'
export interface MessageProps {
//...
id: string
offset?: number;
}
export interface MessageContext {
//...
vm: ComponentInternalInstance
}
export type CreateMessageProps = Omit<MessageProps, 'onDestory' | 'id'>
method.ts
import { render, h, shallowReactive } from 'vue'
const instances: MessageContext[] = shallowReactive([])
const newProps = {
...props,
id,
onDestory: destory,
}
const vm = vnode.component!
const instance = {
id,
vnode,
vm,
props: newProps,
}
instances.push(instance)
return instance
}
export const getLastBottomOffset = (id: string) => {
const idx = instances.findIndex((instance) => instance.id === id)
console.log('idx', id, idx, instances.length)
if (idx <= 0) {
return 0
} else {
const prev = instances[idx - 1]
return prev.vm.exposed!.bottomOffset.value
}
}
Message.vue
<template>
<div
//...
:class="{
//...
ref="messageRef"
:style="cssStyle"
>
//...
</template>
import { ref, onMounted, watch, computed, nextTick } from 'vue'
const props = withDefaults(defineProps<MessageProps>(), {
//...
offset:20
})
const messageRef = ref<HTMLDivElement>()
//计算偏移高度
//这个 div 的高度
const height = ref(0)
//上一个实例的最下面的坐标数字,第一个是0
const lastOffset = computed(() => getLastBottomOffset(props.id))
//这个元素应该使用的 top
const topOffset = computed(() => props.offset + lastOffset.value)
//这个元素为下一个元素预留的 offset, 也就是他最底端 bottom 的值
const cssStyle = computed(() => ({
top: topOffset.value + 'px',
}))
onMounted(async () => {
//...
await nextTick()
height.value = messageRef.value!.getBoundingClientRect().height
})
defineExpose({
bottomOffset,
visible
})
(六)微调 Message 组件功能
添加手动删除
增加手动删除组件的功能。具体实现步骤如下:
- 定义了
manualDestroy
函数,该函数可以根据组件的id
在instances
数组中找到对应的组件实例,并将其visible
属性设置为false
,从而实现手动销毁组件的目的。 - 将
manualDestroy
函数作为destroy
属性添加到每个Message
组件实例对象中,使得每个实例都具备手动销毁的能力。 - 在
types.ts
文件中更新MessageContext
接口,添加destory
属性的类型定义,以保证 TypeScript 对代码的类型检查正常工作。
methods.ts
//手动调用删除,即手动调整组件中visible值,visible通过expose传出
const manualDestroy = () => {
const instance = instances.find((instance) => instance.id === id)
if (instance) {
instance.vm.exposed!.visible.value = false
}
}
const instance = {
//...
destroy: manualDestroy,
}
types.ts
export interface MessageContext {
//...
destory: () => void
}
添加z-index
为 Message
组件增加了 z-index
相关功能,实现对 Message
组件层叠顺序的动态控制。具体实现过程如下:
- 创建了
useZIndex
函数,用于管理z-index
的初始值、当前值以及生成下一个z-index
值。 - 在
types.ts
中更新了组件属性接口,增加zIndex
属性并定义了新的创建属性类型。 - 在
method.ts
中引入useZIndex
并使用nextZIndex
函数为每个Message
组件实例生成唯一递增的z-index
值。 - 在
Message.vue
中通过计算属性将z-index
应用到组件的样式中,使每个Message
组件在页面上能够根据z-index
值正确地进行层叠显示,避免消息组件之间的层叠冲突,提升了组件在复杂界面中的显示效果和交互性。
src/hooks/useZIndex.ts
import { computed, ref } from 'vue'
const zIndex = ref(0)
const useZIndex = (initialValue = 2000) => {
const initialZIndex = ref(initialValue)
const currentZIndex = computed(() => zIndex.value + initialZIndex.value)
const nextZIndex = () => {
zIndex.value ++
return currentZIndex.value
}
return {
currentZIndex,
nextZIndex,
initialZIndex
}
}
export default useZIndex
types.ts
export interface MessageProps {
//...
zIndex: number
}
export type CreateMessageProps = Omit<MessageProps, 'onDestory' | 'id' | 'zIndex'>
method.ts
import useZIndex from '@/hooks/useZIndex'
const { nextZIndex } = useZIndex()
const newProps = {
...props,
id,
zIndex: nextZIndex(),
onDestory: destory,
}
Message.vue
const cssStyle = computed(() => ({
//...
zIndex:props.zIndex
}))
添加键盘关闭
- 封装事件监听逻辑 :创建
useEventListener
组合式函数,将事件监听和移除的逻辑封装起来,提高了代码的复用性和可维护性。该函数能够处理target
为响应式引用或普通EventTarget
对象的情况,并在组件卸载时自动移除事件监听器,避免了内存泄漏问题。 - 实现键盘关闭功能 :在
Message.vue
组件中定义keydown
函数,用于处理键盘按下事件。当按下Escape
键时,关闭Message
组件。然后使用useEventListener
函数为document
对象添加keydown
事件监听器,使得在整个文档范围内按下Escape
键都能关闭Message
组件,增强了组件的交互性和用户体验。
src/hooks/useEventListener.ts
import { onMounted, onBeforeUnmount, isRef, watch, unref } from 'vue'
import type { Ref } from 'vue'
export default function useEventListener(
target: Ref<EventTarget | null> | EventTarget,
event: string,
handler: (e: Event) => any
) {
if (isRef(target)) {
watch(target, (value, oldValue) => {
oldValue?.removeEventListener(event, handler)
value?.addEventListener(event, handler)
})
} else {
onMounted(() => {
target.addEventListener(event, handler)
})
}
onBeforeUnmount(() => {
unref(target)?.removeEventListener(event, handler)
})
}
Message.vue
import useEventListener from '@/hooks/useEventListener'
function keydown(e: Event) {
const event = e as KeyboardEvent
if (event.code === 'Escape') {
visible.value = false
}
}
useEventListener(document, 'keydown', keydown)
添加鼠标悬停
- 交互逻辑增强 :通过在模板中绑定
mouseenter
和mouseleave
事件,使得Message
组件能够响应鼠标的进入和离开操作。 - 定时器控制 :利用
startTimer
和clearTimer
函数对定时器进行控制。当鼠标悬停在Message
组件上时,会清除定时器,暂停组件的自动关闭;当鼠标离开组件时,会重新启动定时器,在指定的duration
时间后关闭组件。这种交互方式提高了用户体验,让用户有足够的时间查看Message
组件的内容,避免了在用户还未查看完时组件就自动关闭的情况。
Message.vue
<template>
<div
//...
@mouseenter="clearTimer"
@mouseleave="startTimer"
>
//...
</template>
let timer: any
function startTimer() {
if (props.duration === 0) return
timer = setTimeout(() => {
visible.value = false
}, props.duration)
}
function clearTimer() {
clearTimeout(timer)
}
(七)为 Message 组件添加动画和样式
- 动画方面 :通过
<Transition>
组件和自定义的动画样式,实现了Message
组件的淡入淡出和移动动画效果。 - 样式方面 :使用 CSS 自定义属性和
@each
指令,为不同类型的Message
组件定义了不同的样式,并且对组件的基础样式进行了详细的设置。 - 逻辑调整 :对组件的生命周期钩子和一些逻辑进行了调整,使得组件的初始化、销毁和高度获取等操作更加合理。通过这些改动,
Message
组件在功能和视觉上都得到了进一步的完善。
types.ts
export interface MessageProps {
//...
transitionName?: string;
}
Message.vue
<template>
<Transition :name="transitionName" @after-leave="destroyComponent" @enter="updateHeight">
//...
</Transition>
</template>
import { ref, onMounted, computed } from 'vue'
const props = withDefaults(defineProps<MessageProps>(), {
//...
transitionName:'fade-up'
})
onMounted(async () => {
visible.value = true
startTimer()
// await nextTick()
// height.value = messageRef.value!.getBoundingClientRect().height
})
useEventListener(document, 'keydown', keydown)
function destroyComponent () {
props.onDestory()
}
function updateHeight() {
height.value = messageRef.value!.getBoundingClientRect().height
}
//删掉
// watch(visible, (newValue) => {
// if (!newValue) {
// props.onDestory()
// }
// })
style.css
.yl-message {
--yl-message-bg-color: var(--yl-color-info-light-9);
--yl-message-border-color: var(--yl-border-color-lighter);
--yl-message-padding: 15px 19px;
--yl-message-close-size: 16px;
--yl-message-close-icon-color: var(--yl-text-color-placeholder);
--yl-message-close-hover-color: var(--yl-text-color-secondary);
}
.yl-message {
width: fit-content;
max-width: calc(100% - 32px);
box-sizing: border-box;
border-radius: var(--yl-border-radius-base);
border-width: var(--yl-border-width);
border-style: var(--yl-border-style);
border-color: var(--yl-message-border-color);
position: fixed;
left: 50%;
top: 20px;
transform: translateX(-50%);
background-color: var(--yl-message-bg-color);
padding: var(--yl-message-padding);
display: flex;
align-items: center;
transition: top var(--yl-transition-duration), opacity var(--yl-transition-duration), transform var(--yl-transition-duration);
.yl-message__content {
color: var(--yl-message-text-color);
overflow-wrap: anywhere;
}
&.is-close .yl-message__content {
padding-right: 30px;
}
.yl-message__close {
display: flex;
align-items: center;
}
.yl-message__close svg {
cursor: pointer;
}
}
@each $val in info,success,warning,danger {
.yl-message--$(val) {
--yl-message-bg-color: var(--yl-color-$(val)-light-9);
--yl-message-border-color: var(--yl-color-$(val)-light-8);
--yl-message-text-color: var(--yl-color-$(val));
.yl-message__close {
--yl-icon-color: var(--yl-color-$(val));
}
}
}
.yl-message.fade-up-enter-from,
.yl-message.fade-up-leave-to {
opacity: 0;
transform: translate(-50%, -100%);
}
style/index.css
@import '../components/Message/style.css';