我在给运营后台做公告滚动条的时候被“走马灯”这个看似不起眼的小组件卡了整整一个下午。需求列出来其实没几条公告项一行或两行横向滚动鼠标悬停时暂停、移开继续窗口从桌面缩到平板时不能出现空白断层内容列表由运营随时增删。第一反应当然是用 CSS 的 animation 加 keyframes 去滚动然而真正写起来才发现这类“永远循环”的动效看起来简单边界条件却不少。折腾完一圈之后我决定把动画部分换成 anime.js组件也从“写样式”变成了“写状态”代码量反而减了一大半。这篇文章就聊聊我在这个走马灯项目里的完整实现过程包括轨道结构、无缝循环的宽度怎么量、anime.js 的循环语义怎么用以及后来踩出的几个坑图片加载、字体换行、窗口 resize 后动画断档。适合正在做运营后台、官网 banner、短视频滚动吃字这类需求的同学也适合对 anime.js 感兴趣但不知道它适合什么场景的开发者内容不依赖框架原生 JS 可以直接抄。1. 为什么走马灯这种需求我最后选了 anime.js 而不是纯 CSS1.1 CSS 动画被卡住的三个点走马灯动画从视觉上解释极其简单内容一直在往一个方向移动移到某个边界之后再回到起点。纯 CSS 的做法也有直接把一个容器用keyframes从translateX(0)动画到translateX(-50%)然后infinite循环内容宽度不变的情况下效果非常好而且不占主线程。真正出问题的是这个需求只要稍微“活”一点CSS 就开始别扭了。第一个点是运行时宽度。运营后台的公告条是用户可能随时添加条目的每条文案长度不同图片 banner 的宽高比也不同。CSS keyframes 里的百分比位置是写死在样式表里的内容一变-50% 对应的实际像素宽度就变了滚动周期也跟着变走马灯要么变快要么变慢甚至出现一个循环还没结束就跳到下一轮的情况。你需要用 CSS 变量或者直接在运行时改 style这不麻烦但已经是在写 JS 了。第二个点是暂停恢复。CSS 有animation-play-state: paused这确实能做到悬停暂停。可如果我想同时做更多控制比如点击某个公告时整个背景进入弹层弹层出现后主滚动要停在当前帧、弹层关闭后从原地继续滚动animation-play-state能停但你要拿到“当前停在哪个位置”就得靠 getComputedStyle 去解析 matrix回头还要手动设置animation-delay过程非常绕。第三个点是多效果叠合。走马灯真正好看的实现通常不只是平移两侧要加渐隐遮罩左右两端内容可能要有淡入淡出条目在进入视口中心时可以轻微放大或者换颜色。这些效果如果全部塞进一个 CSS keyframes 里要么无限叠加 keyframes 的百分比段要么用多个容器分别动画然后同步它们。同步这件事放在 CSS 里是最难受的时间轴完全不共享没法在代码里精确认当前进度。1.2 anime.js 到底给了我们什么anime.js 是一个很小的 JS 动画库体积小到可以接受核心思路是让你用 JS 直接驱动 CSS 属性并且暴露了播放、暂停、seek、时间线这些完整的状态控制。对我这个走马灯场景来说它刚好解决了上面三个麻烦滚动距离不用写在 CSS 里每次渲染前用getBoundingClientRect()量出来就行动画实例独立存在随时pause()/play()还能seek()到任意进度多个动画可以放进一个anime.timeline()或者其他条目各自起实例互不干扰。拿表格对比一下按我当时的实际需求列能力CSSkeyframesanime.js无缝循环需要配合固定宽度运行时测量后动态计算暂停/恢复animation-play-statepause()/play()跳到指定进度需要 Hack改 delay 和负时seek()直接执行内容变化后重算手动改 CSS 变量量完宽重新动画几行代码组合其他动效多个 keyframes 需要手动对齐可以独立实例或 timeline低端机性能仍为合成器动画尚可transform 动画同样走合成OK注意我并不是说 CSS 动画废了。如果你的走马灯内容是固定的永远不增加场景只有桌面那translateX(-50%) infinite是最省事的办法。可但凡要交互要动态内容要响应式把控制和计算交给 anime.js 的收益非常明显。我的底线是看到“走马灯”三个字先想 CSS 的人遇到上面这些扩展需求时迟早会回来补 JS不如一开始就按动画状态机的思路设计。2. 搭无缝滚动轨道的核心复制内容量准宽度2.1 结构先行视口、轨道、条目无论用什么库无缝走马灯的 HTML 结构都长一个样一个隐藏溢出的视口容器里面放一个宽度超过视口的轨道轨道里才是真正的内容项。我这里直接给出当时用的结构div classmarquee-viewport div classmarquee-track a classmarquee-item href...公告一本月活动入口已开放/a a classmarquee-item href...公告二新版本将于周五发布/a a classmarquee-item href...公告三安全巡检时间调整/a /div /div对应的关键样式.marquee-viewport { overflow: hidden; width: 100%; } .marquee-track { display: flex; width: max-content; will-change: transform; } .marquee-item { flex-shrink: 0; white-space: nowrap; margin-right: 32px; }这里有个细节我第一版就栽了.marquee-track必须用width: max-content否则轨道会被视口宽度限制住内容多的时候换行内容少的时候宽度又不足scrollWidth的测量就会出问题。item 上一定要加flex-shrink: 0不然 flex 布局会在空间紧张时把条目压缩视觉上字距忽然变化。这两个样式看着基础却是后面所有 JS 测量的地基。2.2 无缝原理内容复制一份滚动一个副本的宽度无缝滚动的原理可以归纳成一句话把同一份内容放两遍在轨道里动画从位置 0 滚动到“一份内容的宽度”的位置瞬间回到 0因为两份内容长得完全一样肉眼无法察觉这个跳变。用坐标来解释更清楚。假设原始内容组宽度是D轨道里有两份内容轨道总宽度是2D。动画开始时轨道位于X0此时视口看到的是第一份内容动画把translateX从0动画到-D第二份内容就逐渐补上到达-D时如果直接让轨道回到X0由于第一份和第二份长得一模一样视口里看到的画面和上一帧没有区别循环就完成了。这就是“无缝”的全部秘密。它并不神秘只要D永远大于视口宽度滚动过程在任何时刻都不会露底。为什么强调D要大于视口宽度因为如果内容总宽度比视口还窄第一份内容滚完了第二份还没来得及完全进入视口电视上看会有一块空白。解决办法是要么额外复制多份内容要么把滚动内容放宽容。实际项目中我一般写一个函数尝试把内容重复几份直到轨道宽度不小于视口宽度的两倍。2.3 克隆和测量的可运行代码结构搭好之后JS 要做两件事克隆一份内容到轨道尾部算出原始内容宽度D。这里建议用cloneNode(true)而不是innerHTML字符串拼接尤其是条目里有图片、有事件监听时。innerHTML拼接出来的节点会被浏览器重新解析图片可能要二次加载事件也全部失效。function setupMarquee(viewport) { const track viewport.querySelector(.marquee-track); const items Array.from(track.children); // 拷贝一份内容追加到轨道末尾 const clones items.map((item) item.cloneNode(true)); track.append(...clones); // D 是单份原始内容的总宽度 const D items.reduce((sum, item) sum item.getBoundingClientRect().width, 0); // 如果内容不够宽就再多补几份避免视口漏白 while (D viewport.clientWidth * 2) { const extra Array.from(items).map((item) item.cloneNode(true)); track.append(...extra); D items.reduce((sum, item) sum item.getBoundingClientRect().width, 0); } return { track, D, viewport, }; }注意循环补内容这里有个容易忽略的细节cloneNode(true)会把节点上的id也复制过去页面里出现重复 id 虽然功能上不一定立刻爆错但后续如果getElementById或者做自动测试就麻烦了。解决方法是克隆前把id清空或者干脆从一开始就不给轨道内的条目写id。另一个细节是间距margin-right计算D时用getBoundingClientRect().width不会把 margin 算进去所以我另外用一个工具函数计算每个 item 及其 margin 的合计宽度。这块不要用offsetWidth糊弄offsetWidth同样不含外边距漏一处循环在边界处就会慢半拍滚动半拍最后肉眼可见“卡”一下。3. anime.js 动画本体线性缓动与循环语义3.1 一段能跑的动画代码宽度量好轨道内容准备好动画本体反而短。这段就是最终跑起来的核心let marqueeAnim null; function startMarquee(viewport) { const { track, D } setupMarquee(viewport); marqueeAnim anime({ targets: track, translateX: -${D}px, duration: D * 40, // 每像素 40ms数值按视觉节奏调 easing: linear, loop: true, direction: normal, autoplay: true, }); }duration: D * 40是一个经验做法滚动速度要和内容宽度成比例如果固定 20000ms那内容越长滚动越快内容越短滚动越慢用户会觉得速度不稳定。我把 D 当成基准让每个像素走过的时间恒定这样不同长度的活动公告看起来才是同一个节奏。3.2 为什么必须是 linear走马灯这个场景缓动函数只能选linear。听起来像废话但我真的见过新人在项目里用了easeInOut因为“感觉更柔和”结果每一圈开始和结束都伴随着明显的加速、减速像是内容在呼吸一样。走马灯的语义是匀速连续运动任何非线性的缓动都会把“每一圈”暴露给用户尤其内容重复处减速的出现会让无缝循环露出破绽。如果你希望视觉上更“有生命力”应该把注意力放在别处比如用anime.stagger给每个 item 加一点进入视口时的透明度差异而不是去给整个平移做缓动。平移保持线性局部动画才能随意发挥。3.3 循环边界跳到 0 那一下为什么不露馅很多人第一次用 anime.js 写循环时会惊讶到了-D再下一帧是直接变成0吗是的就是瞬间跳回。这就是为什么我们坚持把轨道内容复制一份——那一刻跳回0视口里看到的画面和跳之前完全一样所以用户毫无感觉。但这里有一个 anime.js 特性需要留意如果不显式设置direction某些版本遇到loop: true时默认行为可能不是“从头再来”而是会在结束和开始之间反向运动也就是人们常说的“回去又滚回来”。这不是 bug是库的语义和我们的需求不一致。所以我习惯在配置里显式写上direction: normal把话说清楚。如果你用的是更新的版本也可以看下文档里有没有loopReplace之类的开关有就打开语义更直观。不要依赖记忆每一次接入版本变动都去确认一下循环行为比在浏览器里发现动画来回抽动再排查省时间。3.4 性能把动画留在 transform 里anime.js 默认也是操作transform这是正确选择。CSS 的transform动画走的是合成线程不会在每一帧都去触发布局和 paint而top、left这一类属性会触发 layoutmargin 会触发 layout都不要用。我额外在.marquee-track上加了will-change: transform目前实测下来能减少首帧卡顿但新同学注意别把这个属性加到每个item上一个轨道一个 layer 就够了item 一个加一个 will-change反而会生成大量合成层移动端内存直接拉高。4. 调试实录字体、图片、resize 三个最容易翻车的点4.1 图片没加载完就开始量宽这个坑我从第一版就踩进去了。公告条如果只是文字setupMarquee()执行时文本已经渲染完成宽度基本准确。但只要内容里有图片 banner问题就来了——页面打开时图片还没有加载完getBoundingClientRect().width拿到的可能是 0 或者占位宽度D 算小了滚动到一半内容向左漏底图片忽然加载进来又把轨道撑开循环节奏全乱。解决办法是在初始化前等图片加载完成。我写了一个简单的等待函数function waitForImages(root) { const images Array.from(root.querySelectorAll(img)); return Promise.all( images.map((img) { if (img.complete) return Promise.resolve(); return new Promise((resolve) { img.addEventListener(load, resolve, { once: true }); img.addEventListener(error, resolve, { once: true }); }); }) ); }如果图片资源数量多还可以加上超时逻辑防止某张图挂太久把整个走马灯卡在等待状态function waitForImagesTimeout(root, ms 3000) { return Promise.race([ waitForImages(root), new Promise((resolve) setTimeout(resolve, ms)), ]); }这里的error事件也要监听否则图裂了 promise 永远不会 resolve。另外不要幻想DOMContentLoaded之后图片一定加载完DOM 解析和图片网络加载是两回事。4.2 字体加载导致的宽度抖动文本走马灯还有一种隐蔽的宽度变化自定义字体还没生效时浏览器先用 fallback 字体渲染此时量出来的宽度可能比目标字体宽或者窄。字体加载完成后页面会 reflow轨道宽度变了滚动节奏突然变快或变慢严重时还会在循环边界露出间隙。处理办法是显式等待字体就绪再初始化。比如用document.fonts.readyasync function initWhenFontReady() { if (document.fonts document.fonts.ready) { await document.fonts.ready; } startMarquee(viewport); }如果字体是从 CDN 异步加载的document.fonts.ready在部分场景下可能提前 resolve稳妥一点用document.fonts.load(16px 你的字体名)来主动触发加载。这条建议同样适用于其他“需要精确获知内容宽度”的前端组件不只走马灯。4.3 resize 之后动画断档/重影桌面拖窗口缩小运营后台的面板可能突然变窄视口宽度变了原来那份内容的宽度 D 没有变但如果 item 会换行或者宽度会被压缩测量结果就失效。更麻烦的是如果 resize 时直接“再 new 一个 anime 实例”旧的实例还活着两个动画同时操作同一个 transform会看到轨道在打架——一个把它推过去另一个把它拉回来甚至出现残影。我最后的处理方案很朴素resize 防抖之后把旧动画彻底干净地销毁再重新初始化。let resizeTimer null; window.addEventListener(resize, () { clearTimeout(resizeTimer); resizeTimer setTimeout(() { if (marqueeAnim) { marqueeAnim.pause(); marqueeAnim null; } anime.set(track, { translateX: 0 }); startMarquee(viewport); }, 200); });关键点是anime.set(track, { translateX: 0 })。因为旧的动画可能停在-D的某个中间位置不把 transform 清零新动画的起点是错的。先pause()旧实例再清 transform最后重建实例顺序不能乱。有人问为什么不直接用同一个实例update参数要改的是目标位置和循环周期相当于整段动画语义变了重新建实例逻辑最清晰避免状态残留。5. 玩点进阶悬停暂停、拖拽控制、按钮翻页与遮罩渐变5.1 鼠标悬停暂停这个需求应该是走马灯标配了。鼠标进入视口就暂停离开就继续。anime.js 的实现简单到让人想哭viewport.addEventListener(mouseenter, () { marqueeAnim.pause(); }); viewport.addEventListener(mouseleave, () { marqueeAnim.play(); });如果你是做移动端的把mouseenter换成pointerenter更通用或者干脆用pointerenterpointerleave一套监听通吃。要注意的是当动画处于 paused 状态时play()会从暂停时间点继续不会重置到开头这正是我们要的行为。如果内容里有链接暂停状态下点击链接不会因为动画位移导致误触体验上顺滑很多。5.2 拖拽控制进度把走马灯变成可交互的“胶带”这只是悬停暂停的进阶版允许用户按住走马灯向左滑动画进度跟着手指走松手恢复自动播放。这个功能用户在查看长公告时特别有用尤其一条文案没看完时可以用手“粘”住它。let isDragging false; let startX 0; let startTime 0; viewport.addEventListener(pointerdown, (e) { isDragging true; startX e.clientX; startTime marqueeAnim.currentTime; marqueeAnim.pause(); }); window.addEventListener(pointermove, (e) { if (!isDragging) return; const deltaX e.clientX - startX; const deltaTime (deltaX / D) * marqueeAnim.duration; const targetTime Math.min( marqueeAnim.duration, Math.max(0, startTime deltaTime) ); marqueeAnim.seek(targetTime); }); window.addEventListener(pointerup, () { if (!isDragging) return; isDragging false; marqueeAnim.play(); });这里的换算要理解deltaX是用户手指移动的像素除以一份内容的宽度 D得到的是“进度比例”再乘以动画总时长就得到应该 seek 到的时间点。向右滑让时间前进向左滑让时间倒退两端用Math.max和Math.min限住动画不会跑出循环范围。第一次拖动时可能感觉方向是反的我一般按照“手指往左拖动内容也应该向左走”的口味去调整符号也就是手指往左deltaX 为负seek 时间减少内容显示的是更靠前的画面和直觉一致。拖拽模式下还要防一个问题拖动结束立刻play()可能用户手一抖正在拖动画瞬间被带走。稳妥做法是pointerup后的一小段时间内继续暂停比如 150ms 延迟后再播或者判断离起点距离过小就恢复否则保持暂停。这个细节不做的话产品上线后会有人反馈“我明明在拖它忽然自己跑了”。5.3 按钮翻页与跳段无限循环的走马灯其实不太需要“上一页下一页”按钮但如果你想用按钮引导用户切到某一条公告几个方案如果要精确跳到某一条公告用scrollWidth算出这条 item 相对 D 的偏移再seek()到对应时间如果只是想让用户快速“加快”或“放慢”可以用marqueeAnim.speed调节anime.js 是支持的默认1设成0.5就是一半速度如果想暂停在指定条目标上用 seek 之后不播放点一下这条公告的展开交互。我在实际产品里做过“跳转到下一条”的按钮思路是维护一个currentIndex目标时间 前 n 条 item 的宽度之和 / D × 总时长然后seek过去。因为轨道有两份内容下一条按钮可能在循环边界处跨到第二份的对应位置视觉上也不跳曲线是平的。5.4 点击穿透与遮罩交互一多遇到一个新问题用户按住拖拽松手瞬间可能触发 item 上的a点击。我处理的办法是在视口上盖一层“事件捕获层”把 pointer 事件统一处理把真正的链接点击交给一个小型手势判断div classmarquee-viewport div classmarquee-catch/div div classmarquee-track.../div /div.marquee-catch { position: absolute; inset: 0; z-index: 2; background: transparent; }逻辑pointer 按下的时候记录坐标pointerup 时如果位移小于几个像素而且按下到松开的时间很短就认为是点击此时捕获层不拦截让事件穿透到 item 上的a如果位移较大判定为拖拽捕获层阻止默认行为。这个方案的附加好处是捕获层还能承担鼠标悬停暂停事件的监听避免 item 上的链接触发 hover 闪烁。5.5 两端渐隐遮罩让走马灯从“滚动字幕”变成“氛围效果”最后再补一个视觉上的点睛效果左右两侧加渐隐遮罩。用 CSS 的mask或者两个绝对定位渐变层都行.marquee-viewport::before, .marquee-viewport::after { content: ; position: absolute; top: 0; bottom: 0; width: 80px; pointer-events: none; z-index: 3; } .marquee-viewport::before { left: 0; background: linear-gradient(to right, #fff, transparent); } .marquee-viewport::after { right: 0; background: linear-gradient(to left, #fff, transparent); }这个遮罩段的背景色要和页面保持一致这样才能产生“融进背景”的错觉。如果遮罩层盖住了链接点击记得给遮罩加pointer-events: none我上面代码里已经写了。这里最容易被忽略的是遮罩是静态的不是跟随轨道滚动所以它永远不会露出内容边缘只要轨道在滚动两边看起来就是淡入淡出这种廉价氛围感非常值得加。最后再分享一个小经验如果走马灯需要做多套不一样的循环比如一个纯文字公告、一个图片 banner 轮播、一个品牌关键词滚动不要把所有配置都塞进同一个函数。我用的是一个简单的工厂模式function createMarquee(viewport, options {}) { return { speed: options.speed || 1, anim: null, init() { ... }, destroy() { ... }, }; }destroy()里把监听事件、动画实例、克隆节点都清掉组件生命周期完整页面切换路由或者弹层关闭时直接调用就不会出现“页面已经切走transform 还在后台动”的怪现象。这个走马灯算法的核心密度其实不高难点都在边界处理和状态重建上把这两块想清楚anime.js 的代码量反而只是表层。