返回

Vue 3 如何同时显示多个 Bootstrap Toast 弹窗?告别覆盖

vue.js

Vue 3 中如何同时显示多个 Bootstrap Toast 弹窗?告别覆盖烦恼

在使用 Vue 3 和 Bootstrap 5 开发时,你可能遇到过这样一个场景:想触发多个 Toast 弹窗提示,结果新的 Toast 总是覆盖掉旧的那个,没法像预期的那样同时展示多个。就像下面这段简化的代码一样:

<template>
  <div class="toast" id="toastInfo">
    <div class="toast-header">
      标题
    </div>
    <div class="toast-body">
      一些提示信息。
    </div>
  </div>
</template>

<script setup lang="ts">
import { Toast } from "bootstrap";
import { onMounted } from 'vue';

function showToast() {
  const toastEl = document.getElementById('toastInfo');
  if (toastEl) {
    const toast = new Toast(toastEl, { animation: true });
    toast.show();
  }
}

onMounted(() => {
  // 第一次显示 Toast
  showToast();
  // 等待一小会,模拟第二次触发
  setTimeout(() => {
    // 第二次尝试显示 Toast,结果覆盖了第一个
    showToast();
  }, 1000);
});
</script>

运行这段代码,你会发现第二次调用 showToast() 时,屏幕上还是只有一个 Toast。这是为啥呢?怎么才能让多个 Toast 和平共处,一个接一个地显示出来?

问题出在哪?

根本原因在于,上面的代码每次调用 showToast() 时,都是在同一个 HTML 元素id="toastInfo" 的那个 div)上初始化并显示 Bootstrap Toast。

new Toast(element, options) 的作用是为指定的 element 创建一个 Toast 实例 。这个实例负责管理这个特定 DOM 元素 的显示、隐藏、动画等状态。当你对同一个 DOM 元素反复执行 new Toast(...).show(),你并没有创建新的可见弹窗,只是在重复操作同一个 弹窗元素,让它(重新)显示出来。Bootstrap 的设计就是这样,一个 Toast 实例绑定一个 DOM 元素。

所以,想同时显示多个 Toast,关键在于:每个 Toast 都需要有自己独立的 DOM 结构

解决方案来了

要实现多个 Toast 同时显示,我们需要确保每次触发 showToast 时,都作用于一个新的、独立的 DOM 元素上。下面介绍两种主要思路。

方案一:动态创建 Toast DOM 元素

这种方法比较直接:每次需要显示 Toast 时,动态地用 JavaScript 创建一套全新的 Toast HTML 结构,添加到页面上的某个容器里,然后在这个新创建的元素上初始化并显示 Bootstrap Toast。当 Toast 消失后,再把对应的 DOM 元素移除掉,防止页面上残留无用的元素。

1. 原理和作用

  • 动态生成 :不依赖模板中预先写好的单个 Toast 结构。通过 document.createElement 或类似方法,在需要时即时构建 Toast 的 HTML。
  • 独立实例 :每个动态生成的元素都有其唯一性,因此可以在其上创建独立的 new Toast() 实例,互不干扰。
  • 容器管理 :需要一个固定的容器元素(比如一个 div)来放置所有动态生成的 Toast 元素,方便管理布局和样式。
  • 自动清理 :利用 Bootstrap Toast 提供的事件(如 hidden.bs.toast),在 Toast 完全隐藏后,将其对应的 DOM 元素从页面中安全移除。

2. 代码示例

首先,在你的 Vue 组件模板中准备一个容器:

<template>
  <!-- ... 其他内容 ... -->

  <!-- Toast 容器,所有动态 Toast 都将添加到这里 -->
  <div class="toast-container position-fixed top-0 end-0 p-3" style="z-index: 1055">
    <!-- 动态生成的 Toast 会被插入此处 -->
  </div>

  <button @click="triggerToast" class="btn btn-primary">触发 Toast</button>
</template>

然后,修改你的 <script setup> 部分:

import { Toast } from 'bootstrap';
import { ref } from 'vue';

// 获取容器元素(或者你可以用 ref)
// 注意:在 setup 中直接操作 DOM 比较少见,通常建议用 ref,
// 但这里为了演示动态创建的核心逻辑,暂时用 getElementById
// 更 Vue 的方式见方案二
const getToastContainer = () => document.querySelector('.toast-container');

// 一个计数器,用来生成唯一 ID(可选,但有助于调试)
let toastCounter = 0;

