用anime.js实现无缝走马灯:从数学原理到完整代码
做走马灯的时候我脑子里的第一个方案往往不是CSS animation而是anime.js。原因很简单走马灯这个效果看着基础但真做起来“滚动”只是最简单的一层麻烦的是首尾无缝衔接、鼠标悬停暂停、异步内容加载完再启动、多组速度不同的轨道同时滚。这些控制需求一多纯CSS的animation事件流就会让人抓狂而anime.js恰好把这些都封装成了比较舒服的API调用。这篇就完整记录一下我用anime.js实现走马灯的思路、数学原理、完整代码和踩坑记录给正在做同样需求的朋友一个可直接参照的版本。1. 为什么走马灯优先考虑 anime.js先看清需求边界1.1 CSS动画和轮播组件的问题出在哪如果只做“一行内容匀速无限向左滚动”这种最朴素的场景CSS animation确实够用甚至性能还更好。但我在实际项目里很少遇到“真·朴素”的走马灯。运营给的需求通常长这样内容不是写死的需要接口返回后再启动滚动鼠标悬停要暂停离开要继续页面里可能同时存在两组速度不同的走马灯希望滚动到一定圈数后自动切换状态或者触发一个回调偶尔还要配合倒计时时间到了再开始滚动。这套需求落到CSS上会遇到一个很具体的问题animationend事件在单次动画结束时才触发但无限循环的animation根本没有稳定的“一圈结束”时机可以用。想要在每一圈结束做点事情你只能去监听iteration事件然后自己判断这是第几次迭代。听着还行实际一写就乱尤其是你还想对同一段动画的不同循环区间做不同处理时CSS的状态管理会迅速失控。轮播组件类库比如一些Swiper类方案更不用说它们本质上解决的是“分页切换”的问题核心是“判页”和“滑动到某一页”。但走马灯要求的是连续流动的、没有页面概念的视觉强行用分页轮播来做视觉上会变成一卡一卡地跳转完全失去了走马灯那种不中断的流水感。1.2 anime.js到底赢在哪anime.js更接近“动画状态机”的定位。它允许我用一个动画实例去控制元素的连续位移并且提供了pause、play、restart这套完整的实例方法。更重要的是它有一个loop: true的机制循环到终点时能够自动把数值重置回起点这个机制配合“内容复制”的无缝布局恰好就是走马灯最省事的实现路径。它还有timeline。走马灯的业务经常不只是“滚动”这一个动作——比如滚三圈之后停下来展示最后一屏、鼠标进入时稍作停顿、某个元素到位后附带一个弹性效果这些用timeline串起来非常直观。而且anime.js的finished属性返回一个Promise这让异步流程的编排变得很自然先等走马灯跑完某个阶段再执行下一步逻辑。我的判断标准很直接只有“匀速滚动 直线无限循环”这一项需求时可以选CSS只要出现互动暂停、圈数计数、多种状态切换这类联动逻辑就切到anime.js。它引入量不大API理解成本也低但表达力比CSS动画高出至少一个层级。1.3 一个容易被忽略的细节走马灯不是轮播做之前想清楚一个点走马灯和轮播最根本的区别是“连续”还是“分页”。轮播的体验是翻篇缓慢滑动、停止、固定展示一屏、再滑动到下一屏。走马灯则是连续循环内容永远在匀速流动视觉上不设终点。因此走马灯的基本设计准则是“一整条内容轨道在滚”而不是“某一页内容在滚”。这个认知决定了整个实现的设计。我们写的不是“切换器”而是一条无限长的传送带。传送带的长度逻辑才是本文的核心。2. 动手前必须弄懂 anime.js 的三个核心概念2.1 timeline用时间线描述一串动作anime.timeline把多个动画按顺序排成一条链适合走马灯里叠加其他动画的场景。比如一段带“停顿—滚动—停顿—跳转”的复杂走马灯用timeline写就很清晰const tl anime.timeline({ easing: linear, loop: true }); tl.add({ targets: track, translateX: [0, -400], duration: 4000 }).add({ targets: track, translateX: -400, // 保持不动 duration: 1000 });timeline的好处是可以把“移动”和“静止”都显式地安排进时间轴方便后续扩展。但实现基础版走马灯时其实用不到timeline一个简单的动画实例就够了。它更像是给“复杂变体”留的扩展位。2.2 loop: true 和循环重置机制这是整个实现里最关键的一个概念。anime.js设置loop: true之后动画实例会反复从起点播放到终点。每一轮播放结束时属性值会瞬间跳回起点数值然后开始下一轮。如果目标元素的位移是从0到-400px那么每一轮结束时transform都会从-400px瞬间变回0。在没有复制内容的普通元素上这个跳变会被肉眼直接捕捉到看起来就是内容猛跳了一下。但如果我们在元素内部复制了一份同样的内容并且让整个轨道的实际宽度变为原来的两倍此时“从终点跳回起点”在视觉上就完全察觉不到。这个思路是走马灯无缝衔接的底层逻辑后面第三部分会详细算。2.3 easing走马灯必须使用 linear走马灯是匀速流动效果缓动函数必须设为easing: linear。用easeInOut会出现一个很滑稽的效果每次循环开始时会缓慢起步、快结束时逐渐减速整体变成“一波一波”的涌浪感完全没有匀速滚动的安定感。看起来像是快递员每到一个路口都要刹车再起步太难受了。如果实在想让入场有节奏感可以在timeline里先放一段短暂的easeOutCubic加速再衔接慢速匀速段而不要在整个走马灯动画上做缓动。记住走马灯的主动画永远线性。3. 无缝循环的解法与位移计算走马灯唯一的技术难点3.1 数学原理复制一份内容滚一半宽度假设一组原始内容比如6个品牌名的总宽度是W。我们把这一整组复制一份放在原始内容后面。轨道总宽度变成2W。此时让轨道translateX从0线性移动到-W。当位移到-W时轨道里第二组的第一个元素恰好站在了最初第一组第一个元素的位置。用户看到的画面与起点完全一致。如果这时anime.js把translateX瞬间从-W重置回0用户眼睛感受到的画面应该是“没有变化”因为当前视觉位置和第二组首元素位置是等价的。用一张逻辑对照说明时刻轨道位移视口内显示的内容视觉效果动画起点0第一组前几个元素基准画面动画一圈终点-W第二组的前几个元素位置上与第一组完全重合与起点视觉一致循环重置0第一组前几个元素用户感知不到跳变这就是走马灯“无缝”的核心。就像跑步机跑道跑完一圈回到跑带上同一块踏板但因为画面重复形状没有接缝。3.2 位移不能写死用 scrollWidth / 2 动态拿到单份宽度在代码里W这个值不建议手写更不要逐个数item的宽度然后加起来。我常写的是const distance track.scrollWidth / 2;track是整条轨道元素scrollWidth返回它的完整内容宽度包括视口内看得见的部分也包括复制出来的那一半内容。除以2正好得到一份原始内容的宽度。这个写法好处明显内容增删、不同屏幕宽度下自动适应都不需要改位移逻辑。每次初始化动画时实时取一次即可。注意一个小前提走马灯轨道必须保持在单行不能换行。给track设置white-space: nowrap或者让item不换行并启用flex的flex-shrink: 0确保scrollWidth代表的是水平方向的完整轨道长度。3.3 速度换算用px/s而不是直接用毫秒需求方一般不会说“转一圈用时8秒”而是会说“不要太快大概每秒滚100像素”之类。所以更符合直觉的做法是定义速度变量再换算成动画时长const speed 100; // 单位px/s const distance track.scrollWidth / 2; const duration (distance / speed) * 1000; // anime.js 的 duration 单位是毫秒比如distance是1000pxspeed是100px/sduration就是10000毫秒一圈10秒。这里有个小习惯我要强调我觉得任何走马灯实现里都应该以“速度”为配置入口而不是以“duration”为配置入口。因为容器宽度一变相同duration下的实际滚动速度就变了但以px/s为基准跑多少距离就花多少时间逻辑对得上。如果你想要从右往左滚位移就是translateX: [0, -distance]。如果想要从左往右就使用translateX: [0, distance]同时复制内容的位置逻辑需要反过来一般不常用。4. 完整实现从HTML结构到JS初始化的逐步拆解4.1 HTML骨架和必要的CSSHTML只需要两层结构外层视口容器负责遮挡内层轨道负责滚动。div classmarquee idmarquee div classmarquee__track idtrack div classmarquee__item品牌A/div div classmarquee__item品牌B/div div classmarquee__item品牌C/div div classmarquee__item品牌D/div div classmarquee__item品牌E/div div classmarquee__item品牌F/div /div /divCSS注意两个点外层容器overflow: hidden轨道使用flex且子项禁止压缩。.marquee { width: 100%; overflow: hidden; border: 1px solid #eee; } .marquee__track { display: flex; gap: 24px; width: max-content; will-change: transform; } .marquee__item { flex: 0 0 auto; padding: 12px 24px; background: #f5f5f5; border-radius: 6px; white-space: nowrap; }关键点是width: max-content配合flex-shrink: 0。这样track的宽度完全由内容撑开scrollWidth才能代表真实的轨道总宽度。如果track宽度设为100%复制内容后容器宽度被限制scrollWidth计算会出问题。外层视口不要设置transform之类会创建新层级的属性保持原样即可。4.2 JS基础版四行核心逻辑跑通下面是完整可运行的基础版本没有花哨功能但已经把无缝循环跑通了import anime from animejs/lib/anime.es.js; const track document.getElementById(track); const SPEED 100; // px/s function getDistance() { return track.scrollWidth / 2; } function startMarquee() { const distance getDistance(); const duration (distance / SPEED) * 1000; const anim anime({ targets: track, translateX: [0, -distance], duration: duration, easing: linear, loop: true }); return anim; } const marqueeAnim startMarquee();初始化前提track里已经含有一份原始内容startMarquee函数内部没有自动复制内容。你需要在HTML里或JS初始化时先复制内容但复制逻辑下面细讲。对于基础测试可以直接在HTML里手动写两份相同内容这样也能跑。但正常项目里应该用JS复制。4.3 内容复制的正确姿势cloneNode而不是innerHTML拼接复制内容看起来是个小事但坑很多。我最早写的是track.innerHTML track.innerHTML;这句能复制内容但问题很大innerHTML序列化会丢掉节点上已经绑定的事件监听和若干实时状态。如果item上已经挂了点击事件或者item内含有状态组件复制后全部失效。更夸张的是如果item里有自定义元素或者React/Vue之类的动态结构innerHTML拼接会把整个应用逻辑打乱。我一直推荐用cloneNode做复制const clone track.cloneNode(true); while (clone.firstChild) { track.appendChild(clone.firstChild); }cloneNode(true)深拷贝了节点的所有子树和属性但不会把事件监听也带走所以不会有事件冲突。用while把clone里的子节点全部挪进track视觉上等同于把一份内容追加到原有内容后面。这个操作建议在页面DOM加载完毕后再执行。如果你把两份内容都渲染好了再初始化动画那就更稳。4.4 悬停暂停与继续一个事件搞定走马灯最常见的互动就是鼠标悬停暂停、移开继续。anime.js的动画实例提供了pause和play方法事件处理只需要两行const marqueeEl document.getElementById(marquee); marqueeEl.addEventListener(mouseenter, () { marqueeAnim.pause(); }); marqueeEl.addEventListener(mouseleave, () { marqueeAnim.play(); });注意这里不要使用restart。pause会把动画停在当前帧play会从暂停的位置继续视觉上毫无跳帧体验很好。restart会强制回到起点那反而会打断用户正在查看的内容。4.5 动态内容场景接口数据后再启动实际项目里内容往往不是写死在HTML里的而是接口返回后动态渲染。这种场景下需要等到内容渲染完成、scrollWidth稳定之后再初始化动画。async function init() { const data await fetch(/api/marquee-data).then(res res.json()); track.innerHTML data.map(item { return div classmarquee__item${item.name}/div; }).join(); // 复制内容 const clone track.cloneNode(true); while (clone.firstChild) { track.appendChild(clone.firstChild); } startMarquee(); }这里有个很容易踩的坑如果item里有图片而图片尚未加载完成时就去取scrollWidth得到的宽度会比实际宽度小导致位移距离不足走马灯滚动一圈后会出现明显的跳动。解决思路是等图片加载完再初始化或者给图片设置固定width/height让布局宽度在初始化时已经确定。我建议优先固定宽高因为走马灯里的内容通常是品牌图或方形容器固定尺寸对整体排版也更可控。4.6 resize重置窗口变宽后重新计算容器宽度变化后原来的distance和duration都失效了。需要监听resize防抖后重新创建动画实例。let marqueeAnim; let resizeTimer; function createMarquee() { if (marqueeAnim) { marqueeAnim.pause(); } const distance getDistance(); const duration (distance / SPEED) * 1000; marqueeAnim anime({ targets: track, translateX: [0, -distance], duration: duration, easing: linear, loop: true }); } window.addEventListener(resize, () { clearTimeout(resizeTimer); resizeTimer setTimeout(createMarquee, 300); });重新创建实例时先把旧实例pause再新建一个。旧的动画实例不会自动释放所以建议在重新初始化前显式pause防止两个动画同时操作同一个target产生位移叠加的bug。如果页面存在多组走马灯建议把createMarquee包装成一个工厂函数传入track元素和速度返回动画实例再统一管理resize监听。5. 常见问题排查与性能优化记录5.1 首帧内容闪烁初始化前track暴露了这个问题几乎人人都遇到过。页面加载时HTML已经渲染出整条轨道但JS还没有初始化动画此刻用户是可以看到“多出来”的那份复制内容的。等动画开始后轨道突然向左移动画面会闪一下。解决方式在初始化前隐藏轨道.marquee__track { visibility: hidden; }然后在初始化完成时把它改为可见function startMarquee() { // ... track.style.visibility visible; return anim; }visibility:hidden不会影响布局所以scrollWidth照常计算。等到动画创建完毕后再显示画面就不会闪烁了。注意不要用display:nonedisplay:none会让scrollWidth变成0无法正确计算位移。5.2 边界跳动先检查轨道宽度的计算时机找不到视觉接口的原因我一般在下面几个方向排查现象可能原因处理方式循环一圈后内容明显跳动复制内容行为异常轨道宽度不是精确的两倍关系检查复制逻辑确保轨道宽度恰好是2W每次循环都短了一截图片未加载完成时计算了distance给图片固定宽高或在load事件后才初始化位移速度时快时慢easing不是linear将easing改为linear首尾有细小错位flex gap 导致两份内容间距不一致统一使用gap不要给首尾item加额外margin循环时偶发停顿浏览器在循环重置时强制重排确保动画使用transform不要用margin/left特别说一下flex gap的问题。如果你给每个item设置margin-right第一份内容的最后一个item和第二份内容的第一个item之间会产生两倍间距但其他item之间只有一倍间距。用track的gap属性则可以统一处理所有item间距包括两份内容的接缝处视觉均匀度非常好。5.3 性能优化给轨道开启合成层走马灯的位移发生在transform属性上这本身已经是GPU合成友好的路径。为了更稳我给track加上will-change: transform告诉浏览器提前为该元素创建合成层。这里有一个度的问题will-change不要加在item上更不要给每个item都加filter、box-shadow等高消耗属性。一个走马灯轨道上挂着一堆box-shadow的item滚动起来帧率会惨不忍睹。我一般让item保持在简单的背景色或纯色块花哨的效果留给外面的静态装饰。动画过程中也尽量不要读取offsetWidth、scrollWidth这类会强制布局的属性。调试的时候可以临时读线上代码必须确保所有宽度计算都在初始化阶段完成。5.4 页面切后台再回来动画状态错乱浏览器切后台时requestAnimationFrame会暂停。anime.js基于这个机制在页面恢复时可能出现动画位置偏差。处理方式是监听visibilitychange页面重新可见时重新计算distance并重建动画实例document.addEventListener(visibilitychange, () { if (!document.hidden) { createMarquee(); } });这样即使浏览器在后台丢帧恢复后也能重新校准位置。注意重建后visibility设为visible的逻辑要一并执行否则切页回来轨道还是隐藏的。5.5 多组走马灯同时跑的设计如果页面里有两组走马灯速度不同、交互独立封装函数是最直接的做法function factoryMarquee(trackEl, speed, options {}) { const track trackEl; const SPEED speed; function getDistance() { return track.scrollWidth / 2; } function create() { if (factoryMarquee._anim) { factoryMarquee._anim.pause(); } const distance getDistance(); const duration (distance / SPEED) * 1000; const anim anime({ targets: track, translateX: [0, -distance], duration, easing: linear, loop: true }); track.style.visibility visible; factoryMarquee._anim anim; return anim; } if (options.hoverPause) { const parent track.parentElement; parent.addEventListener(mouseenter, () factoryMarquee._anim factoryMarquee._anim.pause()); parent.addEventListener(mouseleave, () factoryMarquee._anim factoryMarquee._anim.play()); } return { create }; } const marqueeA factoryMarquee(document.getElementById(trackA), 120, { hoverPause: true }); const marqueeB factoryMarquee(document.getElementById(trackB), 60, { hoverPause: true }); marqueeA.create(); marqueeB.create();工厂函数的好处是每次resize只需要调用各自的create方法即可。多组轨道共享一套逻辑后续维护成本很低。5.6 复用节流动画实例管理的小心得我见过一些项目在走马灯里反复创建anime实例而不清理旧实例时间久了页面会非常卡。走马灯的生命周期较长创建实例时一定要先暂停并释放上一个实例。if (currentAnim) currentAnim.pause(); currentAnim anime({ ... });虽然anime.js不会自动释放已经被暂停的实例但至少在重新创建前暂停旧实例避免两个实例同时操作同一个target。如果你有非常多的动态重建刷新场景还可以在重建后手动把旧实例置空引用。在实际项目中我建议把走马灯封装成一支独立的小模块对外只暴露start、stop、resize三个方法。启动时由业务方传入数据和速度内部统一管理复制逻辑、visibility、动画实例这样即使后续扩展倒计时、弹层联动代码也不会乱。最后补几句实际做走马灯的心得真正把走马灯调顺之后我最大的体会是这个看似简单的滚动效果麻烦的重头戏全在“无缝”和“同步”上。无缝靠复制内容加数学位移解决同步靠anime.js的实例方法解决这两件事想通了剩下的就是体力活。还有一个小技巧调试走马灯时我喜欢临时把外层容器的overflow设为visible这样能看到整条轨道的真实长度和位移情况比对着裁切后的画面脑补要快得多。调完再恢复overflow问题定位效率翻倍。如果你在做一个活动页的滚动视觉或者只是想要一个纯滚动的情怀小组件anime.js这一套足够用了。真遇到更复杂的编排动画比如每个item进入视口时的弹性进场、不同速度的错位联动anime.js的timeline和stagger也能在同一个体系里继续扩展不会出现推倒重来的窘境。