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

使用原生JS反转正在进行的CSS过渡 实现菜单平滑切换效果

问题原因

动画跳变的核心是三点:

  • 快速点击时上一轮未完成的过渡回调还在执行,会错误删除类名打乱状态
  • 切换动画方向时直接替换状态类,浏览器会直接跳到新动画的起始值,不会从当前动画进度继续播放
  • 过渡过程中没有状态标记,会重复叠加事件监听和过渡类,导致样式优先级混乱
修复方案

不需要引入额外动画库,对现有代码做两处改动即可:

1. 重写过渡工具函数

核心逻辑是触发新过渡前先中断旧过渡,锁定当前动画的实际渲染值作为新过渡的起点,避免跳变:

const CLASS_SHOWN = "shown";
const CLASS_TRANSITIONING = "transitioning";

function transitionDisplay(element, effect, timing = "regular") {
  // 检测是否存在未完成的过渡
  const isTransitioning = element.classList.contains(CLASS_TRANSITIONING);
  if (isTransitioning) {
    // 清除上一轮过渡的结束回调,避免后续错误修改类名
    element.removeEventListener("transitionend", element._transitionEndHandler);
    // 读取当前实际渲染的样式值,作为新过渡的起始点
    const liveStyles = window.getComputedStyle(element);
    element.style.opacity = liveStyles.opacity;
    element.style.transform = liveStyles.transform;
  }

  const targetPhase = element.classList.contains(CLASS_SHOWN) ? "leave" : "enter";
  const transitionClsBase = `${effect}-${timing}`;
  const activeTransitionCls = `${transitionClsBase}-${targetPhase}`;
  const oldTransitionCls = `${transitionClsBase}-${targetPhase === "enter" ? "leave" : "enter"}`;
  const fromCls = `${activeTransitionCls}-from`;
  const toCls = `${activeTransitionCls}-to`;
  const isEnter = targetPhase === "enter";

  // 清除上一轮过渡残留的类名
  [oldTransitionCls, `${oldTransitionCls}-from`, `${oldTransitionCls}-to`].forEach(cls => {
    element.classList.remove(cls);
  });

  if (isEnter) element.classList.add(CLASS_SHOWN);
  element.classList.add(CLASS_TRANSITIONING, activeTransitionCls, fromCls);

  requestAnimationFrame(() => {
    requestAnimationFrame(() => {
      // 移除硬编码的行内样式,让浏览器从当前状态开始执行新过渡
      element.style.opacity = "";
      element.style.transform = "";

      element.classList.remove(fromCls);
      element.classList.add(toCls);

      // 绑定过渡结束回调,挂载到元素属性上方便后续解绑
      element._transitionEndHandler = () => {
        if (!isEnter) element.classList.remove(CLASS_SHOWN);
        element.classList.remove(activeTransitionCls, toCls, CLASS_TRANSITIONING);
        element._transitionEndHandler = null;
      };

      element.addEventListener("transitionend", element._transitionEndHandler, { once: true });
    });
  });
}

export default transitionDisplay;

2. 调整菜单组件的事件绑定逻辑

避免过渡过程中重复绑定/解绑事件,同步更新按钮状态:

import transitionDisplay from "../../helpers/transition-display";

const SELECTOR_MENU = ".menu";
const CLASS_ACTIVATED = "activated";
const CLASS_SHOWN = "shown";
const CLASS_TRANSITIONING = "transitioning";
const DATA_TOGGLE = "data-toggle";

function Menu(menu) {
  const menuId = menu.id;
  const trigger = document.querySelector(`[${DATA_TOGGLE}="${menuId}"]`);
  const focusableElements = Array.from(menu.querySelectorAll(":where(a[href], button, input):not([tabindex^='-'], [disabled])"));
  const lastFocusableElement = focusableElements[focusableElements.length - 1];

  function toggle() {
    const isShown = menu.classList.contains(CLASS_SHOWN);
    const isTransitioning = menu.classList.contains(CLASS_TRANSITIONING);

    // 仅在非过渡状态下绑定/解绑事件,避免重复绑定
    if (!isTransitioning) {
      isShown ? unbindShowEvents() : bindShowEvents();
    }

    // 同步更新按钮状态
    trigger.classList.toggle(CLASS_ACTIVATED, !isShown);
    trigger.setAttribute("aria-expanded", !isShown);
    
    transitionDisplay(menu, "slide");
  }

  function bindShowEvents() {
    trigger.addEventListener("keydown", handleTabKeydown);
    menu.addEventListener("keydown", handleTabKeydown);
    document.addEventListener("click", handleOutsideClick);
    document.addEventListener("keydown", handleEscapeKeydown);
  }

  function unbindShowEvents() {
    trigger.removeEventListener("keydown", handleTabKeydown);
    menu.removeEventListener("keydown", handleTabKeydown);
    document.removeEventListener("click", handleOutsideClick);
    document.removeEventListener("keydown", handleEscapeKeydown);
  }

  function handleOutsideClick(event) {
    if (!trigger.contains(event.target) && !menu.contains(event.target)) {
      toggle();
    }
  }

  function handleEscapeKeydown(event) {
    if (event.key === "Escape") {
      toggle();
    }
  }

  function handleTabKeydown(event) {
    if (event.key === "Tab" && ((event.shiftKey && document.activeElement === trigger) || (!event.shiftKey && document.activeElement === lastFocusableElement))) {
      toggle();
    }
  }

  trigger.addEventListener("click", toggle);
}

export { Menu, SELECTOR_MENU };

实现说明

这个方案和主流组件库的无跳变过渡逻辑完全一致:触发反向动画时不重置状态,先抓取当前的实际渲染值作为新动画的起点,保证动画始终连贯。原有CSS过渡代码不需要做任何修改,改完后无论点击速度多快,动画都会从当前进度平滑切换方向,不会出现跳变。
如果菜单初始状态设置了display: none,需要在进入动画的最开始先把display属性改为可见状态,否则getComputedStyle无法读取到正确的中间样式值。

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

相关产品推荐
方舟 Agent Plan

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

最近更新时间:2026.09.02 10:03:37