Message 消息提示组件实现

467 阅读5分钟

用户实现方式

import {CHMessage} from "@/components/message";
CHMessage({
  message: '你',
  type: 'error',
})

疑惑

组件如何挂载,如何销毁

实现ui层面

<template>
  <transition
      name="message-animation"
  >
    <div
        role="alert"
        :id="props.id"
        :class="['message',classNameType,{message_padding_right:showClose}]"
        :style="{top:props.offset+'px'}"
        v-show="isShowMessage"
    >
      <slot>
        <p
            v-if="!props.dangerouslyUseHTMLString"
            :class="[{message_p_padding_right:props.showClose}]"
        >
          {{ props.message }}
        </p>
        <p
            v-else
            :class="[{message_p_padding_right:props.showClose}]"
            v-html="props.message"
        ></p>
      </slot>
      <div class="message-icon" v-if="props.showClose" @click="showMessage(false)"></div>
    </div>
  </transition>
</template>

<script lang="ts" setup name="CHMessage">
import {computed, onMounted, ref, VNode} from "vue";
import {messageType} from "@/components/message/type";

/**组件是否显示*/
const isShowMessage = ref<boolean>(false)

interface IProps {
  /**消息类型*/
  type?: messageType,
  /**消息内容*/
  message?: string | VNode | Object,
  /**显示时间,单位为毫秒。 设为 0 则不会自动关闭*/
  duration?: number
  /**设置组件的根元素*/
  appendTo?: string | HTMLElement,
  /**Message 距离窗口顶部的偏移量*/
  offset?: number
  /**组件id*/
  id: string
  /**关闭回调*/
  onClose?: () => void
  /**当前是否渲染,html字符串*/
  dangerouslyUseHTMLString?: boolean
  /**是否显示关闭按钮*/
  showClose?: boolean
}
/**消息类型 - 计算属性*/
const classNameType = computed(() => {
  return props.type
})

const props = withDefaults(defineProps<IProps>(), {
  type: 'info',
  duration: 3000,
  message: '',
  offset: 20,
  id: '',
  dangerouslyUseHTMLString: false,
  showClose: false
})

</script>

<style lang="scss" scoped>
.message {
  width: 100%;
  min-width: 380px;
  max-width: 50%;
  height: auto;
  font-size: 14px;
  box-sizing: border-box;
  border-radius: 4px;
  padding: 15px;
  position: fixed;
  left: 50%;
  transform: translateX(-50%);
  display: flex;
  transition: opacity .3s, transform .4s, top .4s;
  top: 20px;

  p {
    margin: 0;
    padding: 0;
  }

  .message_p_padding_right {
    padding-right: 32px;
  }

  .message_padding_right {
    padding-left: 20px;
  }

  &.info {
    color: #909399;
    background-color: #f4f4f5;
  }

  &.success {
    color: #67c23a;
    background-color: #f0f9eb;
  }

  &.warning {
    color: #e6a23c;
    background-color: #fdf6ec;
  }

  &.error {
    color: #f56c6c;
    background-color: #fef0f0;
  }

  //关闭图标
  .message-icon {
    position: absolute;
    right: 15px;
    top: 50%;
    transform: translateY(-50%);
    width: 15px;
    height: 15px;
    background-color: #222222;
  }
}

.message-animation-enter-from,
.message-animation-leave-to, {
  transform: translate(-50%, -100%);
  opacity: 0;
}

.message-animation-enter-active {
  transition: all .3s ease-in;
}
</style>

挂载ui

  1. 用户是通过函数的方式,调用显示ui层,所以我们返回一个CHMessage函数
  2. 判断用户有没有传 appendTo(设置组件的根元素),没有传递,默认就是body,如果是字符串,还得先获取节点,如果传递进来的就是节点,也得判断一下是否是正常节点
  3. 需要为message赋予一个唯一id
  4. 创建一个props对象,传递给message组件
  5. 创建一个div节点,同时赋值一个className
  6. 通过vue3的createVNode函数,创建一个VNode(虚拟节点)
  7. 在通过vue3的render函数,把VNode节点挂载到div节点上
  8. appendTo节点追加VNode节点,这时组件挂载到了真实节点上
  9. messageVms数组中追加VNode节点,保存当前节点,用于后续的销毁
  10. 当前创建VNode节点的el实例可能是text类型,在这强行的把el实例指向我们的message组件
    vm.el = document.getElementById(id)
    
  11. 方便开发者可以快速实现Message类型,比如:CHMessage.error({message: '你好'}),往CHMessage函数定义:infosuccesswarningerror
    messageType.forEach((type) => {
        CHMessage[type] = (options: IPropsMessage = {}) => {
            options.type = type
    return CHMessage(options)
        }
    })
    
