交错动画的时序编排:Stagger 延迟计算与组级联动的工程实践
一、"它们一起出现的时候很美,但卡成了一团"
做一个 Dashboard 的卡片列表入场动画。10 张卡片依次从下方淡入——第 1 张 0ms、第 2 张 80ms、第 3 张 160ms……效果很优雅。但是当我把卡片数量增加到 30 张时,最后一张卡片的等待时间是 30×80ms = 2.4 秒,用户早已滑动离开了。
这就是 Stagger 动画的核心矛盾:延迟太小看不出交错感;延迟太大最后一项永远等不到。
解决这个矛盾的关键是"压缩时间窗口"——不是让所有项的延迟等差递增,而是用非线性曲线(如指数、对数函数)让前几项的间隔大(展现交错感),后几项加速追赶(不浪费用户时间)。
二、Stagger 延迟的数学模型
为了直观对比这两种模式,我们可以观察 30 张卡片场景下的延迟分布差异。在线性延迟模式下,第 0 项从 0ms 开始,第 1 项 80ms,第 2 项 160ms,以此类推,直到第 29 项延迟高达 2320ms,此时用户往往已经滑动离开,导致体验较差。而在优化后的非线性延迟模式下,虽然前几项保持类似的交错间隔(如第 1 项 80ms,第 2 项 150ms),但后续项会加速追赶,使得第 29 项的延迟被压缩至 400ms 左右,确保所有卡片仍在用户可见范围内完成入场,从而获得良好的体验。
三种延迟计算策略:
线性:delay(i) = i × stagger
指数衰减:delay(i) = stagger × (1 - e^(-i/k)),其中 k 是衰减系数。延迟随 i 增大而趋近 stagger × 1 = stagger ——所有项最终都收敛到同一个延迟。
对数增长:delay(i) = stagger × log(i + 2)。延迟缓慢增长,不会线性发散。
最优策略:混合模式——前 5 项用线性(展现交错感),之后用指数衰减(快速追赶)。
三、产线级 Stagger 动画系统
// stagger/stagger-engine.ts // 交错动画时序编排引擎 type StaggerStrategy = 'linear' | 'exponential-decay' | 'logarithmic' | 'hybrid'; interface StaggerConfig { /** 交错类型 */ strategy: StaggerStrategy; /** 基础交错时间 (ms) */ baseDelay: number; /** * 衰减系数(指数衰减模式) * k 越大,衰减越慢 * k=5: 前 5 项快速衰减,适合少量项 * k=10: 缓慢衰减,适合大量项 */ decayFactor?: number; /** 最大总延迟 (ms),超过则忽略后续项的交错 */ maxTotalDelay?: number; /** 每组的项数(分组交错) */ groupSize?: number; /** 组间延迟 (ms) */ groupDelay?: number; } /** * 交错动画时序引擎 * * 核心能力:计算每一项的精确延迟时间 */ class StaggerEngine { /** * 计算第 index 项的延迟时间 * * @param index - 项的序号(从 0 开始) * @param totalItems - 总项数(用于对数/混合模式的参数自适应) * @param config - 交错配置 * @returns 延迟时间(ms) */ static calcDelay( index: number, totalItems: number, config: StaggerConfig ): number { const { strategy, baseDelay, maxTotalDelay } = config; // 分组处理 if (config.groupSize && config.groupSize > 1) { return this.calcGroupDelay(index, totalItems, config); } let delay = 0; switch (strategy) { case 'linear': delay = index * baseDelay; break; case 'exponential-decay': { const k = config.decayFactor || 5; // delay = baseDelay * (1 - e^(-index/k)) * k // 当 index → ∞, delay → baseDelay * k delay = baseDelay * (1 - Math.exp(-index / k)) * k; break; } case 'logarithmic': // delay = baseDelay * ln(index + 2) delay = baseDelay * Math.log(index + 2); break; case 'hybrid': // 前 5 项线性,之后指数衰减 const linearCount = Math.min(5, totalItems); if (index < linearCount) { delay = index * baseDelay; } else { const remainingIndex = index - linearCount; const k = config.decayFactor || 3; delay = linearCount * baseDelay + baseDelay * (1 - Math.exp(-remainingIndex / k)) * k; } break; } // 限制最大总延迟 if (maxTotalDelay && delay > maxTotalDelay) { delay = maxTotalDelay; } return Math.round(delay); } /** * 计算分组交错的延迟 * * 分组交错适用于"卡片按行出现"的场景: * 同一行的卡片同时出现,不同行之间有交错延迟 */ private static calcGroupDelay( index: number, totalItems: number, config: StaggerConfig ): number { const groupIndex = Math.floor(index / config.groupSize!); const positionInGroup = index % config.groupSize!; // 组内所有项共享组延迟基准 const groupDelay = groupIndex * (config.groupDelay || config.baseDelay * config.groupSize!); // 组内可以做微小的交错(可选) const intraGroupDelay = positionInGroup * Math.min(config.baseDelay / 2, 20); return groupDelay + intraGroupDelay; } }/* * ========================================== * CSS Stagger 动画实现 * ========================================== * * 使用 CSS 自定义属性 + transition-delay 驱动交错动画 * JS 只负责设置 --stagger-delay 变量,CSS 负责动画执行 */ .stagger-container { /* * 容器属性 * display: flex/grid 根据布局需求选择 */ } .stagger-item { /* 初始状态:隐藏(动画未触发时) */ opacity: 0; transform: translateY(30px); /* * transition 负责动画执行 * --stagger-delay 由 JS 动态设置 */ transition: opacity 500ms cubic-bezier(0.16, 1, 0.3, 1), transform 500ms cubic-bezier(0.16, 1, 0.3, 1); /* 交错延迟由 CSS 变量控制 */ transition-delay: var(--stagger-delay, 0ms); /* * will-change 提示浏览器优化 transform 和 opacity 动画 * 但不要滥用——只对即将动画的元素添加 */ will-change: transform, opacity; } /* 触发动画——JS 添加 .visible 类 */ .stagger-item.visible { opacity: 1; transform: translateY(0); } /* * ========================================== * 性能优化:延迟清理 will-change * ========================================== * * will-change 如果长期保留会占用 GPU 内存。 * 动画结束后应移除: */ .stagger-item.animation-done { will-change: auto; }// stagger/stagger-animator.ts // 交错动画执行器(DOM 操作层) class StaggerAnimator { /** * 为元素列表设置交错延迟并触发动画 * * @param container - 包含交错元素的容器 * @param itemSelector - 每个交错元素的 CSS 选择器 * @param config - 交错配置 * * 使用方式: * const animator = new StaggerAnimator(); * animator.animate(document.querySelector('.list'), '.card', { * strategy: 'hybrid', * baseDelay: 60 * }); */ animate( container: HTMLElement, itemSelector: string, config: StaggerConfig ): void { const items = container.querySelectorAll(itemSelector); const totalItems = items.length; items.forEach((item, index) => { const htmlItem = item as HTMLElement; const delay = StaggerEngine.calcDelay(index, totalItems, config); // 设置 CSS 变量(transition-delay 的消费方) htmlItem.style.setProperty('--stagger-delay', `${delay}ms`); // 使用 requestAnimationFrame 确保浏览器布局完成后再触发动画 // 否则 transition-delay 可能被"跳帧"忽略 requestAnimationFrame(() => { requestAnimationFrame(() => { htmlItem.classList.add('visible'); // 动画结束后清理 will-change const totalAnimationTime = delay + 500; // transition duration setTimeout(() => { htmlItem.classList.add('animation-done'); }, totalAnimationTime + 100); // 100ms 缓冲 }); }); }); } /** * 重置容器内所有交错元素 * 用于列表重新加载时清除旧动画状态 */ reset(container: HTMLElement, itemSelector: string): void { const items = container.querySelectorAll(itemSelector); items.forEach((item) => { item.classList.remove('visible', 'animation-done'); (item as HTMLElement).style.removeProperty('--stagger-delay'); }); } }// 在 React 组件中使用 function ProductList({ products }: { products: Product[] }) { const containerRef = useRef<HTMLDivElement>(null); const animator = useRef(new StaggerAnimator()); useEffect(() => { if (containerRef.current && products.length > 0) { // 每次数据变化,重置并重新动画 animator.current.reset(containerRef.current, '.product-card'); animator.current.animate(containerRef.current, '.product-card', { strategy: 'hybrid', baseDelay: 60, decayFactor: 3, maxTotalDelay: 600 // 最长等 600ms,避免末尾项等太久 }); } }, [products]); return ( <div ref={containerRef} className="product-grid"> {products.map((product, i) => ( <div key={product.id} className="stagger-item product-card"> <ProductCard product={product} /> </div> ))} </div> ); }四、场景策略选择指南
少量项(< 10):线性延迟(linear)。简单的index × 60ms就足够,不需要任何非线性策略。控制逻辑越简单越不容易出 Bug。
中等项(10-30):混合模式(hybrid)。前 5 项线性展现交错感,后面的指数衰减确保不超时。
大量项(> 30):对数增长(logarithmic)。延迟增长极其缓慢——第 0 项 0ms、第 30 项约 200ms——交错感和时间可控性之间的完美平衡。
分组场景(按行出现):使用groupSize和groupDelay。同一行的卡片同时出现(组内延迟 = 0),行之间用groupDelay做交错。
五、总结
Stagger 延迟计算的核心不是"让每一项延迟不同",而是控制最后一项的总等待时间在用户可接受范围内:
- 线性延迟:适合 < 10 项,简单可靠
- 混合模式:前 5 项线性 + 之后指数衰减,适合 10-30 项
- 对数增长:延迟增长极慢,适合 > 30 项的大量列表
maxTotalDelay:所有策略的上限保护,避免"永远等不到"的动画
最佳实践:不管什么策略,始终设置maxTotalDelay(建议 600-800ms,大约是人眼能感知的"仍算快"的上限)。当总项数过多时,让后面项快速追赶或放弃交错。