Taro模拟实现wx.showToast

 Taro模拟实现wx.showToast
分类:前端

用法

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)

注意事项

  1. 同时只能显示一个 Toast:如果当前有 Toast 显示,再次调用 showToast 会被忽略
  2. 背景滚动控制:Toast 显示时会自动禁止背景滚动,关闭时自动恢复
  3. 动画效果:Toast 显示和隐藏都有透明度动画,持续时间为 300ms
  4. 内存管理:组件会自动清理定时器,避免内存泄漏

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 = ''
  }
}