Vue 3 如何同时显示多个 Bootstrap Toast 弹窗?告别覆盖
2025-03-29 22:34:53
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-container
的position-fixed
和top-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
数组和addToast
、removeToast
方法放到 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 同时展示。