>
post-detail

【Vue3】Pinia + Transition-Group 优雅实现 Message 消息组件

「技术」

2 分钟

18 阅读

0

2025-12-31

1 前言

消息组件是非常常用的组件之一,Element Plus 和 Naive UI 都有提供相应的消息组件,使用起来也相当方便。

那么,如果自己实现一个,怎么做呢?

我最初的方案, 是用 VNode 实现的,创建一个 Message 组件的 VNode ,然后在 body 渲染出来。

这种办法的缺点很明显,写起来很复杂,最主要的是,样式和过渡动画不好控制。

现在,我使用一个新的方案,可以非常优雅,非常简单的实现这个消息组件,并且可以很轻松的控制样式和动画效果。

话不多说,看效果 GIF 2025-12-31-星期三 16-10-45.gif

2 实现

思路相当简单。响应式的数据,就由 Pinia 来做,然后渲染就是用 transition-group + v-for 的形式,渲染 Pinia 的响应式数据,没了。

首先,先要知道我们的组件需要什么。也就是要先定义类型。

想必一下就能猜到了,首先,必要就是消息内容 message,然后就是消息的类型 type,包括 info、success、warning、error 这几种。

但如果要手动控制持续时间的话,就是使用 setTimeout 来做,就需要再加一个 duration 。

此外,如果我们要删除某个数据的话,还得用一个自动标识每一个数据,就需要 id 字段。

这样,最基本的数据就差不多定义好了。message.d.ts 的内容如下

interface IMessageOption {  
  message: string  
  type?: 'success' | 'error' | 'warning' | 'info'  
  duration?: number  
}  

interface IMessageInstanceItem extends IMessageOption {  
  id: symbol // symbol 唯一且不可变
}  

export type {  
  IMessageOption,  
  IMessageInstanceItem  
}

为什么要分开定义?因为 id 是自动生成的,调用者不需要传,所以这里分开定义,方便写添加方法。当然,你要全部写一起也可以,这只是我的习惯。

现在就是要创建一个仓库,来存储全局响应式数据。

import { defineStore } from 'pinia'  
import { ref } from 'vue'  
import type { IMessageInstanceItem, IMessageOption } from '@/components/types/message'  

export const useMessageStore = defineStore(  
  'message',  
  () => {  
    // 消息实例数组  
    const messageInstance = ref<IMessageInstanceItem[]>([])  

    function add(message: IMessageOption) {  
      // 生成 id      const id = Symbol()  
      // 添加消息实例  
      messageInstance.value.push({ id: id, ...message })  
      // 定时关闭  
      setTimeout(() => {  
        remove(id)  
      }, message.duration ?? 3000)  
    }  

    function remove(id: symbol) {  
      messageInstance.value = messageInstance.value.filter(item => item.id !== id)  
    }  

    return {  
      messageInstance,  
      add,  
      remove  
    }  
  }  
)

然后,就是创建一个基础的消息组件。我就使用 tailwindcss + font awesome 快速整一个 BasicMessage.vue 组件。

<script setup lang="ts">  
import type { IMessageOption } from '@/components/types/message'  

withDefaults(defineProps<IMessageOption>(), {  
  duration: 3000,  
  type: 'info'  
})  

const icons = {  
  info: 'fas fa-circle-info',  
  success: 'fas fa-circle-check',  
  warning: 'fas fa-circle-exclamation',  
  error: 'fas fa-circle-xmark',  
}  

</script>  

<template>  
  <div class="shadow-xl rounded-md min-w-40 w-fit h-10 px-4 py-2 flex flex-row items-center gap-2 text-white" :class="[type]">  
    <i :class="[icons[type]]" />  
    <span>{{ message }}</span>  
  </div>  
</template>  

<style scoped>  
@reference "@/assets/main.css";  

.info {  
  @apply bg-sky-400;  
}  

.success {  
  @apply bg-green-400;  
}  

.warning {  
  @apply bg-yellow-400;  
}  

.error {  
  @apply bg-red-400;  
}  
</style>

非常简单的组件,如果要支持手动关闭的,defineProps 的泛型换成 IMessageInstanceItem 类型,也就是把 id 传到组件中,然后点击某个地方关闭的时候,根据 id 调用仓库的 remove 方法就行。

