Element Plus 组件库实现:6. Message组件

923 阅读3分钟

前言

Message组件,顾名思义,是负责展示和处理信息的模块。它可以被集成到各种应用程序中,为用户提供友好的信息展示界面,以及丰富的交互功能。通过精心设计和开发,Message组件不仅可以提升用户的信息获取效率,还能增强用户的使用体验,从而推动应用程序的整体性能提升。本文将简单介绍Message组件的基本开发。

需求分析

  • 在特定时机,弹出一个对应的提示信息
  • 提示信息在一定时间之后可以消失
  • 提供手动关闭的功能
  • 可以弹出多条信息
  • 类型(primary,warning,danger等)
  • 使用调用函数的方法来创建组件

通过以上分析,发现小小的一个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
}
  • 组件
// Message.vue
<template>
    <Transition 
    :name="props.transitionName">
        <div class="yv-message">
       <!-- 内容区域 -->
            <div class="yv-message__content">
                <slot>
                    <RenderVnode />
                </slot>
            </div>
            <!-- 关闭图标 -->
            <div class="yv-message__close">
                <Icon icon="xmark"></Icon>
            </div>
        </div>
    </Transition>
</template>

代码实现

  • 函数式方式创建组件
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 YvMessage(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="props.transitionName" @after-leave="destroyComponent" @enter="updateHeight">
        <div class="yv-message" v-show="visible" role="alert" :class="{
            [`yv-message--${type}`]: type,
            'is-close': props.showClose
        }" ref="messageRef" :style="cssStyle" @mouseenter="clearTimer" @mouseleave="startTimer">
            <div class="yv-message__content">
                <slot>
                    <RenderVnode :v-node="props.message" v-if="props.message" />
                </slot>
            </div>
            <div class="yv-message__close" v-if="props.showClose">
                <Icon icon="xmark" @click.stop="handleClose"></Icon>
            </div>
        </div>
    </Transition>
</template>

其中after-leaveenter为Transition组件中的钩子函数,你可以通过钩子函数在过渡过程中自定义你想要的操作,详见 官方文档-Transition组件-JavaScript 钩子

总结

本文简单介绍了Message组件的基本实现,与其他组件不同的是,这里使用函数式的方式创建组件,并且可以创建多个组件并显示。写到最后自己都有点懵了,感觉逻辑不是很清晰了已经,之后会更新更加详细的逻辑,如有失误,感谢评论区批评指正。