function showDynamicToast(title: string = '默认标题', body: string = '默认内容。') {
  const container = getToastContainer();
  if (!container) {
    console.error('Toast container not found!');
    return;
  }

  toastCounter++;
  const toastId = `dynamic-toast-${toastCounter}`;

  // 1. 创建 Toast 的外层 DIV
  const toastElement = document.createElement('div');
  toastElement.id = toastId;
  toastElement.classList.add('toast');
  toastElement.setAttribute('role', 'alert');
  toastElement.setAttribute('aria-live', 'assertive');
  toastElement.setAttribute('aria-atomic', 'true');
  // 可以根据需要添加 delay 等选项的数据属性
  // toastElement.setAttribute('data-bs-delay', '5000');

  // 2. 创建 Toast Header (如果需要)
  const toastHeader = document.createElement('div');
  toastHeader.classList.add('toast-header');
  toastHeader.innerHTML = `
    <strong class="me-auto">${title}</strong>
    <small>刚刚</small>
    <button type="button" class="btn-close" data-bs-dismiss="toast" aria-label="Close"></button>
  `;

  // 3. 创建 Toast Body
  const toastBody = document.createElement('div');
  toastBody.classList.add('toast-body');
  toastBody.textContent = body; // 使用 textContent 防止 XSS

  // 4. 组装
  toastElement.appendChild(toastHeader);
  toastElement.appendChild(toastBody);

  // 5. 添加到容器
  container.appendChild(toastElement);

  // 6. 初始化 Bootstrap Toast 实例
  const toast = new Toast(toastElement, {
    animation: true,
    // autohide: false, // 如果不想自动隐藏,可以设置
  });

  // 7. 添加事件监听器:当 Toast 隐藏后,移除对应的 DOM 元素
  toastElement.addEventListener('hidden.bs.toast', () => {
    // 确保 toast 实例被销毁 (Bootstrap 5.1+ 推荐)
    // 对于旧版本,这一步可能不是必须的,但好习惯是调用 dispose
    // 注意:Bootstrap 的 dispose 可能在新版有所变化,确认文档
    // const currentToastInstance = Toast.getInstance(toastElement);
    // if (currentToastInstance) {
    //   currentToastInstance.dispose();
    // }

    // 从 DOM 中移除元素
    toastElement.remove();
    console.log(`Toast ${toastId} removed from DOM.`);
  });

  // 8. 显示 Toast
  toast.show();
  console.log(`Toast ${toastId} shown.`);
}

function triggerToast() {
  const timestamp = new Date().toLocaleTimeString();
  showDynamicToast(`消息 #${toastCounter + 1}`, `这是在 ${timestamp} 触发的动态 Toast。`);
}

// 可以在 onMounted 或其他地方调用 triggerToast 来测试
// onMounted(() => {
//   triggerToast();
//   setTimeout(triggerToast, 1500);
// });

3. 安全建议

  • 内容清理 (Sanitization) :如果你允许用户输入或其他不可信来源的内容显示在 Toast 中(比如标题或正文),必须 进行严格的清理,防止跨站脚本攻击(XSS)。在上面的例子中,toastBody.textContent = body 是相对安全的,因为它不会解析 HTML。如果需要显示富文本,务必使用可靠的 HTML 清理库(如 DOMPurify)。

4. 进阶使用技巧

  • 定制化 :你可以给 showDynamicToast 函数添加更多参数,比如 type('success', 'error', 'info'),然后根据类型动态添加不同的 CSS 类(如 text-bg-success, text-bg-danger)到 toastElement 上,实现不同样式的 Toast。
  • 唯一性保证 :上面的 toastCounter 是一个简单的 ID 生成方式。在复杂应用中,可以考虑使用 UUID 库生成更可靠的唯一 ID。
  • 位置调整toast-containerposition-fixedtop-0 end-0 p-3 类决定了 Toast 的出现位置。你可以根据 Bootstrap 的 Position utilities 调整这些类,实现比如“底部居中”、“左上角”等效果。

这种方法的优点 是逻辑相对直接,不需要引入额外的 Vue 组件状态管理。缺点 是涉及较多的手动 DOM 操作,在 Vue 的语境下可能不那么“优雅”,并且需要小心处理元素的创建和销毁,避免内存泄漏。

方案二:封装 Toast 组件与管理器

这种方法更符合 Vue 的设计哲学。核心思想是创建一个可复用的 ToastMessage.vue 组件,由它负责渲染单个 Toast 的结构和管理其 Bootstrap 实例。然后,在父组件或全局状态(如 Pinia Store)中维护一个 Toast 消息列表(数组),通过 v-for 指令将这个列表渲染成多个 ToastMessage 组件实例。