import {render, createVNode, VNode, ComponentPublicInstance} from "vue";
import Message from './message.vue'
import {IMessage, IPropsMessage, MessageFunc, MessageQueue} from "@/components/message/type";

const messageType = ['info', 'success', 'warning', 'error'] as const

let messageVms: MessageQueue = [] // 节点数组
let seed = 1 // 组件id值

export const CHMessage: MessageFunc = (options?: IPropsMessage): IMessage => {
    // 节点挂载位置,body
    let appendTo: HTMLElement | null = document.body;
    if (typeof options?.appendTo === 'string') {
        appendTo = document.querySelector(options.appendTo)
    } else if (isElement(options?.appendTo)) {
        appendTo = options?.appendTo || null
    }

    //    判断 appendTo 是否是 html节点
    if (!isElement(appendTo)) {
        appendTo = document.body
    }

//    创建props
    let id = `message_${seed++}`
    let userOnClose = options?.onClose
    let props = {
        ...options,
        id,
    }


    // 创建节点
    const container = document.createElement('div')
    container.className = `container_${id}`
    const vm = createVNode(
        Message,
        props,
        typeof options?.message === 'function'
            ? {default: options.message}
            : typeof options?.message === 'object'
                ? {default: () => options?.message}
                : null
    )

    render(vm, container)
    messageVms.push({vm}) //往节点数组中追加节点
    appendTo.appendChild(container.firstElementChild!) //渲染dom
    // 获取当前组件的dom节点覆盖,vue实例上的el节点信息
    vm.el = document.getElementById(id)
}

//给 messageType 添加属性
messageType.forEach((type) => {
    CHMessage[type] = (options: IPropsMessage = {}) => {
        options.type = type
return CHMessage(options)
    }
})
//判断节点
const isElement = (e: unknown): e is Element => {
    if (typeof Element === 'undefined') return false
    return e instanceof Element
}

显示MessageUi层

虽然Messageui组已经挂载到了真实节点上,但还是看不到Message组件,现在来实现组件内部逻辑,让组件显示出来。

组件已经挂载到了真实节点上,这个时候会触发vue的生命周期钩子onMounted,通过生命周期钩子,实现组件的显示。

同时实现一个倒计时函数,倒计时完成,销毁当前组件,需要判断props.duration不为零,才执行倒计时,如props.duration为零说明,当前组件不需要自动关闭。

onMounted(() => {
//  显示组件
  showMessage(true)
//  开始计时
  timer()
})
/**
 * 是否现在组件
 * @param isShow
 */
const showMessage = (isShow: boolean = true) => {
  isShowMessage.value = isShow;
}
/**
 * 计时器 props.duration 后关闭
 */
let setTimeoutVariable: NodeJS.Timeout | null;
const timer = () => {
  props.duration && (
      setTimeoutVariable = setTimeout(() => {
        showMessage(false)
        clearTimeout(setTimeoutVariable as NodeJS.Timeout)
        setTimeoutVariable = null
      }, props.duration)
  )
}

销毁组件

虽然倒计时函数关闭了组件,至少在界面上已经看不到当前组件了,但是组件实例还在,而且真实的节点上也是存在当前组件的。

可以借助,vue的动画组件transition@after-leave事件回调,动画完成,且元素已从DOM中移除给发送事件emit('destroy'),来告知销毁真实节点。

销毁还没有完成,只是销毁了真实节点,在messageVms数组中还存在着组件实例,找到数组中对应的实例,进行删除操作,@before-leave调用props.onClose函数,来销毁VNode

  • ui
<transition
    name="message-animation"
    @before-leave="props.onClose"
    @after-leave="emit('destroy')"
></transition>
  • CHMessage函数 销毁真实节点
// 清除消息元素以防止内存泄漏 销毁组件
vm.props!.onDestroy = () => {
    render(null, container)
    // 由于元素被销毁,那么 VNode 也应该被 GC 收集
    // 我们不希望导致任何内存泄漏,因为我们已返回 vm 作为对用户的引用
    // 以便我们手动将其设置为 false。
}
  • 新建销毁函数
const closure = (id: string, appendTo: HTMLElement, userOnClose?: (vm: VNode) => void) => {
    const idIndex = messageVms.findIndex(({vm}) => id === vm.component!.props.id) //当前组件的索引
    if (idIndex === -1) return
    const {vm} = messageVms[idIndex];//取出当前组件
    if (!vm) return;
    userOnClose?.(vm)  
}
  • 在给Message组件传递参数的时候传递一个关闭方法
let props = {
    ...options,
    id,
    offset: verticalOffset,
    onClose: () => {
        closure(id, userOnClose)
    }
}

