
交错动画的时序编排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 越大衰减越慢 * k5: 前 5 项快速衰减适合少量项 * k10: 缓慢衰减适合大量项 */ 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 useRefHTMLDivElement(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} classNameproduct-grid {products.map((product, i) ( div key{product.id} classNamestagger-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大约是人眼能感知的仍算快的上限。当总项数过多时让后面项快速追赶或放弃交错。