1. 原理和作用

  • 组件化 :将单个 Toast 的展现和逻辑封装在 ToastMessage.vue 组件内,提高代码复用性和可维护性。
  • 数据驱动视图 :父组件通过管理一个数据数组(例如 toasts)来控制当前需要显示哪些 Toast。添加新 Toast 就是往数组里加一项数据;移除 Toast 就是从数组里删掉一项数据。Vue 的响应式系统会自动更新视图,增删 ToastMessage 组件实例。
  • 独立生命周期 :每个 ToastMessage 组件实例有自己的生命周期。它可以在 onMounted 钩子中初始化自己的 Bootstrap Toast 实例,并在 onBeforeUnmount 钩子中(或者通过 Bootstrap 的 hidden.bs.toast 事件回调)通知父组件自己应该被移除了,同时调用 toast.dispose() 清理资源。
  • 解耦 :Toast 的触发逻辑(在父组件或 store 中)与 Toast 的具体渲染和行为(在 ToastMessage 组件中)分离开来。

2. 代码示例

a. 创建 ToastMessage.vue 组件

<template>
  <div class="toast" role="alert" aria-live="assertive" aria-atomic="true" ref="toastElementRef">
    <div class="toast-header">
      <strong class="me-auto">{{ title }}</strong>
      <small>{{ time }}</small>
      <button type="button" class="btn-close" data-bs-dismiss="toast" aria-label="Close"></button>
    </div>
    <div class="toast-body">
      {{ message }}
    </div>
  </div>
</template>

<script setup lang="ts">
import { ref, onMounted, onBeforeUnmount, watch } from 'vue';
import { Toast } from 'bootstrap';

// 定义 Props
const props = defineProps<{
  id: string | number; // 用于标识 Toast,方便移除
  title: string;
  message: string;
  time?: string; // 显示时间,可选
  type?: 'info' | 'success' | 'warning' | 'danger'; // Toast 类型,可选
  autohide?: boolean;
  delay?: number;
}>();

// 定义 Emits,用于通知父组件关闭
const emit = defineEmits<{
  (e: 'close', id: string | number): void;
}>();

const toastElementRef = ref<HTMLElement | null>(null);
let toastInstance: Toast | null = null;

onMounted(() => {
  if (toastElementRef.value) {
    // 添加基于 type 的样式类 (可选)
    if (props.type) {
      toastElementRef.value.classList.add(`text-bg-${props.type}`); // Bootstrap 5.2+ uses text-bg-*
      // For older versions you might need custom CSS or other classes like `bg-success`, `text-white` etc.
      if (props.type === 'warning') { // warning 通常是浅色背景,需要深色文字
          toastElementRef.value.classList.remove('text-bg-warning');
          toastElementRef.value.classList.add('bg-warning', 'text-dark');
      } else if (['success', 'danger', 'info'].includes(props.type)){
         toastElementRef.value.classList.add('text-white'); // Ensure text is readable on dark backgrounds
      }

    }


    toastInstance = new Toast(toastElementRef.value, {
      animation: true,
      autohide: props.autohide !== false, // 默认为 true
      delay: props.delay || 5000, // 默认 5 秒
    });

    // 监听 Bootstrap 的隐藏事件
    toastElementRef.value.addEventListener('hidden.bs.toast', () => {
      emit('close', props.id); // 通知父组件关闭
    });

    // 自动显示 Toast
    toastInstance.show();
  }
});

onBeforeUnmount(() => {
  // 组件销毁前,确保 Bootstrap Toast 实例也被销毁
  if (toastInstance) {
    // 先尝试隐藏,避免在 dispose 时还在动画中可能出现的问题
    // toastInstance.hide(); // 可能不需要,dispose 应该会处理
    toastInstance.dispose();
    console.log(`Toast instance for ${props.id} disposed.`);
  }
   // 移除事件监听器 (虽然组件销毁理论上会清理,但显式移除是好习惯)
  if (toastElementRef.value) {
       toastElementRef.value.removeEventListener('hidden.bs.toast', () => { emit('close', props.id); });
  }
});

// 如果需要支持外部控制显示/隐藏 (一般用不上,因为挂载就显示)
// watch(() => props.show, (newValue) => {
//   if (newValue && toastInstance) {
//     toastInstance.show();
//   } else if (!newValue && toastInstance) {
//     toastInstance.hide();
//   }
// });
</script>

