Modal.tsx

import { Text, View } from "@tarojs/components"
import { document } from "@tarojs/runtime"
import { createElement, type ReactNode, useEffect, useRef, useState } from "react"
import { lockScroll, mountPortal, unlockScroll, unmountPortal } from "../utils"
import "./modal.less"

const modalSelectorSet = new Set<string>()
const DEFAULT_MODAL_ID = "#modal"


interface ModalOptions {
  /**
   * 标题
   */
  title?: string;
  /**
   *  内容
   */
  content: ReactNode;
  /**
   * 是否显示透明蒙层
   */
  mask?: boolean;
  /**
   * 确定的回调函数
   */
  onConfirm?: () => void;
  /**
   * 取消的回调函数
   */
  onCancel?: () => void;
  [key: string]: any
}

interface ModalProps extends ModalOptions {
  open?: boolean
  id?: string
}

function Modal(props: ModalProps) {
  const { open, id, content, mask = true, title, onConfirm, onCancel } = props
  const [visible, setVisible] = useState(open)
  const [isAnimating, setIsAnimating] = useState(false)
  const closeTimerRef = useRef<NodeJS.Timeout | null>(null)
  const isMountedRef = useRef(true)

  // 处理确认按钮点击
  function handleConfirm() {
    if (isAnimating) return // 防止动画期间重复点击
    setIsAnimating(true)
    onConfirm && onConfirm()
    handleClose()
  }

  // 处理取消按钮点击
  function handleCancel() {
    if (isAnimating) return // 防止动画期间重复点击
    setIsAnimating(true)
    onCancel && onCancel()
    handleClose()
  }

  /**
   * 关闭弹窗
   */
  function handleClose() {
    // 清理之前的定时器
    if (closeTimerRef.current) {
      clearTimeout(closeTimerRef.current)
    }

    closeTimerRef.current = setTimeout(() => {
      // 检查组件是否仍然挂载
      if (isMountedRef.current) {
        setVisible(false)
        setIsAnimating(false)
        // 恢复背景滚动
        unlockScroll()
      }
    }, 300) // 与CSS动画时长保持一致
  }

  useEffect(() => {
    if (open) {
      setVisible(true)
      setIsAnimating(false)
      // 禁止背景滚动
      lockScroll()
    }
  }, [open])

  // 组件卸载时清理定时器
  useEffect(() => {
    return () => {
      isMountedRef.current = false
      if (closeTimerRef.current) {
        clearTimeout(closeTimerRef.current)
        closeTimerRef.current = null
      }
    }
  }, [])

  if (!visible) return null

  return (
    <View
      className={`modal-container ${isAnimating ? 'modal-closing' : ''}`}
      id={id}
      style={{ backgroundColor: mask ? 'rgba(0, 0, 0, 0.6)' : 'transparent' }}
      >
      <View className='modal-content' catchMove>
        {
          title && (
            <View className='modal-header'>
              <View className='modal-title'>
                {title}
              </View>
            </View>
          )
        }
        <View className='modal-body'>
          {content}
        </View>
        <View className='modal-footer'>
          <View className='modal-footer-btn confirm' onClick={handleConfirm}>
            <Text className='modal-footer-btn-text'>
              确定
            </Text>
          </View>
          <View className='modal-footer-btn-line'></View>
          <View className='modal-footer-btn cancel' onClick={handleCancel}>
            <Text className='modal-footer-btn-text'>
              取消
            </Text>
          </View>
        </View>
  </View>
  </View >
)
}

/**
 * 显示 Modal
 * @param options 
 * @returns 
 */
Modal.showMoal = (options: ModalOptions) => {
  const { onConfirm, onCancel, ...rest } = options || {}
  const toastId = DEFAULT_MODAL_ID
  const hasExistingToast = modalSelectorSet.has(toastId)
  if (hasExistingToast) return toastId
  // 添加Modal标识
  modalSelectorSet.add(toastId)
  try {
    const viewDom = document.createElement("view")
    // 创建Modal组件实例
    const ModalElement = createElement(Modal, {
      ...rest,
      open: true,
      id: toastId,
      onConfirm: () => {
        setTimeout(() => {
          onConfirm && onConfirm()
          modalSelectorSet.delete(toastId)
          unmountPortal(viewDom)
        }, 300)
      },
      onCancel: () => {
        setTimeout(() => {
          onCancel && onCancel()
          modalSelectorSet.delete(toastId)
          unmountPortal(viewDom)
        }, 300)
      }
    })
    // 挂载Modal组件
    mountPortal(ModalElement, viewDom)
  } catch (error) {
    console.error('Modal.open执行出错:', error)
    return null
  }
}

