做走马灯的时候,我脑子里的第一个方案往往不是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是1000px,speed是100px/s,duration就是10000毫秒,一圈10秒。
这里有个小习惯我要强调:我觉得任何走马灯实现里,都应该以“速度”为配置入口,而不是以“duration”为配置入口。因为容器宽度一变,相同duration下的实际滚动速度就变了,但以px/s为基准,跑多少距离就花多少时间,逻辑对得上。
如果你想要从右往左滚,位移就是translateX: [0, -distance]。如果想要从左往右,就使用translateX: [0, distance],同时复制内容的位置逻辑需要反过来,一般不常用。
4. 完整实现:从HTML结构到JS初始化的逐步拆解
4.1 HTML骨架和必要的CSS
HTML只需要两层结构:外层视口容器负责遮挡,内层轨道负责滚动。
<div class="marquee" id="marquee"> <div class="marquee__track" id="track"> <div class="marquee__item">品牌A</div> <div class="marquee__item">品牌B</div> <div class="marquee__item">品牌C</div> <div class="marquee__item">品牌D</div> <div class="marquee__item">品牌E</div> <div class="marquee__item">品牌F</div> </div> </div>CSS注意两个点:外层容器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 class="marquee__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:none,display: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也能在同一个体系里继续扩展,不会出现推倒重来的窘境。