<style scoped>
/* 可以添加一些组件特定的样式 */
.toast {
  /* 确保能堆叠显示,如果使用 position-absolute */
  /* width: 350px; */ /* 设置一个宽度 */
  /* max-width: 100%; */
}
/* Add custom styling for different types if needed beyond text-bg-* */
/* .toast.bg-success { ... } */
</style>

b. 在父组件(如 App.vue 或其他页面组件)中使用

<template>
  <div>
    <h1>多 Toast 示例 (组件化)</h1>
    <button @click="addInfoToast" class="btn btn-info me-2">添加 Info Toast</button>
    <button @click="addSuccessToast" class="btn btn-success me-2">添加 Success Toast</button>
    <button @click="addErrorToast" class="btn btn-danger">添加 Error Toast (不自动隐藏)</button>

    <!-- Toast 容器 -->
    <div class="toast-container position-fixed bottom-0 end-0 p-3" style="z-index: 1055">
      <!-- 使用 v-for 渲染 Toast 列表 -->
      <ToastMessage
        v-for="toast in toasts"
        :key="toast.id"
        :id="toast.id"
        :title="toast.title"
        :message="toast.message"
        :type="toast.type"
        :autohide="toast.autohide"
        :delay="toast.delay"
        :time="toast.time"
        @close="removeToast"
      />
    </div>
  </div>
</template>

<script setup lang="ts">
import { ref } from 'vue';
import ToastMessage from './ToastMessage.vue'; // 引入子组件

interface ToastData {
  id: number;
  title: string;
  message: string;
  type: 'info' | 'success' | 'warning' | 'danger';
  time?: string;
  autohide?: boolean;
  delay?: number;
}

const toasts = ref<ToastData[]>([]);
let nextToastId = 0;

function addToast(options: Omit<ToastData, 'id' | 'time'>) {
  const id = nextToastId++;
  const time = new Date().toLocaleTimeString();
  toasts.value.push({ ...options, id, time });
}

function removeToast(id: string | number) {
  toasts.value = toasts.value.filter(toast => toast.id !== id);
  console.log(`Toast ${id} removed from list.`);
}

// 示例触发函数
function addInfoToast() {
  addToast({
    title: '提示',
    message: '这是一条普通的信息提示。',
    type: 'info',
  });
}

function addSuccessToast() {
  addToast({
    title: '成功',
    message: '操作已成功完成!',
    type: 'success',
    delay: 3000, // 3秒后消失
  });
}

function addErrorToast() {
  addToast({
    title: '错误',
    message: '发生了一个严重错误,请检查。这个不会自动隐藏。',
    type: 'danger',
    autohide: false, // 不自动隐藏
  });
}
</script>

<style>
/* 全局或 App.vue 样式,确保 toast-container 正确堆叠 */
.toast-container {
  /* z-index: 1055;  已内联设置 */
  /* 根据需要调整位置,例如 top-0 start-0 */
}
</style>

3. 进阶使用技巧

  • 全局服务化 :对于需要在应用任何地方都能方便触发 Toast 的场景,可以将 toasts 数组和 addToastremoveToast 方法放到 Pinia store 中管理。这样,任何组件都可以注入 store 并调用 addToast 来显示通知,而不需要一层层传递事件或 props。App.vue 或布局组件负责监听 store 中的 toasts 数组并渲染 ToastMessage
  • 过渡效果 :结合 Vue 的 <transition-group> 组件包裹 v-for 循环,可以为 Toast 的添加和移除添加更平滑的动画效果,提升用户体验。
  • 更复杂的配置 :可以给 ToastMessage 添加更多 props,例如是否显示关闭按钮、点击 Toast 时执行的回调函数等。

这种方法的优点 是更符合 Vue 的开发模式,代码结构清晰,易于维护和扩展。Toast 的状态由响应式数据管理,DOM 操作被封装在组件内部。缺点 是相比第一种方法,需要额外创建一个组件,稍微增加了些项目复杂度(但对于可维护性来说通常是值得的)。


选择哪种方案取决于你的项目需求和个人偏好。如果只是简单用用,或者项目非 Vue 主导,动态创建 DOM 可能快一点。但如果是在一个 Vue 应用中,需要频繁、灵活地使用 Toast,并且希望代码风格统一、易于管理,那么封装成 Vue 组件通常是更好的选择。这两种方法都能让你摆脱 Toast 被覆盖的困扰,实现多个 Toast 同时展示。