消息提示(Message)是一个非常常见的UI元素,用于向用户显示非阻塞的通知信息。Vue 3 提供了强大的组合式API (Composition API) 和更好的TypeScript支持,使得创建复杂且功能丰富的组件变得更加简单。本文将详细介绍如何基于 Vue 3 和 TypeScript 从零开始搭建一个高度可定制的 Message 组件,仿照 Element Plus 的设计和功能。
需求分析
功能需求
- 全局调用:Message 组件应该可以通过全局方法
Message(options)调用,而不需要在模板中声明。 - 多种类型:支持不同的消息类型,如
success、warning、info和danger,每种类型有不同的样式和图标。 - 自定义内容:允许用户传递自定义的消息内容,可以是纯文本或 VNode 节点。
- 自动关闭:消息可以在指定的时间后自动关闭,默认为3秒。
- 手动关闭:提供关闭按钮,用户可以点击关闭按钮手动关闭消息。
- 堆叠显示:当多个消息同时显示时,能够以堆叠的方式展示,确保不会互相覆盖。
- 事件处理:支持
onClose回调函数,在消息关闭时触发。
通过以上分析,发现小小的一个Message组件竟然蕴藏着这么多的功能,尤其还有弹出多条信息和使用函数创建的难点,接下来我们逐一分析。
确定方案
- 属性
import type { VNode, ComponentInternalInstance } from 'vue'
export interface MessageProps {
// 信息文本,可以是简单字符串,也可以是复杂节点
message?: string | VNode
type?: string
icon?: string
// 信息显示持续时间
duration?: number
// 手动关闭
showClose?: boolean
// 偏移
offset?: number
// 添加z-index
zIndex: number
// 手动关闭的方法
useDestroy: () => void
id: string
// 过渡动画的名称
transitionName?: string
}
// 用户创建Message时,被过滤掉的属性是必选的,但是不是由用户添加,而是由内部处理
// 所以将必选的过滤掉
export type CreateMessageProps = Omit<
MessageProps,
'useDestroy' | 'id' | 'zIndex'
>
- 实例属性
export interface MesssageContext {
// 每个Message实例需要一个唯一id
id: string
// 实例对应的已经生成的Message组件节点
vnode: VNode
// 也要具有Message的属性
props: MessageProps
// 可以用过vm拿到Vue组件实例内部属性和方法
vm: ComponentInternalInstance
// 销毁实例
destroy: () => void
}
- 组件
<template>
<Transition
:name="transitionName"
@after-leave="destroyComponent"
@enter="updateHeight"
>
<!-- 内容区域 -->
<div
class="jd-message"
v-show="visible"
:class="{
[`jd-message--${type}`]: type,
'is-close': showClose,
}"
role="alert"
ref="messageRef"
:style="cssStyle"
@mouseenter="clearTimer"
@mouseleave="startTimer"
>
<div class="jd-message__content">
<slot>
<RenderVnode :vNode="message" v-if="message" />
</slot>
</div>
<!-- 关闭图标 -->
<div class="jd-message__close" v-if="showClose">
<Icon @click.stop="visible = false" icon="xmark" />
</div>
</div>
</Transition>
</template>
代码实现
- 函数式方式创建组件
createVNode 和 render
实际上 createVNode 是 h 函数的别名,这两个函数的功能是创建一个 VNode,这个 VNode 可以理解为是 DOM 的描述,当我们要渲染 VNode 到真正的页面时,就要用到 render 函数。我们可以封装一个 MessageBox.vue 的组件,然后通过 createVNode 创建对应组件的 VNode,用 render 把组件渲染到页面上。
import { render, h, shallowReactive } from 'vue'
import Message from './Message.vue'
// 用于拿到当前组件的z-index属性的hook函数,后文将展示
import useZIndex from '@/hooks/useMessageZIndex'
import type { CreateMessageProps, MesssageContext } from './types'
let seed = 1
// 主要函数,用户通过此函数进行创建Message
export function jdMessage(props: CreateMessageProps) {
// 即将被创建的组件的z-index
const { nextZIndex } = useZIndex()
// 每个组件有唯一的id
const id = `message_${seed++}`
// 用来防止Message组件的容器
const container = document.createElement('div')
// 在添加组件应有的属性
const newProps = {
...props,
id,
zIndex: nextZIndex(),
// destroyMesssage见下文
useDestroy: destroyMesssage
}
const vnode = h(Message, newProps)
// 通过render函数将虚拟DOM节点渲染或挂载到真实DOM节点上
render(vnode, container)
// 在页面上添加,即显示
document.body.appendChild(container.firstElementChild!)
- 用一个数组来保存已创建的组件实例
- 通过维护这个数组来进行组件的添加和删除
// 存储当前创建的实例,并且使用shallowReactive做浅层监听
const instances: MesssageContext[] = shallowReactive([])
const instance = {
id,
vnode,
props: newProps,
vm,
// manualDistory下文添加
destroy: manualDistory
}
// 将当前创建的实例添加到实例数组中
instances.push(instance)
// 手动调用删除, 也就是手动调整组件中的visible值
// visible 是通过expose传出来的
const manualDistory = () => {
const instance = instances.find((instance) => instance.id === id)
if (instance) {
instance.vm.exposed!.visible.value = false
}
}
// 卸载组件
const destroyMesssage = () => {
// 从实例数组中删除
const index = instances.findIndex((instance) => instance.id === id)
if (index === -1) return
instances.splice(index, 1)
render(null, container)
}
-
有多个Message被创建时,要设置每个组件的位置
- 计算偏移量:top= 上一个实例留下的底部偏移 + offset
- 为下一个实例预留底部偏移量:top + height
- 通过getBoundingClientRect().height拿到高度
- 将偏移量暴露出去
// 拿到最后一个组件实例
export const getLastInstance = () => {
return instances.at(-1)
}
// 获取上一个实例的offset
export const getLastBottomOffset = (id: string) => {
const index = instances.findIndex((instance) => instance.id === id)
// 如果 index <= 0 说明是第一项
if (index <= 0) {
return 0
} else {
const prev = instances[index - 1]
// 拿到上一项的bottomOffset
return prev.vm.exposed!.bottomOffset.value
}
}
Message组件
在组件中要做什么呢,通过之前的分析及部分实现,不难看出在组件中需要做的事情是比较简单:
- 定义属性并初始化
const props = withDefaults(defineProps<MessageProps>(), {
type: 'info',
duration: 3000,
offset: 20,
transitionName: 'fade-up'
})
- 添加实现组件显示隐藏的功能
const visible = ref<boolean>(false)
const handleClose = () => {
visible.value = false
}
- 确定组件位置
// 计算偏移高度
const height = ref(0)
// 上一个实例的最下面的坐标数字,第一个是0
const lastOffset = computed(() => getLastBottomOffset(props.id))
// 当前元素应该使用的top
const topOffset = computed(() => props.offset + lastOffset.value)
// 该元素为下一个元素预留的offset, 也就是它最低端bottom的值
const bottomOffset = computed(() => height.value + topOffset.value)
// 计算出组件样式
const cssStyle = computed(() => ({
top: topOffset.value + 'px',
zIndex: props.zIndex
}))
- 添加定时器,并且在合适的时机关闭组件
let timer: any;
const startTimer = () => {
if (props.duration === 0) return
timer = setTimeout(() => {
visible.value = false
}, props.duration)
}
const clearTimer = () => {
clearTimeout(timer)
}
- 在合适的时机更新组件
onMounted(async () => {
visible.value = true
startTimer()
})
function destroyComponent() {
props.useDestroy()
}
function updateHeight() {
height.value = messageRef.value!.getBoundingClientRect().height
}
- 暴露属性
defineExpose({
bottomOffset,
visible
})
最终的Message组件
<template>
<Transition
:name="transitionName"
@after-leave="destroyComponent"
@enter="updateHeight"
>
<div
class="jd-message"
v-show="visible"
:class="{
[`jd-message--${type}`]: type,
'is-close': showClose,
}"
role="alert"
ref="messageRef"
:style="cssStyle"
@mouseenter="clearTimer"
@mouseleave="startTimer"
>
<div class="jd-message__content">
<slot>
<RenderVnode :vNode="message" v-if="message" />
</slot>
</div>
<div class="jd-message__close" v-if="showClose">
<Icon @click.stop="visible = false" icon="xmark" />
</div>
</div>
</Transition>
</template>
其中after-leave和enter为Transition组件中的钩子函数,你可以通过钩子函数在过渡过程中自定义你想要的操作,详见 官方文档-Transition组件-JavaScript 钩子