uni-app 全局 Toast 实现
一、问题背景
在 uni-app 开发中,经常需要在任何地方(如网络请求拦截器、路由守卫等)显示 Toast 提示,但传统方案有以下局限性:
传统方案的局限性
| 方案 | 优点 | 缺点 |
|---|---|---|
uni.showToast() | 原生API,简单 | 功能有限,样式单一,无法自定义 |
| 传统 Toast 组件 | 可自定义样式 | 只能在当前组件使用,无法跨组件调用 |
| Wot UI 方案(provide/inject) | 函数式调用 | 必须在 setup 顶层调用,无法在路由拦截和请求拦截中使用 |
核心难点
- uni-app 无法像 Vue 3 那样全局挂载组件
- 组件实例无法在非 Vue 上下文中访问
- 需要在网络请求和路由拦截中使用 Toast
二、解决方案架构
整体方案由三个核心部分组成:
┌─────────────────────────────────────────────────────────┐
│ Pinia 状态管理层 │
│ useGlobalToast (全局状态) │
│ - toastOptions (显示配置) │
│ - currentPage (当前页面) │
└────────────────────┬────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────┐
│ Layout 插件层 │
│ @uni-helper/vite-plugin-uni-layouts │
│ (一次插入,全局可用) │
└────────────────────┬────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────┐
│ 组件渲染层 │
│ GlobalToast + wd-toast │
│ (监听状态变化,调用 wd-toast API) │
└─────────────────────────────────────────────────────────┘三、实现详解
1. Layout 插件 - 一次插入,全局可用
通过 @uni-helper/vite-plugin-uni-layouts 插件实现统一布局管理:
<!-- src/layouts/default.vue -->
<template>
<wd-config-provider :theme-vars="themeVars" :theme="theme">
<slot />
<!-- 全局组件一次性插入 -->
<wd-notify />
<wd-message-box />
<wd-toast />
<global-loading />
<global-toast />
<global-message />
</wd-config-provider>
</template>优点: 所有页面都会包含这些全局组件,实现"一次插入,全局可用"
2. useGlobalToast - Pinia 状态管理
// src/composables/useGlobalToast.ts
import { defineStore } from 'pinia'
import type { ToastOptions } from 'wot-design-uni/components/wd-toast/types'
interface GlobalToast {
toastOptions: ToastOptions
currentPage: string
}
const defaultOptions: ToastOptions = {
duration: 2000,
show: false,
}
export const useGlobalToast = defineStore('global-toast', {
state: (): GlobalToast => ({
toastOptions: defaultOptions,
currentPage: '',
}),
actions: {
// 显示 Toast
show(option: ToastOptions | string) {
this.currentPage = getCurrentPath()
const options = CommonUtil.deepMerge(
defaultOptions,
typeof option === 'string' ? { msg: option } : option
) as ToastOptions
this.toastOptions = CommonUtil.deepMerge(options, {
show: true,
position: options.position || 'middle',
}) as ToastOptions
},
// 成功提示
success(option: ToastOptions | string) {
this.show(CommonUtil.deepMerge({
iconName: 'success',
duration: 1500,
}, typeof option === 'string' ? { msg: option } : option) as ToastOptions)
},
// 错误提示
error(option: ToastOptions | string) {
this.show(CommonUtil.deepMerge({
iconName: 'error',
duration: 2000,
}, typeof option === 'string' ? { msg: option } : option) as ToastOptions)
},
// 关闭 Toast
close() {
this.toastOptions.show = false
},
},
})核心功能:
- 提供统一的 API(show/success/error/close)
- 维护全局状态(toastOptions、currentPage)
- 支持字符串和对象两种调用方式
3. GlobalToast 组件实现
<!-- src/components/GlobalToast.vue -->
<script lang="ts" setup>
const { toastOptions, currentPage } = storeToRefs(useGlobalToast())
const { close: closeGlobalToast } = useGlobalToast()
const toast = useToast('globalToast')
const currentPath = getCurrentPath()
// 支付宝小程序兼容性处理
// #ifdef MP-ALIPAY
const hackAlipayVisible = ref(false)
nextTick(() => {
hackAlipayVisible.value = true
})
// #endif
// 监听全局状态变化
watch(() => toastOptions.value, (newVal) => {
if (newVal && newVal.show) {
// 只在当前页面显示 Toast
if (currentPage.value === currentPath) {
toast.show(toastOptions.value)
}
}
else {
toast.close()
}
})
</script>
<template>
<!-- 支付宝小程序特殊处理 -->
<!-- #ifdef MP-ALIPAY -->
<wd-toast v-if="hackAlipayVisible" selector="globalToast" :closed="closeGlobalToast" />
<!-- #endif -->
<!-- #ifndef MP-ALIPAY -->
<wd-toast selector="globalToast" :closed="closeGlobalToast" />
<!-- #endif -->
</template>关键特性:
- 通过
currentPage确保 Toast 只在正确的页面显示 - 支持支付宝小程序的兼容性处理
- 使用
virtualHost和styleIsolation优化结构和样式
四、使用方式
1. 在 Vue 组件中使用
<script setup>
import { useGlobalToast } from '@/composables/useGlobalToast'
const toast = useGlobalToast()
const handleClick = () => {
toast.show('这是一个全局 Toast')
toast.success('操作成功')
toast.error('操作失败')
}
</script>2. 在网络请求拦截器中使用
// src/utils/request.ts
import { useGlobalToast } from '@/composables/useGlobalToast'
const toast = useGlobalToast()
axios.interceptors.response.use(
response => response,
error => {
toast.error(error.message)
return Promise.reject(error)
}
)3. 在路由守卫中使用
// src/router/guards.ts
import { useGlobalToast } from '@/composables/useGlobalToast'
const toast = useGlobalToast()
router.beforeEach((to, from, next) => {
if (!checkAuth()) {
toast.error('请先登录')
next('/login')
} else {
next()
}
})五、设计亮点
| 亮点 | 说明 |
|---|---|
| 分层架构 | 状态层、布局层、组件层职责清晰 |
| 全局可用 | 通过 Layout 插件一次插入,所有页面可用 |
| 非 Vue 上下文调用 | 支持 Pinia 状态管理,可在拦截器中使用 |
| 页面隔离 | 通过 currentPage 确保 Toast 只在正确页面显示 |
| 跨平台兼容 | 支持支付宝小程序等平台的特殊处理 |
| 类型安全 | 完整的 TypeScript 类型定义 |
六、可能的面试追问
Q1:为什么需要 currentPage 字段?
"uni-app 是单页应用,页面切换时组件不会重新挂载。如果不记录 currentPage,Toast 可能在错误的页面显示。通过对比 currentPage 和 currentPath,确保 Toast 只在触发时所在的页面显示。"
Q2:为什么不直接在 Pinia action 里调用 wd-toast 的 API?
"因为 wd-toast 的 useToast 必须在 Vue 组件的 setup 中调用(依赖 Vue 上下文),而 Pinia action 可以在任何地方调用。所以需要通过全局状态 + 组件监听的间接方式来实现。"
Q3:如果同时调用多个 Toast,怎么处理?
"当前实现是简单的覆盖逻辑,后调用的 Toast 会覆盖前面的。如果需要队列机制,可以在 Pinia state 中维护一个数组,组件中依次显示。不过实际业务中,Toast 覆盖是常见做法,因为同时显示多个提示用户体验并不好。"
Q4:性能如何?会不会频繁创建组件?
"不会。GlobalToast 组件是通过 Layout 插件在所有页面插入的,整个应用只有一个实例。watch 监听的是 ref 变化,性能开销很小。wd-toast 本身有防抖处理,频繁调用也不会造成问题。"
Q5:这个方案能否推广到 Loading、MessageBox 等组件?
"完全可以,这也是我在项目中实践的。Loading 和 MessageBox 的实现方式完全一致:
- 在 Pinia 中维护全局状态
- 创建对应的 Global 组件监听状态
- 在 Layout 中统一插入
这样就形成了一套完整的全局反馈组件体系。"
七、相关技术栈
- uni-app - 跨端开发框架
- Vue 3 - 前端框架
- Pinia - 状态管理
- Wot Design Uni - UI 组件库
- @uni-helper/vite-plugin-uni-layouts - Layout 插件
- TypeScript - 类型系统
八、总结
这套方案解决了 uni-app 中全局 Toast 的核心痛点,核心思路是:
- 状态驱动 - 用 Pinia 管理全局状态
- 布局注入 - 通过 Layout 插件实现一次插入、全局可用
- 组件响应 - 组件监听状态变化,调用底层 API
- 页面隔离 - 通过 currentPage 确保显示在正确页面
这套模式可以推广到所有需要全局调用的反馈组件,形成统一的全局反馈体系。
