vue中命令式弹窗的实现

270 阅读3分钟

本文是基于Vue3 实现的一个命令式的全局弹窗,命令式就是只需一行代码,执行一个函数就能弹窗,与声明式相比优势还是很多的:减少冗余模板(templete中引入组件)、全局易用(js、模板中都能使用)、动态性强(成功、失败、警告等状态)

命令式全局弹窗的实现

预期:函数调用弹窗

参考ui框架 使用命令式调用,就能弹出弹窗

// 希望只调这么个函数就能提示框
function showToast() {
  useToast('这是一个消息', 'success')
}

实现思路:

  1. Toast组件的封装
  2. 封装 useToast函数
  3. 调用 useToast('这是一个消息', 'success')传入内容和类型
<!-- // src\components\Toast.vue -->

<template>
  <Teleport to="body">
    <div class="toast" :class="type">
      {{ msg }}
    </div>
  </Teleport>
</template>

<script setup>
defineProps({
  msg: {
    type: String,
    default: ''
  },
  type: {
    type: String,
    default: 'info'
  }
})

</script>

<style scoped>
.toast {
  position: fixed;
  top: 20px;
  left: 50%;
  padding: 12px 24px;
  background: #333;
  color: white;
  border-radius: 4px;
  box-shadow: 0 2px 10px rgba(0, 0, 0, 0.2);
  z-index: 1000;
  transform: translateX(-50%);
  animation: fadeIn 0.3s ease-out;
}

.toast.success {
  background: #4CAF50;
}
.toast.error {
  background: #F44336;
}
.toast.warning {
  background: #FF9800;
}

@keyframes fadeIn {
  from { opacity: 0; transform: translateY(-10px) translateX(-50%); }
  to { opacity: 1; transform: translateY(0) translateX(-50%); }
}
</style>
  1. 封装 useToast函数
// src\utils\message.ts

import Toast from "../components/Toast.vue";
import { createApp } from "vue";

export function useToast( msg = '', type='info', delay = 3000) {
  const div = document.createElement("div");
  const toastId = `toast-${Date.now()}`; // 使用时间戳生成唯一 ID
  div.id = toastId;
  document.body.appendChild(div);
  
  const close = () => {
    app.unmount();
    document.body.removeChild(div);
  }
  const app = createApp(Toast, {type, msg, close});
  app.mount('#'+toastId);
  setTimeout(() => {
    close();
  }, delay);
};
  1. 调用 useToast('这是一个消息', 'success')传入内容和类型

<template>
  <div class="mesage">
    <div @click="showToast">显示Toast</div>
  </div>
</template>

<script setup lang="ts">
import { useToast } from '../utils/message'

function showToast() {
  useToast('这是一个消息', 'success')
}
</script>

我们看一下效果~~~

我们发现已经基本实现了,,但是细心的小伙伴肯定发现了 ==>> 这后面的把前面的覆盖了,人家官网的是往下挤的来着。

重叠提升框的处理

思考:因为我们的提示框是使用的定位,所以需要维护活跃的提示框、计算当前提示框的top、及高度

添加的top计算

  • 第一个 top => 20px
  • 第二个 top => 上一个的top + 上一个的高度 + 10px 的边距
  • 第三个 top => 上一个的top + 上一个的高度 + 10px 的边距

计算当前提示框距离顶部位置

当前项存进数组中activeToasts

// 记录所有活跃toast
const activeToasts = [];

export function useToast( msg = '', type='info', delay = 3000) {
  const div = document.createElement("div");
  const toastId = `toast-${Date.now()}`; // 使用时间戳生成唯一 ID
  div.id = toastId;
  
  // 计算当前提示框距离顶部位置----start-----计算当前提示框距离顶部位置
  const calculateTop = () => {
    if (activeToasts.length === 0) return 20; // 默认 top:20px
    const lastToast = activeToasts[activeToasts.length - 1];
    return lastToast.top + lastToast.height + 10; // 前一个 Toast 的 bottom + 10px margin
  };
  const top = calculateTop();
   // 计算当前提示框距离顶部位置----end-----计算当前提示框距离顶部位置
  
  document.body.appendChild(div);

  const app = createApp(Toast, {type, msg, close});
  app.mount('#'+toastId);

  // 当前项存进数组中 ------start-------当前项存进数组中
  setTimeout(() => {
    const toastEl = document.getElementById(toastId)?.querySelector('.toast');
    if (toastEl) {
      const height = toastEl.offsetHeight;
      // id top height 存储在 activeToasts 数组中
      activeToasts.push({ id: toastId, top, height });
      toastEl.style.top = `${top}px`;
    }
  }, 0);
  // 当前项存进数组中 ------end-------当前项存进数组中

  setTimeout(close, delay);
};

移除时的top计算

移除项的下面的所有项 都需要减去 移除项的高度

