用法
Toast.showToast(options)
显示 Toast 提示。
参数
| 参数 | 类型 | 默认值 | 必填 | 说明 |
|---|---|---|---|---|
| title | string | - | ✅ | 提示内容 |
| duration | number | 1500 | ❌ | 显示时长(毫秒),0 表示不自动关闭 |
| mask | boolean | false | ❌ | 是否显示遮罩层 |
| onClose | function | - | ❌ | 关闭时的回调函数 |
Toast.hideToast()
手动关闭当前显示的 Toast。
// 检查是否有 Toast 显示
if (Toast.isShowToast()) {
Toast.hideToast()
}
Toast.isShowToast()
检查当前是否有 Toast 显示。
const isShowing = Toast.isShowToast()
console.log('Toast 是否显示中:', isShowing)
注意事项
- 同时只能显示一个 Toast:如果当前有 Toast 显示,再次调用
showToast会被忽略 - 背景滚动控制:Toast 显示时会自动禁止背景滚动,关闭时自动恢复
- 动画效果:Toast 显示和隐藏都有透明度动画,持续时间为 300ms
- 内存管理:组件会自动清理定时器,避免内存泄漏
Toast.tsx
import { createElement, useEffect, useRef, useState } from "react";
import { View } from "@tarojs/components";
import { mountPortal, unmountPortal, disableScroll, enableScroll } from "@/utils";
import './index.scss';
const DEFAULT_TOAST_ID = '#toast';
const EXIT_DURATION = 300;
const toastSelectorSet = new Set<string>();
let triggerExit: ((onFinish: () => void) => void) | null = null;
interface ToastOptions {
title: string;
mask?: boolean;
duration?: number;
onClose?: () => void;
}
interface ToastProps extends ToastOptions {
id: string;
open: boolean;
}
function Toast({ id, open, title, duration = 1500, onClose, mask = false }: ToastProps) {
const [isExiting, setIsExiting] = useState(false);
const exitCallbackRef = useRef<(() => void) | null>(null);
const beginExit = (onFinish: () => void) => {
exitCallbackRef.current = onFinish;
setIsExiting(true);
};
useEffect(() => {
triggerExit = beginExit;
return () => {
triggerExit = null;
};
}, []);
useEffect(() => {
if (!isExiting) return;
const exitTimer = setTimeout(() => {
exitCallbackRef.current?.();
exitCallbackRef.current = null;
}, EXIT_DURATION);
return () => clearTimeout(exitTimer);
}, [isExiting]);
useEffect(() => {
if (!open) return;
disableScroll();
let durationTimer: ReturnType<typeof setTimeout> | null = null;
if (duration > 0) {
durationTimer = setTimeout(() => {
beginExit(() => onClose?.());
}, duration);
}
return () => {
if (durationTimer) clearTimeout(durationTimer);
};
}, [open, duration, onClose]);
useEffect(() => {
return () => {
enableScroll();
};
}, []);
return (
<View
catchMove
id={id}
className={`toast-container ${isExiting ? 'toast-exit' : ''}`}
style={{ backgroundColor: mask ? 'rgba(0, 0, 0, 0.5)' : 'transparent' }}
>
<View className="toast-content">
{title}
</View>
</View>
);
}
function destroyToast(toastId: string, userOnClose?: () => void) {
if (!toastSelectorSet.has(toastId)) return;
userOnClose?.();
toastSelectorSet.delete(toastId);
unmountPortal();
}
/**
* 显示 Toast
* @param options
*/
Toast.showToast = (options: ToastOptions) => {
const { onClose, ...rest } = options;
const toastId = DEFAULT_TOAST_ID;
if (toastSelectorSet.has(toastId)) {
return toastId;
}
toastSelectorSet.add(toastId);
const toastView = createElement(Toast, {
...rest,
id: DEFAULT_TOAST_ID,
open: true,
onClose: () => destroyToast(toastId, onClose),
});
mountPortal(toastView);
};
/**
* 主动关闭 Toast
*/
Toast.hideToast = () => {
const toastId = DEFAULT_TOAST_ID;
if (!toastSelectorSet.has(toastId) || !triggerExit) {
return;
}
triggerExit(() => destroyToast(toastId));
};
Toast.isShowToast = () => toastSelectorSet.has(DEFAULT_TOAST_ID);
export default Toast;
toast.css
.toast-container {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
display: flex;
justify-content: center;
align-items: center;
z-index: 1000;
opacity: 1;
animation: toastFadeIn 0.3s ease-out both;
.toast-content {
background-color: rgba(0, 0, 0, 0.8);
padding: 24px;
border-radius: 16px;
color: #fff;
}
&.toast-exit {
animation: toastFadeOut 0.3s ease-in both;
}
}
@keyframes toastFadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes toastFadeOut {
from {
opacity: 1;
}
to {
opacity: 0;
}
}
utils.ts
import { getCurrentPages } from "@tarojs/taro"
import { render, unmountComponentAtNode } from "@tarojs/react"
import { document, type TaroElement } from "@tarojs/runtime"
import { ReactNode } from "react"
import { noop } from "@tarojs/shared"
export function getPagePath() {
const currentPages = getCurrentPages()
const currentPage = currentPages[currentPages.length - 1]
const path = currentPage.$taroPath
return path
}
const portalViewMap: Map<string, TaroElement> = new Map()
// 获取或创建 Portal 容器
function getPortalContainer(path: string): TaroElement {
if (!portalViewMap.has(path)) {
const portalView = document.createElement("view")
portalView.setAttribute("id", "portal_view")
const pageElement = document.getElementById(path)
if (pageElement) {
pageElement.appendChild(portalView)
portalViewMap.set(path, portalView)
} else {
console.error("cannot find page element")
}
}
return portalViewMap.get(path)!
}
// 挂载 Portal 内容
export function mountPortal(children: ReactNode): TaroElement {
const path = getPagePath()
const portalContainer = getPortalContainer(path)
// 直接渲染内容到容器中
render(children, portalContainer, noop)
return portalContainer
}
// 清理 Portal 内容
export function unmountPortal() {
const container = document.getElementById("portal_view")
if (!container) {
console.error("cannot find portal_view")
return
}
// 使用 unmountComponentAtNode 来卸载 React 组件
unmountComponentAtNode(container)
}
// 滚动控制工具函数
let scrollLockCount = 0
let originalBodyStyle = ''
/**
* 禁止背景滚动
*/
export function disableScroll() {
scrollLockCount++
const body = document.body
if (body && scrollLockCount === 1) {
// 保存原始样式
originalBodyStyle = body.getAttribute('style') || ''
// 添加滚动禁止样式,保留原有样式
const currentStyle = body.getAttribute('style') || ''
const scrollLockStyle = 'overflow: hidden; position: fixed; width: 100%;'
if (currentStyle) {
// 如果已有样式,合并样式
body.setAttribute('style', `${currentStyle}; ${scrollLockStyle}`)
} else {
// 如果没有样式,直接设置
body.setAttribute('style', scrollLockStyle)
}
}
}
/**
* 恢复背景滚动
*/
export function enableScroll() {
scrollLockCount = Math.max(0, scrollLockCount - 1)
const body = document.body
if (body && scrollLockCount === 0) {
// 恢复原始样式
if (originalBodyStyle) {
body.setAttribute('style', originalBodyStyle)
} else {
body.removeAttribute('style')
}
originalBodyStyle = ''
}
}
/**
* 强制恢复背景滚动(重置计数器)
*/
export function forceEnableScroll() {
scrollLockCount = 0
const body = document.body
if (body) {
// 恢复原始样式
if (originalBodyStyle) {
body.setAttribute('style', originalBodyStyle)
} else {
body.removeAttribute('style')
}
originalBodyStyle = ''
}
}