最近研究了一下element-plus的Message组件源码,有了一些不错的收获,最后我会带大家造出一个属于你自己的Message组件
1. 前言
考虑到主要注重逻辑部分,所以一些HTML的源码我会简化以及不涉及css的操作,更方便大家阅读
推荐一款Vue开发者库vue-demi,可以让你写出兼容2与3的代码,本文也会使用到这个库
阅读之前我需要大家有以下基础
1. Vue3的基本语法
2. Vue3的<script setup>语法糖的用法
3. TypeScript基础
4. element-plus Message组件的基本用法,如果不了解可以去看看官网的例子
Message组件的官网例子
具体用法:
ElMessage({
duration: 2000,
message: 'Test',
type: 'info',
})
2. 在线案例: mdvui.github.io/MDVUI/
-
info -
error -
success
3. 组件部分
html
<template>
<transition
name="message-fade"
@before-leave="onClose"
@after-leave="destroy"
>
<div
v-show="render"
ref="rootRef"
class="message"
:class="[
info ? 'color-blue': '',
error ? 'color-red': '',
success ? 'color-green': '',
]"
:style="Style"
>
<i class="icon" v-html="error || info ? 'info': 'done'" />
<div class="mv-alert-tip-slot">
{{ message }}
</div>
</div>
</transition>
</template>
注意到我们使用transition这个Vue内置组件,其中最为核心的就是@before-leave与@after-leave这两个事件,并且分别绑定了两个方法
这就很让人奇怪了,为什么要绑定两个销毁的方法呢?让我们留个悬念
接下来看到核心部分
<div
v-show="render"
ref="rootRef"
class="message"
:class="[
info ? 'color-blue': '',
error ? 'color-red': '',
success ? 'color-green': '',
]"
:style="Style"
>
<i class="icon" v-html="error || info ? 'info': 'done'" />
<div class="message-slot">
{{ message }}
</div>
</div>
既然我们要让组件消失或者出现,那就要用一个v-show或者v-if的指令来操作,这两个指令其实都可以实现,不过官网使用的是v-show的例子,我们这里也用v-show
这样就能触发transition组件的@before-leave与@after-leave这两个事件
至于
:class="[ info ? 'color-blue': '', error ? 'color-red': '', success ? 'color-green': '', ]"
是判断当前传进来的Message-Type,最后展示出颜色
我们可以多一种考虑,当用户不传入type时,我们可以默认的展示info,有的读者会提出疑问,当我们用TypeScript把props中的type选为必选属性不就好了吗?
这种想法没什么问题,但你要考虑到js用户是不会受到类型约束的,而且你不穿props中的某个属性,Vue Complier只会抛出一个Warning,所以最终的办法是我们做到默认为info
而
<i class="icon" v-html="error || info ? 'info': 'done'" />
是展示Message的左侧图标
最后
<div class="message-slot">
{{ message }}
</div>
是插入你在Message中所要展示的内容了
4. 组件的逻辑部分
此处我只分析最为核心的部分
import type { VNode } from 'vue-demi'
import { computed, ref } from 'vue-demi'
import { onMounted } from 'vue'
export type MessageType = 'success' |'error'| 'info'
export interface IMessageProps {
id?: number
type?: MessageType
duration?: number
zIndex?: number
message?: string | VNode
offset?: number
onDestroy?: () => void
onClose?: () => void
}
const props = withDefaults(defineProps<IMessageProps>(), {
type: 'info',
duration: 3000,
message: '',
offset: 20,
onDestroy: () => {},
onClose: () => {},
})
const Style = computed(() => ({
top: `${props.offset}px`,
zIndex: props.zIndex,
}))
const error = computed(() => props.type === 'error')
const info = computed(() => props.type === 'info' || (props.type !== 'success' && props.type !== 'error'))
const success = computed(() => props.type === 'success')
const render = ref()
onMounted(() => {
startTimer()
render.value = true
})
function startTimer() {
setTimeout(() => {
close()
}, props.duration)
}
function destroy() {
props.onDestroy()
}
function close() {
render.value = false
}
核心1
const Style = computed(() => ({
top: `${props.offset}px`,
zIndex: props.zIndex,
}))
此处的代码是为了控制每个Message的高度与zIndex的,确保每个Message组件都能正确的展示出来
核心2
onDestroy?: () => void,
onClose?: () => void,
这段代码存在于props中,而onClose是给transition中的@before-leave使用的
function destroy() {
props.onDestroy()
}
是为了触发onDestory函数,而onDestroy()是在外部传进来的,接下来我们会说到
5. ElMessage核心部分
import type { VNode } from 'vue-demi'
import { PopupManager } from '@mdvui/utils/popup-manager'
import { createVNode, isVNode, render } from 'vue-demi'
import MessageConstructor from './Message.vue'
import type { IMessageProps } from './Message.vue'
interface MessageOptions extends IMessageProps {
appendTo?: HTMLElement | string
}
let instances: VNode[] = []
let seed = 0
const message = (options: MessageOptions | string) => {
if (typeof options === 'string') {
options = { message: options }
}
let appendTo: HTMLElement | null = document.body
if (typeof options.appendTo === 'string') {
appendTo = document.querySelector(options.appendTo)
}
if (!(appendTo instanceof HTMLElement)) {
appendTo = document.body
}
const props = {
zIndex: PopupManager.nextZIndex(),
id: seed++,
onClose: () => {
close(seed - 1)
},
...options,
}
let verticalOffset = options.offset || 20
instances.forEach((vInstance) => {
verticalOffset += (vInstance.el?.offsetHeight || 0) + 16
})
props.offset = verticalOffset
const container = document.createElement('div')
container.className = 'message-container'
const vm = createVNode(
MessageConstructor,
props,
isVNode(props.message) ? { default: () => props.message } : null,
)
vm.props!.onDestroy = () => {
render(null, container)
}
instances.push(vm)
render(vm, container)
appendTo.appendChild(container)
return {
close: () => close(vm.props!.id as number),
}
}
export const close = (vmId: number) => {
const idx = instances.findIndex(vm => vm.props!.id = vmId)
if (idx === -1) {
return
}
const vm = instances[idx]
const removedHeight = vm.el!.offsetHeight
instances.splice(idx, 1)
const len = instances.length
if (len === 0) {
return
}
for (let i = 0; i < len; i++) {
// TODO Why when using `offsetHeight` will cause bug? And use `style.top` it will be ok?
const pos = parseInt(instances[i].el!.style.top, 10) - removedHeight - 16
instances[i].component!.props.offset = pos
}
}
export default message
接上文,onDestory()的作用是为了在组件动画结束后释放内存,从而避免内存泄漏
vm.props!.onDestroy = () => {
render(null, container)
}
接下来就是全文的重点,我们如何把Message渲染给用户?当然是通过render函数,那render函数的作用是什么呢?
下面我们来分析render函数的作用
export declare const render: RootRenderFunction<Element | ShadowRoot>;
export declare type RootRenderFunction<HostElement = RendererElement> = (vnode: VNode | null, container: HostElement, isSVG?: boolean) => void;
可以看见render绑定了一个RootRenderFunction的类型,而分析RootRenderFunction这个类型,不难猜出这是一个把Virtual Node渲染到一个RendererElement上,其实RendererElement也是HTMLElement
现在我们缕清一下思路,我们现在要做的就是把我们刚刚写好的Vue Component这样一个Virtual Node渲染到一个div上面,这个div就充当为容器的作用
我们可以这样做
const container = document.createElement('div')
container.className = 'container'
const vm = createVNode(
MessageConstructor,
props,
isVNode(props.message) ? { default: () => props.message } : null,
)
vm.props!.onDestroy = () => {
render(null, container)
}
这样就建立起了一个Virtual Node以及一个HTMLDIVElement,现在就是render函数出场了
render(vm, container)
appendTo.appendChild(container)
render把Virtual Node渲染为一个HTMLELement之后挂载到container内,最后我们再把container挂载到appendTo下面,最后就会在页面上渲染出来,接下来我们分析appendTo
let appendTo: HTMLElement | null = document.body
if (typeof options.appendTo === 'string') {
appendTo = document.querySelector(options.appendTo)
}
if (!(appendTo instanceof HTMLElement)) {
appendTo = document.body
}
以上代码就确保,你appendTo要么是document.body要么就是你传过来的dom,如此,你的Message组件就能挂载到页面上了,不过不要高兴的太早,因为我们还没有计算每个Message的高度
let verticalOffset = options.offset || 20
instances.forEach((vInstance) => {
verticalOffset += (vInstance.el?.offsetHeight || 0) + 16
})
props.offset = verticalOffset
其实也不难计算,就是每个组件的高度都比上一个组件高16px,最后再把高度传给props.offset,这样组件就会自动更新高度了
初始高度的问题解决了,还有一个问题,就是组件关闭时,我们希望每一个组件的高度(除第一个组件外)能回到上一个组件的高度,那如何解决这个问题呢?
let instances: VNode[] = []
let seed = 0
export const close = (vmId: number) => {
const idx = instances.findIndex(vm => vm.props!.id = vmId)
if (idx === -1) {
return
}
const vm = instances[idx]
const removedHeight = vm.el!.offsetHeight
instances.splice(idx, 1)
const len = instances.length
if (len === 0) {
return
}
for (let i = 0; i < len; i++) {
// TODO Why when using `offsetHeight` will cause bug? And use `style.top` it will be ok?
const pos = parseInt(instances[i].el!.style.top, 10) - removedHeight - 16
instances[i].component!.props.offset = pos
}
}
做的事情很简单
-
- 把关闭的组件找到
-
- 删除它
-
- 再把每个组件的高度设置为上一个组件的高度
而
instances在我们使用render函数的时候就做了
- 再把每个组件的高度设置为上一个组件的高度
而
instances.push(vm)
render(vm, container)
这样的操作,最后还记得我们在transition组件有一个@before-leave="onClose"吗?
通过如下的操作
const props = {
zIndex: PopupManager.nextZIndex(),
id: seed++,
onClose: () => {
close(seed - 1)
},
...options,
}
const vm = createVNode(
MessageConstructor,
props,
isVNode(props.message) ? { default: () => props.message } : null,
)
就能自动在组件销毁时close掉这个组件,而且在createVNode的时候我们就已经把props丢进去了
至此,属于你的Message组件打造结束
谢谢大家的阅读,希望大家能在评论区里面指出不足之处和提一些建议,以及给一个小小的赞