使用原生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
相关产品推荐
相关产品推荐