计算组件头部位置

Message组件能实现创建和销毁,但位置不对,多次调用,会在同一个位置,计算一下每次创建组件事的高度

  • CHMessage函数,循环数组,获取每个组件的高度,最后传递给Message组件
//   设置头部距离
    let verticalOffset = options?.offset || 20
    messageVms.forEach(({vm}) => {
        verticalOffset += (vm.el?.offsetHeight || 0) + 16
    })
    verticalOffset += 16

//    创建props
    let id = `message_${seed++}`
    let userOnClose = options?.onClose
    let props = {
        ...options,
        id,
        offset: verticalOffset,
        onClose: () => {
            closure(id, userOnClose)
        }
    }
  • closure 销毁函数,组件在销毁时,要重新计算每个组件的头部距离
const closure = (id: string, userOnClose?: (vm: VNode) => void) => {
    const idIndex = messageVms.findIndex(({vm}) => id === vm.component!.props.id) //当前组件的索引
    if (idIndex === -1) return
    const {vm} = messageVms[idIndex];//取出当前组件
    if (!vm) return;
    userOnClose?.(vm)

    const removedHeight = vm.el!.offsetHeight //保存当前要删除的组件高度
    messageVms.splice(idIndex, 1)
    const len = messageVms.length
    if (len < 1) return;

    for (let i = 0; i < len; i++) {
        const pos = Number.parseInt(messageVms[i].vm.el!.style['top'], 10) - removedHeight - 16
        messageVms[i].vm.component!.props.offset = pos
    }
}

完整代码

  • vue
<template>
  <transition
      name="message-animation"
      @before-leave="props.onClose"
      @after-leave="emit('destroy')"
  >
    <div
        role="alert"
        :id="props.id"
        :class="['message',classNameType,{message_padding_right:showClose}]"
        :style="{top:props.offset+'px'}"
        v-show="isShowMessage"
        @mouseenter="clearTimer"
        @mouseleave="startTimer"
    >
      <slot>
        <p
            v-if="!props.dangerouslyUseHTMLString"
            :class="[{message_p_padding_right:props.showClose}]"
        >
          {{ props.message }}
        </p>
        <p
            v-else
            :class="[{message_p_padding_right:props.showClose}]"
            v-html="props.message"
        ></p>
      </slot>
      <div class="message-icon" v-if="props.showClose" @click="showMessage(false)"></div>
    </div>
  </transition>
</template>

<script lang="ts" setup name="CHMessage">
import {computed, onMounted, ref, VNode} from "vue";
import {messageType} from "@/components/message/type";

/**组件是否显示*/
const isShowMessage = ref<boolean>(false)

interface IProps {
  /**消息类型*/
  type?: messageType,
  /**消息内容*/
  message?: string | VNode | Object,
  /**显示时间,单位为毫秒。 设为 0 则不会自动关闭*/
  duration?: number
  /**设置组件的根元素*/
  appendTo?: string | HTMLElement,
  /**Message 距离窗口顶部的偏移量*/
  offset?: number
  /**组件id*/
  id: string
  /**关闭回调*/
  onClose?: () => void
  /**当前是否渲染,html字符串*/
  dangerouslyUseHTMLString?: boolean
  /**是否显示关闭按钮*/
  showClose?: boolean
}

const props = withDefaults(defineProps<IProps>(), {
  type: 'info',
  duration: 3000,
  message: '',
  offset: 20,
  id: '',
  dangerouslyUseHTMLString: false,
  showClose: false
})

onMounted(() => {
//  显示组件
  showMessage(true)
//  开始计时
  timer()
})

/**消息类型 - 计算属性*/
const classNameType = computed(() => {
  return props.type
})

/**
 * 是否现在组件
 * @param isShow
 */
const showMessage = (isShow: boolean = true) => {
  isShowMessage.value = isShow;
}

/**
 * 计时器 props.duration 后关闭
 */
let setTimeoutVariable: NodeJS.Timeout | null;
const timer = () => {
  props.duration && (
      setTimeoutVariable = setTimeout(() => {
        showMessage(false)
        clearTimeout(setTimeoutVariable as NodeJS.Timeout)
        setTimeoutVariable = null
      }, props.duration)
  )
}

/**
 * 鼠标移入
 */
const clearTimer = () => {
  clearTimeout(setTimeoutVariable as NodeJS.Timeout)
  setTimeoutVariable = null
}
/**
 * 鼠标移出
 */
const startTimer = () => {
  timer()
}

const emit = defineEmits<{
  (e: 'destroy'): void
}>()
</script>