export default Modal

Modal.less

.modal-container {
  position: fixed;
  top: 0;
  left: 0;
  width: 100%;
  height: 100%;
  display: flex;
  align-items: center;
  justify-content: center;
  z-index: 1000;
  /* 添加背景遮罩动画 */
  animation: modalBackdropFadeIn 0.3s cubic-bezier(0.4, 0, 0.2, 1);
  background-color: rgba(0, 0, 0, 0.5);
  /* 确保模态框容器不滚动 */
  overflow: hidden;
  /* 防止触摸滚动 */
  touch-action: none;

  /* 关闭动画状态 */
  &.modal-closing {
    animation: modalBackdropFadeOut 0.3s cubic-bezier(0.4, 0, 0.2, 1);

    .modal-content {
      animation: modalContentSlideOut 0.3s cubic-bezier(0.4, 0, 0.2, 1);
    }
  }

  .modal-content {
    min-width: 70%;
    max-width: 80%;
    background-color: #fff;
    border-radius: 16px;
    /* 优化内容动画:添加缩放和位移效果 */
    animation: modalContentSlideIn 0.3s cubic-bezier(0.34, 1.56, 0.64, 1);
    /* 添加硬件加速 */
    transform: translateZ(0);
    will-change: transform, opacity;
    /* 确保内容区域可以滚动(如果需要) */
    max-height: 80vh;
    overflow-y: auto;

    .modal-header {
      padding: 24px;
      display: flex;
      justify-content: center;
      align-items: center;

      .modal-title {
        font-size: 32px;
        line-height: 40px;
        font-weight: 500;
      }
    }

    .modal-body {
      display: flex;
      justify-content: center;
      align-items: center;
      padding: 24px;
      min-height: 120px;
    }

    .modal-footer {
      display: flex;
      align-items: center;
      position: relative;
      height: 88px;

      &::after {
        content: '';
        position: absolute;
        top: 0;
        left: 0;
        display: block;
        width: 100%;
        height: 1px;
        background-color: #E5E5E5;
      }

      .modal-footer-btn {
        flex: 1;
        height: 100%;
        display: flex;
        justify-content: center;
        align-items: center;
        /* 添加按钮点击动画 */
        transition: background-color 0.2s ease;

        &:active {
          background-color: rgba(0, 0, 0, 0.05);
        }
      }

      .modal-footer-btn-line {
        width: 1px;
        height: 100%;
        background-color: #E5E5E5;
      }

      .modal-footer-btn.confirm {
        height: 100%;

        .modal-footer-btn-text {
          font-size: 32px;
          line-height: 40px;
          font-weight: 500;
          color: #f60;
        }
      }

      .modal-footer-btn.cancel {
        height: 100%;

        .modal-footer-btn-text {
          font-size: 32px;
          line-height: 40px;
          color: #000;
        }
      }
    }
  }
}

/* 背景遮罩淡入动画 */
@keyframes modalBackdropFadeIn {
  from {
    opacity: 0;
  }
  to {
    opacity: 1;
  }
}

/* 背景遮罩淡出动画 */
@keyframes modalBackdropFadeOut {
  from {
    opacity: 1;
  }
  to {
    opacity: 0;
  }
}

/* 模态框内容滑入动画 - 优化版本 */
@keyframes modalContentSlideIn {
  0% {
    opacity: 0;
    transform: scale(0.8) translateY(20px);
  }
  100% {
    opacity: 1;
    transform: scale(1) translateY(0);
  }
}

/* 模态框内容滑出动画 */
@keyframes modalContentSlideOut {
  0% {
    opacity: 1;
    transform: scale(1);
  }
  100% {
    opacity: 0;
    transform: scale(0.8) translateY(20px);
  }
}

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")
  }
}