从0搭建Vue3组件库之Message组件

1,128 阅读4分钟

消息提示(Message)是一个非常常见的UI元素,用于向用户显示非阻塞的通知信息。Vue 3 提供了强大的组合式API (Composition API) 和更好的TypeScript支持,使得创建复杂且功能丰富的组件变得更加简单。本文将详细介绍如何基于 Vue 3 和 TypeScript 从零开始搭建一个高度可定制的 Message 组件,仿照 Element Plus 的设计和功能。

需求分析

功能需求

  1. 全局调用:Message 组件应该可以通过全局方法 Message(options) 调用,而不需要在模板中声明。
  2. 多种类型:支持不同的消息类型,如 successwarninginfo 和 danger,每种类型有不同的样式和图标。
  3. 自定义内容:允许用户传递自定义的消息内容,可以是纯文本或 VNode 节点。
  4. 自动关闭:消息可以在指定的时间后自动关闭,默认为3秒。
  5. 手动关闭:提供关闭按钮,用户可以点击关闭按钮手动关闭消息。
  6. 堆叠显示:当多个消息同时显示时,能够以堆叠的方式展示,确保不会互相覆盖。
  7. 事件处理:支持 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-leaveenter为Transition组件中的钩子函数,你可以通过钩子函数在过渡过程中自定义你想要的操作,详见 官方文档-Transition组件-JavaScript 钩子