Toast.tsx

import { document, TaroElement } from "@tarojs/runtime"
import { createElement, ReactNode, useEffect, useRef, useState } from "react"
import { mountPortal, unmountPortal } from "../utils"
import "./toast.css"

const toastSelectorSet = new Set<string>()
const DEFAULT_TOAST_ID = "#toast"

interface ToastOptions {
  /**
   * 提示的内容
   */
  title: ReactNode;
  /**
   * 是否显示透明蒙层
   */
  mask?: boolean;
  /**
   * 提示的延迟时间
   */
  duration?: number;
  /**
   * 结束的回调函数
   */
  onClose?: () => void;
  [key: string]: any
}

interface ToastProps extends ToastOptions {
  open?: boolean
  id?: string
}

function Toast(props: ToastProps) {
  const { open, id, duration = 1500, onClose, title, mask } = props
  const [visible, setVisible] = useState(open)
  const timer = useRef<NodeJS.Timeout | null>(null)
  const isMountedRef = useRef(true)

  useEffect(() => {
    if (open) {
      setVisible(true)

      // 清理之前的定时器
      if (timer.current) {
        clearTimeout(timer.current)
      }

      timer.current = setTimeout(() => {
        // 检查组件是否仍然挂载
        if (isMountedRef.current) {
          onClose && onClose()
          setVisible(false)
        }
      }, duration)
    }

    return () => {
      if (timer.current) {
        clearTimeout(timer.current)
        timer.current = null
      }
    }
  }, [open, duration, onClose])

  // 组件卸载时标记状态
  useEffect(() => {
    return () => {
      isMountedRef.current = false
    }
  }, [])

  if (!visible) return null

  return (
    <view className='toast-container' id={id} style={{ backgroundColor: mask ? 'rgba(0, 0, 0, 0.5)' : 'transparent' }}>
      <view className='toast-content'>
        {title}
      </view>
    </view>
  )
}

/**
 * 显示 Toast
 * @param options 
 * @returns 
 */
Toast.showToast = (options: ToastOptions) => {
  const { onClose, ...rest } = options || {}
  const toastId = DEFAULT_TOAST_ID
  const hasExistingToast = toastSelectorSet.has(toastId)
  if (hasExistingToast) return toastId
  // 添加Toast标识
  toastSelectorSet.add(toastId)
  try {
    const viewDom = document.createElement("view")
    // 创建Toast组件实例
    const toastElement = createElement(Toast, {
      ...rest,
      open: true,
      id: toastId,
      onClose: () => {
        onClose && onClose()
        toastSelectorSet.delete(toastId)
        unmountPortal(viewDom)
      }
    })
    // 挂载Toast组件
    mountPortal(toastElement, viewDom)
  } catch (error) {
    console.error('Toast.open执行出错:', error)
    return null
  }
}

/**
 * 主动关闭 Toast
 */
Toast.hideToast = () => {
  const toastId = DEFAULT_TOAST_ID
  const hasExistingToast = toastSelectorSet.has(toastId)
  if (!hasExistingToast) {
    return
  }
  try {
    // 查找Toast对应的父级DOM元素
    const viewDom = document.getElementById(toastId)?.parentElement
    if (viewDom) {
      // 从集合中移除Toast标识
      toastSelectorSet.delete(toastId)
      // 卸载Toast组件
      unmountPortal(viewDom)
    } else {
      console.warn("未找到Toast 父级DOM元素")
    }
  } catch (error) {
    console.error('Toast.hideToast执行出错:', error)
  }
}

// 检查 Toast 是否显示中
Toast.isShowToast = () => {
  const toastId = DEFAULT_TOAST_ID
  return toastSelectorSet.has(toastId)
}

export default Toast

toast.css

.toast-container {
  position: fixed;
  top: 0;
  left: 0;
  width: 100%;
  height: 100%;
  display: flex;
  align-items: center;
  justify-content: center;
  z-index: 1000;
}

.toast-content {
  background-color: rgba(0, 0, 0, 0.8);
  padding: 24px;
  border-radius: 16px;
  color: #fff;
  font-size: 24px;
  max-width: 80%;
  text-align: center;
  animation: toastFadeIn 0.3s linear;
}

@keyframes toastFadeIn {
  from {
    opacity: 0;
  }
  to {
    opacity: 1;
  }
}

utils.ts

import { getCurrentPages } from "@tarojs/taro"
import { render, unmountComponentAtNode } from "@tarojs/react"
import { document, TaroNode, type TaroElement } from "@tarojs/runtime"
import { ReactNode } from "react"


const portalViewMap: Map<string, TaroElement> = new Map()
const PORTAL_VIEW_ID = "portal_view"
// 滚动锁定相关变量
let scrollLockCount = 0
let originalBodyStyle = ''

/**
 * 禁止背景滚动
 */
export const lockScroll = () => {
  scrollLockCount++
  if (scrollLockCount === 1) {
    // 保存原始样式
    const body = document.body
    originalBodyStyle = body.style.cssText


    // 设置禁止滚动样式
    body.style.cssText = `
      ${originalBodyStyle}
      overflow: hidden !important;
      position: fixed !important;
      top: 0 !important;
      left: 0 !important;
      right: 0 !important;
      width: 100% !important;
    `
  }
}

/**
 * 恢复背景滚动
 */
export const unlockScroll = () => {
  scrollLockCount = Math.max(0, scrollLockCount - 1)
  if (scrollLockCount === 0) {
    const body = document.body
    body.style.cssText = originalBodyStyle
  }
}


export function getPagePath() {
  const currentPages = getCurrentPages()
  const currentPage = currentPages[currentPages.length - 1]
  const path = currentPage.$taroPath
  return path
}

// 获取或创建 Portal 容器
function getPortalContainer(path: string): TaroElement {
  if (!portalViewMap.has(path)) {
    const portalView = document.createElement("view")
    portalView.setAttribute("id", PORTAL_VIEW_ID)
    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, dom: TaroElement): TaroElement {
  const path = getPagePath()
  const portalContainer = getPortalContainer(path)
  // 直接渲染内容到容器中
  render(children, dom)
  portalContainer.appendChild(dom)
  return portalContainer
}

// 清理 Portal 内容
export function unmountPortal(dom: TaroNode) {
  const path = getPagePath()
  const pageElement = document.getElementById(path)
  unmountComponentAtNode(dom)
  if (pageElement) {
    dom.parentElement?.removeChild(dom)
  } else {
    console.error("cannot find page element")
  }
}