JS 实现:高度不定过渡动画

29 阅读2分钟

2025-11-21 18.14.00.gif

💥 版本 1 — 高性能 + 缓动(Easing)版

特点:

  • requestAnimationFrame
  • 多元素独立动画
  • 强制同步帧(避免遇到闪动问题)
  • 支持 自定义 easing
  • 默认采用常用的 easeInOutCubic
version1.js

// 常用缓动函数
const Easings = {
  linear: t => t,
  easeInOutCubic: t =>
    t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2,
  easeOutBounce: t => {
    const n1 = 7.5625, d1 = 2.75
    if (t < 1 / d1) return n1 * t * t
    if (t < 2 / d1) return n1 * (t -= 1.5 / d1) * t + .75
    if (t < 2.5 / d1) return n1 * (t -= 2.25 / d1) * t + .9375
    return n1 * (t -= 2.625 / d1) * t + .984375
  }
}

const animMap = new WeakMap()

/**
 * 高性能过渡动画(带缓动)
 * @param {HTMLElement} element 
 * @param {Function} [callback] 
 * @param {Object} [options]
 * @param {number} [options.duration=300]
 * @param {string} [options.easing='easeInOutCubic']
 */
const highlyTransitionalAnimation = (element, callback, options = {}) => {
  const duration = options.duration ?? 300
  const easing = Easings[options.easing] ?? Easings.easeInOutCubic

  const isCollapsed = getComputedStyle(element).display === 'none'
  
  // 若有动画在执行,取消
  if (animMap.has(element)) {
    cancelAnimationFrame(animMap.get(element))
    animMap.delete(element)
  }

  if (isCollapsed) {
    // ---------- 展开 ----------
    element.style.display = 'block'
    element.style.overflow = 'hidden'
    element.style.height = 'auto'
    
    // ⭐ 第一帧:获得自然高度
    const fullHeight = element.offsetHeight

    // ⭐ 第二帧:把高度强制设成 0,浏览器必须渲染一次;关键:回到 0 开始动画
    element.style.height = '0px'
    element.offsetHeight // 同步帧,避免跳动(强制同步 layout(非常关键))

    const start = performance.now()

    const step = (now) => {
      let t = (now - start) / duration
      if (t > 1) t = 1

      const eased = easing(t)
      element.style.height = (fullHeight * eased) + 'px'

      if (t < 1) {
        animMap.set(element, requestAnimationFrame(step))
      } else {
        animMap.delete(element)
        
        // ---------- 第三帧:动画结束后稳定处理 ----------
        element.style.height = fullHeight + 'px'
        
        requestAnimationFrame(() => {
          element.style.height = ''
          element.style.overflow = ''
        })

        callback?.('expand')
      }
    }

    animMap.set(element, requestAnimationFrame(step))

  } else {
    // ---------- 收起 ----------
    const startHeight = element.offsetHeight
    element.style.height = startHeight + 'px'
    element.style.overflow = 'hidden'
    
    // ⭐ 第一帧:锁定当前高度
    /**
     * 没有这一行,你的浏览器会:
     * 把多个 style 更改批量合并,导致瞬间跳 0 → 再跳回 → 再动画
    */
    element.offsetHeight // 强制同步 layout(防止跳动)‼️‼️

    const start = performance.now()

    const step = (now) => {
      let t = (now - start) / duration
      if (t > 1) t = 1

      const eased = easing(t)
      element.style.height = (startHeight * (1 - eased)) + 'px'

      if (t < 1) {
        animMap.set(element, requestAnimationFrame(step))
      } else {
        animMap.delete(element)
        
        // ---------- 第三帧:完全收起状态 ----------
        element.style.display = 'none'
        element.style.height = ''
        element.style.overflow = ''

        callback?.('collapse')
      }
    }

    animMap.set(element, requestAnimationFrame(step))
  }
}

export { highlyTransitionalAnimation, Easings }

💥 版本 2 — 手风琴(Accordion)多元素版

import { highlyTransitionalAnimation, Easings } from './version1.js'

/**
 * Accordion 控制器
 * @param {HTMLElement[]} elements - 需要折叠的元素列表
 * @param {Object} [options]
 * @param {number} [options.duration=300]
 * @param {string} [options.easing='easeInOutCubic']
 */
const createAccordion = (elements, options = {}) => {
  const stateMap = new WeakMap()

  elements.forEach(el => stateMap.set(el, false)) // false = collapsed

  const toggle = (target) => {
    const isCollapsed = !stateMap.get(target)

    elements.forEach(el => {
      const shouldExpand = el === target

      if (shouldExpand && !stateMap.get(el)) {
        // 打开当前目标
        highlyTransitionalAnimation(
          el,
          () => stateMap.set(el, true),
          options
        )
      } else if (!shouldExpand && stateMap.get(el)) {
        // 关闭其他项
        highlyTransitionalAnimation(
          el,
          () => stateMap.set(el, false),
          options
        )
      }
    })
  }

  return { toggle }
}

export default createAccordion

🧪 如何使用 Version 2(Accordion)

import createAccordion from './accordion.js'

// 所有可折叠元素
const items = document.querySelectorAll('.accordion-item')

// 创建一个手风琴控制器
const accordion = createAccordion(items, {
  duration: 350,
  easing: 'easeInOutCubic'
})

// 绑定点击
document.querySelectorAll('.accordion-header').forEach((header, index) => {
  header.addEventListener('click', () => {
    accordion.toggle(items[index])
  })
})