You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

ReactJs onClick事件导致Modal下拉菜单自动关闭问题排查

下拉菜单点击打开后立即关闭Bug修复

问题描述

  • 基于React开发的操作选项下拉菜单组件,参考Udemy课程实现,此前运行正常,新增animate-scale系列CSS类实现缩放动画效果后出现异常
  • 异常表现:点击Create按钮尝试唤起菜单时,菜单会在渲染后立刻自动隐藏,无法正常停留展示
  • 复现效果:
    Bug复现动图
  • 在线复现代码地址:
    https://codesandbox.io/s/practical-jackson-6xut0y?file=/src/App.js

问题相关代码

Header组件代码(Header.js)

import React, { useEffect, useRef, useState } from "react";
import { AiOutlinePlus } from "react-icons/ai";
import {
  BsFillNodePlusFill,
  BsFillSunFill,
  BsNodeMinusFill,
} from "react-icons/bs";
import { useTheme } from "../hooks";
export default function Header({ onAddMovieClick, onAddActorClick }) {
  const [showOptions, setShowOptions] = useState(false);
  const { toggleTheme } = useTheme();

  const options = [
    {
      title: "Add Movie",
      onClick: onAddMovieClick,
    },
    {
      title: "Add Actor",
      onClick: onAddActorClick,
    },
  ];

  return (
    <div className="flex justify-between items-center relative">
      <input
        type="text"
        className="border-2 border-light-subtle dark:border-dark-subtle outline-none bg-transparent focus:border-primary dark:border-white transition p-1 rounded"
        placeholder="Search Movies..."
      />

      <div className="flex items-center space-x-3">
        <button
          onClick={toggleTheme}
          className="dark:text-white text-light-subtle p-1 rounded "
        >
          <BsFillSunFill size={24} />
        </button>
        <button
          onClick={() => setShowOptions(true)}
          className="flex space-x-2  items-center border-secondary text-secondary border-2 font-semibold text-lg px-3 rounded"
        >
          <span>Create</span>
          <AiOutlinePlus />
        </button>
      </div>
      <CreateOptions
        visible={showOptions}
        onClose={() => setShowOptions(false)}
        options={options}
      />
    </div>
  );
}

const CreateOptions = ({ visible, onClose, options }) => {
  const container = useRef();
  const containerID = "option-container";
  useEffect(() => {
    const handleClose = (e) => {
      if (!visible) return;
      const { parentElement, id } = e.target;

      if (parentElement.id === containerID || id === containerID) return;

      container.current.classList.remove("animate-scale");
      container.current.classList.add("animate-scale-reverse");
    };

    document.addEventListener("click", handleClose);
    return () => {
      document.removeEventListener("click", handleClose);
    };
  }, [visible]);
  const handleAnimationEnd = (e) => {
    if (e.target.classList.contains("animate-scale-reverse")) {
      console.log("triggered");
      onClose();
    }
    e.target.classList.remove("animate-scale");
  };
  if (!visible) return null;
  return (
    <div
      id={containerID}
      ref={container}
      onAnimationEnd={handleAnimationEnd}
      className="absolute top-12 right-1 drop-shadow-lg bg-white flex flex-col p-5 space-y-3 dark:bg-secondary animate-scale"
    >
      {options.map(({ title, onClick }) => (
        <Option key={title} onClick={onClick}>
          {title}
        </Option>
      ))}
    </div>
  );
};

const Option = ({ children, onClick }) => {
  return (
    <button onClick={onClick} className="text-secondary dark:text-white">
      {children}
    </button>
  );
};

动画样式代码(index.css)

.animate-scale {
  transform-origin: top;
  animation: scale 0.2s;
}

.animate-scale-reverse {
  transform-origin: top;
  animation: scale 0.2s reverse forwards;
}

@keyframes scale {
  0% {
    transform: scaleY(0);
  }
  100% {
    transform: scaleY(1);
  }
}

根因分析

  1. 点击Create按钮触发setShowOptions(true)时,点击事件会沿DOM树冒泡到document层,此时菜单组件刚完成挂载,useEffect中绑定的全局click监听handleClose会立刻触发
  2. 原有点击范围判断逻辑仅校验点击目标本身的id和直接父元素id,Create按钮不在菜单容器内部,逻辑误判为点击了菜单外部区域,直接给菜单容器添加了反向关闭动画类animate-scale-reverse
  3. 反向关闭动画执行完成后触发onAnimationEnd回调,调用onClose()将showOptions设为false,菜单组件直接被卸载,最终表现为点击按钮后菜单刚出现就立刻关闭

修复方案

  1. 给Create按钮的点击事件添加阻止冒泡逻辑,避免点击按钮时触发document层的关闭监听:
<button
  onClick={(e) => {
    e.stopPropagation();
    setShowOptions(true);
  }}
  className="flex space-x-2  items-center border-secondary text-secondary border-2 font-semibold text-lg px-3 rounded"
>
  <span>Create</span>
  <AiOutlinePlus />
</button>
  1. 优化外部点击判断逻辑,递归向上遍历所有父节点判断点击目标是否在菜单容器内,避免菜单内嵌套多层元素时判断失效:
const handleClose = (e) => {
  if (!visible) return;
  let target = e.target;
  let isClickInside = false;
  // 向上遍历所有父节点判断是否在容器内
  while (target) {
    if (target.id === containerID) {
      isClickInside = true;
      break;
    }
    target = target.parentElement;
  }
  if (isClickInside) return;

  container.current.classList.remove("animate-scale");
  container.current.classList.add("animate-scale-reverse");
};
  1. 补全动画结束后的类名清理逻辑,移除反向动画类,避免后续打开菜单时动画异常:
const handleAnimationEnd = (e) => {
  if (e.target.classList.contains("animate-scale-reverse")) {
    onClose();
    e.target.classList.remove("animate-scale-reverse");
  }
  e.target.classList.remove("animate-scale");
};

内容的提问来源于stack exchange,提问作者Ahmad Nadeem

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.08.26 20:39:19