// 移除当前 Toast,并更新后续 Toast 的位置
const index = activeToasts.findIndex(t => t.id === toastId);
if (index !== -1) {
  const removedHeight = activeToasts[index].height;
  activeToasts.splice(index, 1);
  // 后续 Toast 的 top 要减去被删除的高度
  for (let i = index; i < activeToasts.length; i++) {
    activeToasts[i].top -= removedHeight + 10;
    const toastEl = document.getElementById(activeToasts[i].id)?.querySelector('.toast');
    if (toastEl) {
      toastEl.style.top = `${activeToasts[i].top}px`;
    }
  }
}

最终效果

完整代码

Toast组件

<template>
  <div class="toast" :id="toast + '-item'" :class="type">
    {{ msg }}
  </div>
</template>

<script setup>
import { defineProps } from 'vue'
defineProps({
  msg: {
    type: String,
    default: ''
  },
  type: {
    type: String,
    default: 'info'
  },
  toastId: {
    type: String,
    default: ''
  }
})
</script>

<style scoped>
.toast {
  position: fixed;
  top: 20px;
  left: 50%;
  padding: 12px 24px;
  background: #333;
  color: white;
  border-radius: 4px;
  box-shadow: 0 2px 10px rgba(0, 0, 0, 0.2);
  z-index: 1000;
  transform: translateX(-50%);
  animation: fadeIn 0.3s ease-out;
}

.toast.success {
  background: #4CAF50;
}
.toast.error {
  background: #F44336;
}
.toast.warning {
  background: #FF9800;
}

@keyframes fadeIn {
  from { opacity: 0; transform: translateY(-10px) translateX(-50%); }
  to { opacity: 1; transform: translateY(0) translateX(-50%); }
}
</style>

useToast

// utils/message.js

import Toast from "../components/Toast.vue";
import { createApp } from "vue";

// 记录所有活跃toast
const activeToasts = [];
export function useToast( msg = '', type='info', delay = 3000) {
  const div = document.createElement("div");
  const toastId = `toast-${Date.now()}`; // 使用时间戳生成唯一 ID
  div.id = toastId;

  // 计算当前提示框距离顶部位置----start-----计算当前提示框距离顶部位置
  const calculateTop = () => {
    if (activeToasts.length === 0) return 20; // 默认 top:20px
    const lastToast = activeToasts[activeToasts.length - 1];
    return lastToast.top + lastToast.height + 10; // 前一个 Toast 的 bottom + 10px margin
  };
  const top = calculateTop();
   // 计算当前提示框距离顶部位置----end-----计算当前提示框距离顶部位置

  document.body.appendChild(div);
  
  const close = () => {
    app.unmount();
    document.body.removeChild(div);

    // 移除当前 Toast,并更新后续 Toast 的位置
    const index = activeToasts.findIndex(t => t.id === toastId);
    if (index !== -1) {
      const removedHeight = activeToasts[index].height;
      activeToasts.splice(index, 1);
      // 后续 Toast 的 top 要减去被删除的高度
      for (let i = index; i < activeToasts.length; i++) {
        activeToasts[i].top -= removedHeight + 10;
        const toastEl = document.getElementById(activeToasts[i].id)?.querySelector('.toast');
        if (toastEl) {
          toastEl.style.top = `${activeToasts[i].top}px`;
        }
      }
    }

  }
  const app = createApp(Toast, {type, msg, close});
  app.mount('#'+toastId);

    // 当前项存进数组中 ------start-------当前项存进数组中
  setTimeout(() => {
    const toastEl = document.getElementById(toastId)?.querySelector('.toast');
    if (toastEl) {
      const height = toastEl.offsetHeight;
      // id top height 存储在 activeToasts 数组中
      activeToasts.push({ id: toastId, top, height });
      toastEl.style.top = `${top}px`;
    }
  }, 0);
  // 当前项存进数组中 ------end-------当前项存进数组中

  setTimeout(close, delay);
};

使用

<template>
  <div class="mesage">
    <div @click="showToast">显示Toast</div>
    <div @click="useToast('这是一个警告消息' + Date.now(), 'warning')">warning</div>
    <div @click="useToast('这是一个错误消息' + Date.now(), 'error')">error</div>
  </div>
</template>

<script setup lang="ts">
import { useToast } from '../utils/message'

function showToast() {
  useToast('这是一个消息' + Date.now(), 'success')
}
</script>

总结

  • 每个Toast id应唯一,且不能被覆盖
  • 需考虑存在多个提示框的情况
  • 存在多个提示框需动态计算其位置(添加和删除)
  • 可以把useToast 挂载在实例的原型上,就省去了引入的步骤

结语:

如果本文对你有收获,麻烦动动发财的小手,点点关注、点点赞!!!👻👻👻

因为收藏===会了

如果有不对、更好的方式实现、可以优化的地方欢迎在评论区指出,谢谢👾👾👾