<style lang="scss" scoped>
.message {
  width: 100%;
  min-width: 380px;
  max-width: 50%;
  height: auto;
  font-size: 14px;
  box-sizing: border-box;
  border-radius: 4px;
  padding: 15px;
  position: fixed;
  left: 50%;
  transform: translateX(-50%);
  display: flex;
  transition: opacity .3s, transform .4s, top .4s;
  top: 20px;

  p {
    margin: 0;
    padding: 0;
  }

  .message_p_padding_right {
    padding-right: 32px;
  }

  .message_padding_right {
    padding-left: 20px;
  }

  &.info {
    color: #909399;
    background-color: #f4f4f5;
  }

  &.success {
    color: #67c23a;
    background-color: #f0f9eb;
  }

  &.warning {
    color: #e6a23c;
    background-color: #fdf6ec;
  }

  &.error {
    color: #f56c6c;
    background-color: #fef0f0;
  }

  //关闭图标
  .message-icon {
    position: absolute;
    right: 15px;
    top: 50%;
    transform: translateY(-50%);
    width: 15px;
    height: 15px;
    background-color: #222222;
  }
}

.message-animation-enter-from,
.message-animation-leave-to, {
  transform: translate(-50%, -100%);
  opacity: 0;
}

.message-animation-enter-active {
  transition: all .3s ease-in;
}
</style>
  • js
import {render, createVNode, VNode, ComponentPublicInstance} from "vue";
import Message from './message.vue'
import {IMessage, IPropsMessage, MessageFn, MessageFunc, MessageQueue} from "@/components/message/type";

const messageType = ['info', 'success', 'warning', 'error'] as const

let messageVms: MessageQueue = [] // 节点数组
let seed = 1 // 组件id值

// @ts-ignore
export const CHMessage: MessageFn & MessageFunc = (options?: IPropsMessage): IMessage => {
    // 节点挂载位置,body
    let appendTo: HTMLElement | null = document.body;
    if (typeof options?.appendTo === 'string') {
        appendTo = document.querySelector(options.appendTo)
    } else if (isElement(options?.appendTo)) {
        appendTo = options?.appendTo || null
    }

    //    判断 appendTo 是否是 html节点
    if (!isElement(appendTo)) {
        appendTo = document.body
    }

//   设置头部距离
    let verticalOffset = options?.offset || 20
    messageVms.forEach(({vm}) => {
        verticalOffset += (vm.el?.offsetHeight || 0) + 16
    })
    verticalOffset += 16

//    创建props
    let id = `message_${seed++}`
    let userOnClose = options?.onClose
    let props = {
        ...options,
        id,
        offset: verticalOffset,
        onClose: () => {
            closure(id, userOnClose)
        }
    }


    // 创建节点
    const container = document.createElement('div')
    container.className = `container_${id}`
    const vm = createVNode(
        Message,
        props,
        typeof options?.message === 'function'
            ? {default: options.message}
            : typeof options?.message === 'object'
                ? {default: () => options?.message}
                : null
    )
    // 清除消息元素以防止内存泄漏 销毁组件
    vm.props!.onDestroy = () => {
        render(null, container)
        // 由于元素被销毁,那么 VNode 也应该被 GC 收集
        // 我们不希望导致任何内存泄漏,因为我们已返回 vm 作为对用户的引用
        // 以便我们手动将其设置为 false。
    }

    render(vm, container)
    messageVms.push({vm}) //往节点数组中追加节点
    appendTo.appendChild(container.firstElementChild!) //渲染dom
    // 获取当前组件的dom节点覆盖,vue实例上的el节点信息
    vm.el = document.getElementById(id)

    return {
        close: () => {
            (vm.component!.proxy as ComponentPublicInstance<{ isShowMessage: boolean }>).isShowMessage = false
        }
    }
}

//给 messageType 添加属性
messageType.forEach((type) => {
    CHMessage[type] = (options: IPropsMessage = {}) => {
        options.type = type
        return CHMessage(options)
    }
})

const closure = (id: string, userOnClose?: (vm: VNode) => void) => {
    const idIndex = messageVms.findIndex(({vm}) => id === vm.component!.props.id) //当前组件的索引
    if (idIndex === -1) return
    const {vm} = messageVms[idIndex];//取出当前组件
    if (!vm) return;
    userOnClose?.(vm)

    const removedHeight = vm.el!.offsetHeight //保存当前要删除的组件高度
    messageVms.splice(idIndex, 1)
    const len = messageVms.length
    if (len < 1) return;

    for (let i = idIndex; i < len; i++) {
        const pos = Number.parseInt(messageVms[i].vm.el!.style['top'], 10) - removedHeight - 16
        messageVms[i].vm.component!.props.offset = pos
    }
}

const isElement = (e: unknown): e is Element => {
    if (typeof Element === 'undefined') return false
    return e instanceof Element
}