element-plus源码研究——造出Message组件

8,564 阅读5分钟

最近研究了一下element-plusMessage组件源码,有了一些不错的收获,最后我会带大家造出一个属于你自己的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 image.png

  • error image.png

  • success image.png

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,有的读者会提出疑问,当我们用TypeScriptprops中的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)

renderVirtual 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
  }
}

做的事情很简单

    1. 把关闭的组件找到
    1. 删除它
    1. 再把每个组件的高度设置为上一个组件的高度 而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组件打造结束

谢谢大家的阅读,希望大家能在评论区里面指出不足之处和提一些建议,以及给一个小小的赞