💥 版本 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])
})
})