接下来,就是要解决渲染问题了。

要渲染,那肯定是要一个容器。容器放在哪?当然是根组件。并且,消息是始终在窗口顶部的,不受页面滚动影响,所以容器必须是 fixed 布局。

这样,思路就清晰了,定义一个 fixed 布局的父容器,挂到根组件,然后内部使用 v-for 渲染仓库中的实例数据,动画效果,就使用 transition-group 来控制。父容器组件:MessageProvider.vue

<script setup lang="ts">  
import BasicMessage from '@/components/message/BasicMessage.vue'  
import { useMessageStore } from '@/stores/messageStore.ts'  

const messageStore = useMessageStore()  
</script>  

<template>  
  <div class="z-100 h-fit w-fit fixed top-4 left-1/2 -translate-x-1/2">  
    <transition-group name="message" tag="div" class="h-fit min-w-50 w-fit flex flex-col gap-4 items-center" >  
      <basic-message  
        v-for="instance in messageStore.messageInstance"  
        :key="instance.id"  
        :message="instance.message"  
        :type="instance.type"  
        :duration="instance.duration"  
      />  
    </transition-group>  
  </div>  
</template>  

<style scoped>  
@reference "@/assets/main.css";  

.message-move,  
.message-enter-active,  
.message-leave-active {  
  @apply transition-all duration-1000 ease-in-out;  
}  

.message-enter-from {  
  @apply opacity-0 translate-y-10 scale-80;  
}  

.message-leave-to {  
  @apply opacity-0 -translate-y-10 scale-80;  
}  

.message-leave-active {  
  @apply absolute;  
}  
</style>

Transition-Group 的使用,可以参考官网:TransitionGroup | Vue.js,非常适合控制动态列表数据的过渡效果。

然后,把 MessageProvider.vue 挂到 App.vue 组件中

<script setup lang="ts">  
import MessageProvider from '@/components/message/MessageProvider.vue'  
</script>  

<template>  
  <RouterView/>  

  <message-provider />  
</template>  

<style scoped></style>

完成了。

接下来就是写调用代码,只需要写一个非常简单的 hook 。

import { useMessageStore } from '@/stores/messageStore.ts'  

const useMessage = () => {  
  function info(message: string) {  
    // 按需获取,防止获取时,pinia 未初始化。  
    const messageStore = useMessageStore()  
    messageStore.add({  
      message: message,  
      type: 'info'  
    })  
  }  

  function success(message: string) {  
    const messageStore = useMessageStore()  
    messageStore.add({  
      message: message,  
      type: 'success'  
    })  
  }  

  function warning(message: string) {  
    const messageStore = useMessageStore()  
    messageStore.add({  
      message: message,  
      type: 'warning'  
    })  
  }  

  function error(message: string) {  
    const messageStore = useMessageStore()  
    messageStore.add({  
      message: message,  
      type: 'error'  
    })  
  }  

  return {  
    info,  
    success,  
    warning,  
    error  
  }  
}  

export const message = useMessage()

在 App.vue 写一个简单的调用代码。

<script setup lang="ts">  
import MessageProvider from '@/components/message/MessageProvider.vue'  
import { message } from '@/components/message/hook/useMessage.ts'  

const init = async () => {  
  await new Promise(resolve => {  
    setTimeout(() => {  
      message.info('This is a message')  

      resolve(true)  
    }, 1000)  
  })  
  await new Promise(resolve => {  
    setTimeout(() => {  
      message.success('This is a message')  

      resolve(true)  
    }, 1000)  
  })  
  await new Promise(resolve => {  
    setTimeout(() => {  
      message.warning('This is a message')  

      resolve(true)  
    }, 1000)  
  })  
  await new Promise(resolve => {  
    setTimeout(() => {  
      message.error('This is a message')  

      resolve(true)  
    }, 1000)  
  })  
}  

init()  
</script>  

<template>  
  <RouterView/>  

  <message-provider />  
</template>  

<style scoped></style>

效果如下 GIF 2025-12-31-星期三 16-10-45.gif

3 总结

  1. 学习了 Transition-Group 的用法。
  2. 学会了如何使用 Pinia + Transition-Group 的方式实现消息组件。

0 人赞过

评论

评论加载中…