做Vue前端开发这几年,我接触过的项目里几乎都逃不过一个看似不起眼的需求:marquee文字跑马灯效果组件。商城首页的公告栏、后台管理系统的通知条、大屏看板的滚动标语,甚至活动页的长跑马灯口号,处处都要用。这个需求看起来太简单了,简单到很多人第一反应是"网上找个封装好的库,或者干脆用CSS动画糊一版"。但真正落到生产环境里,你会遇到一堆说不清道不明的边界问题:文本被动态替换的时候组件会不会闪、鼠标悬停暂停和触摸滚动兼不兼容、页面切到后台后组件还在空转消耗性能、屏幕阅读器会不会被滚动文本刷屏。这篇文章不打算推荐某个现成库,而是从原理到生产级实现,完整拆解一个Vue跑马灯组件该怎么做,以及我在反复打磨过程中踩过的那些坑。
1. 先想清楚:你要的到底是滚动还是轮播
1.1 跑马灯在真实项目里的常见形态
很多人把跑马灯和轮播图混为一谈,这其实是两种完全不同的交互模型。轮播图是"一屏一屏地切换",每屏之间有停顿,用户有主动切换的预期;而跑马灯是"内容持续地向一个方向流动",重点在于"流动感"和"告知性",用户根本不会也不需要对它做精细操作。我见过最典型的误用场景,是某后台系统的通知列表迭代了三次需求,从"单条滚动公告"慢慢变成了"多卡片轮播",代码从CSS动画一路改到引入完整的轮播库,最后又整体推翻。
在做组件之前,先确认你的业务到底属于哪一类。跑马灯常见的形态有三种:第一种是单行文本无限循环滚动,比如商城顶部公告、登录页的温馨提示;第二种是多条消息按顺序一条接一条滚动,这本质上仍然是单条跑马灯,只是内容源是一个数组,滚完一条换下一条;第三种是图文混排的内容块整体滚动,比如大屏上的指标摘要条,这类需求靠纯文本参数往往撑不起来,往往需要走插槽自定义内容。不同形态对组件设计的诉求差别很大,但核心的地基是同一个:无缝滚动的实现。
1.2 为什么说要自研,但又不能轻视自研
说起来有点矛盾,我通常的建议确实是"别直接下载那种几百KB的跑马灯轮子",原因是市面上很多跑马灯库已经多年没更新,API设计还是jQuery时代的思路,为了兼容度塞进了大量你根本用不到的配置项;但"自己写"不等于"随便写个CSS动画就完事"。真正生产级的自研跑马灯,需要解决文案动态更新、宽度测量时机、动画生命周期管理、无障碍适配这些隐藏需求。
另外必须提一个很多人踩过的雷:HTML里那个早就废弃的<marquee>标签,千万不要用。它在部分浏览器里的表现混乱,不支持标准的无障碍语义,渲染行为也高度依赖浏览器实现,更别说在Vue组件里做受控管理了。用它一时爽,测试提一堆bug回来的时候就是火葬场。正确的姿态是:自己写一个轻量组件,但把它当成一个正经的工程问题来对待。
2. "无缝"这两个字到底是怎么实现的
2.1 位移动画只是第一步
跑马灯的基本动作听起来很直白:给内容加一个从左到右或者从右到左的位移动画。用CSS写的话大概是这样:
@keyframes marquee-move { from { transform: translateX(0); } to { transform: translateX(-100%); } }但如果你真的只给原始内容加一个translateX(-100%),你会看到一个大问题:当内容完全移出容器之后,画面左边就空了,动画会出现一段"空白期",然后又从头弹出来。之所以很多人第一版跑马灯看起来像抽风,就是因为这里没处理好。translateX(-100%)的百分比是相对元素自身宽度的,当translate方向是负值时,内容会一路向左直到完全消失,动画循环回到起点时,视觉上就是"跳回"。
无缝滚动的基础逻辑,是让动画的"终点状态"在视觉上等同于"起点状态"。一个内容在起点时展示的是文本A的第一屏内容,在终点时也必须看起来像一个连续的文本在继续移动。要做到这一点,绕不开一个经典的实现手法:内容复制。
2.2 复制内容,让起点等于终点
假设你的容器宽度是固定的,比如容器宽600px,文本实际宽度是1200px,文本从位置0一直移动到位置-1200px,动画周期结束时,整个文本刚好完全滚出视野。但如果我们在文本后面拼接一份完全相同的副本,整个内容的宽度就变成了2400px,动画从0移动到-1200px时,第一份文本滚出去了,第二份文本恰好顶上来,停留在第一份文本的起始视觉位置,循环往复,用户看到的始终是同一段文字在连续滚动。
这样说可能还有一点抽象,我用一个更直白的例子。想象一条无限长的传送带,上面每隔一段距离贴着相同的标语,传送带向前走,你站在某一节窗口前看,标语永远没有滚完的时候。跑马灯组件里的"传送带"就是那个装了两份内容的滚动轨道,而"窗口"就是overflow: hidden的容器。
在实际代码中,复制内容有两条路线。一条是在模板里直接写两个相同的插槽内容,这些内容用一份VNode渲染两次会产生共享状态的问题,不推荐;更稳的做法是在挂载后用JavaScript把内容节点克隆一份,把它追加到滚动轨道里,同时给克隆节点加上aria-hidden="true",避免屏幕阅读器把同一段内容读两遍。克隆节点不会参与Vue的虚拟DOM管理,本质上就是一个纯粹为了"视觉衔接"存在的元素,这样也把逻辑上的干扰降到最低了。
2.3 宽度测量与动画时长的换算关系
既然动画的位移量取决于内容的实际宽度,那么"多宽"这件事就必须在组件挂载后真实测量,而不是拿字符数量猜。常见的错误是有人用文本的length乘以一个经验字号来估算宽度,只要字体、字号、字间距任何一个变量发生变化,估算就崩了。正确做法是等DOM渲染完成后再取offsetWidth:
const contentWidth = contentRef.value.offsetWidth;这个值就是动画位移量。动画时长的确定也很简单,跑马灯的速度其实应该定义成"每秒位移多少像素",业务方描述需求时说的"快一点""慢一点"太模糊了,定成px/s才能精确控制。时长计算公式为:
duration = contentWidth / speed举个例子:文本宽度800px,希望它以40px/s的速度滚动,那么动画时长就是20秒,线性匀速播放。把这段动画的iterations设为无限,再配合复制内容的双份宽度,视觉上就实现了"永不停歇"的滚动。
补充一个细节:动画位移用的是transform: translateX(),不要用left或margin-left去做逐帧位移。transform走的是合成器线程,不会触发布局和绘制,在高频滚动场景下帧率稳定得多,这也是所有跑马灯性能优化的共识。
3. 组件API设计:不堆参数,但要覆盖变化
3.1 核心配置项拆解
组件能不能被愉快地复用,很大程度上取决于props的设计。这一块我的原则是:参数宁缺毋滥,但一个都不能少。下面是这套Marquee组件最终沉淀出的核心配置项,每项都经过了至少两个项目的检验。
| 配置项 | 类型 | 默认值 | 说明 |
|---|---|---|---|
text | string | '' | 纯文本模式下显示的内容,与插槽二选一 |
speed | number | 40 | 滚动速度,单位px/s |
direction | string | 'left' | 滚动方向,left表示向左滚动,right表示向右 |
pauseOnHover | boolean | true | 鼠标悬停时暂停动画,桌面端体验关键项 |
pauseOnTouch | boolean | true | 触摸按下时暂停,移动端防止误触后跟不上内容 |
reduceMotion | boolean | true | 是否尊重系统"减弱动态效果"偏好 |
ariaLabel | string | '' | 滚动区域的无障碍标签,给屏幕阅读器提供完整文本语义 |
direction的实现只影响动画关键帧里的位移正负号,向左是translateX(-width),向右是translateX(width),注意向右滚动时轨道内容排列顺序也需要镜像处理,否则第一个文本滚完会出现"反向回头"的违和感。
3.2 插槽与文本参数并存的设计
为什么既有text参数又保留插槽?因为业务场景真的分成两种:一种只需要传一段字符串,比如系统名+公告内容,用参数最省事;另一种需要给滚动内容加图标、加颜色、加重点标记,这时候纯文本完全不能满足,所以组件必须留一个slot。
我的处理方式是:组件里保留一个默认插槽,同时维护一个textprop 作为兜底内容。模板里优先渲染插槽内容,如果调用方没传插槽,则渲染text。这个逻辑并不复杂,但它让组件在"轻量文本"和"自定义富内容"之间保持了两个入口,不会因为追求灵活而逼着所有使用方去写插槽。
还有一点容易被忽略:当使用插槽时,克隆节点的渲染也要一并考虑。我用JavaScript克隆DOM,克隆出来的节点天然带着原有的DOM结构,图标和样式都会原样复制,所以这块反而不是问题。
3.3 对外暴露的控制方法
一个生产组件不能只有被动配置,还必须给调用方提供主动控制的入口。我通过defineExpose暴露了三个方法:pause()、resume()、restart()。
defineExpose({ pause, resume, restart });pause和resume对应暂停和继续,restart用于内容源变化后整体重置动画。为什么要暴露而不是让父组件去改props?这是为了覆盖一类真实场景:某大屏项目里,运营希望点击跑马灯中的某个区域时滚动暂停,读完再点击恢复。这期间并不需要销毁重建组件,只需要控制动画播放的暂停态。
4. 从原理到代码:一个可运行的Marquee组件
4.1 模板结构:三层嵌套各司其职
先看模板骨架。我把组件拆成了三层:最外层marquee负责整体约束和事件监听,中间marquee__track是动画轨道,内部只有一份原始内容节点。克隆节点不使用Vue模板生成,而是在组件挂载后由JavaScript操作DOM追加。
<template> <div class="marquee" :class="{ 'marquee--paused': isPaused }" @mouseenter="handleMouseEnter" @mouseleave="handleMouseLeave" @touchstart="handleTouchStart" @touchend="handleTouchEnd" > <div class="marquee__track" ref="trackRef"> <div class="marquee__content" ref="contentRef"> <slot>{{ text }}</slot> </div> </div> </div> </template>外层容器必须设置overflow: hidden,同时为了保证任何方向滚动都不会出现滚动条,scrollbar-width: none最好也加上。还有一个细节:最外层要加will-change: transform,给合成器一个明确的优化提示,跑起来更顺畅。
4.2 逻辑层:测量、克隆、动画、清理
逻辑层我用Vue 3的组合式API来写,整体的执行顺序是:组件挂载完成之后测量内容宽度,再追加克隆节点,最后用Web Animations API创建无限循环动画。选择Web Animations API而不是直接写CSS动画类,是因为JS可以随时拿到动画对象做暂停、恢复和取消,这在动态控制上比切换CSS类名要直接得多。
import { ref, onMounted, onBeforeUnmount, watch } from 'vue'; const props = defineProps({ text: { type: String, default: '' }, speed: { type: Number, default: 40 }, direction: { type: String, default: 'left' }, pauseOnHover: { type: Boolean, default: true }, pauseOnTouch: { type: Boolean, default: true } }); const trackRef = ref(null); const contentRef = ref(null); const isPaused = ref(false); let animation = null; let clonedNode = null; const getDistance = () => { const content = contentRef.value; if (!content) return 0; return direction === 'left' ? -content.offsetWidth : content.offsetWidth; }; const buildAnimation = () => { const track = trackRef.value; const content = contentRef.value; if (!track || !content) return; cleanupAnimation(); const distance = getDistance(); clonedNode = content.cloneNode(true); clonedNode.setAttribute('aria-hidden', 'true'); track.appendChild(clonedNode); const duration = (Math.abs(distance) / props.speed) * 1000; animation = track.animate( [ { transform: 'translateX(0)' }, { transform: `translateX(${distance}px)` } ], { duration, iterations: Infinity, easing: 'linear' } ); }; const pause = () => { animation?.pause(); isPaused.value = true; }; const resume = () => { animation?.play(); isPaused.value = false; }; const restart = () => { buildAnimation(); isPaused.value = false; }; const cleanupAnimation = () => { animation?.cancel(); if (clonedNode && clonedNode.parentNode === trackRef.value) { trackRef.value.removeChild(clonedNode); } clonedNode = null; animation = null; };这段代码里有一个容易踩的时序坑:克隆节点的追加必须严格放在animation创建之前,但同时宽度测量又必须发生在克隆之前。因为如果先克隆,track的宽度会变为原始内容的两倍,再拿到content.offsetWidth固然不会变,但后续追加第二个克隆就会把轨道宽度翻到三倍。我在初版组件里先克隆后测量,结果动画的位移量看起来正常,但换行布局被撑坏,调试了很久才意识到测量时机出了问题。
4.3 在页面里怎么用
组件写好之后,业务侧的调用方式非常简单。纯文本场景传一个text就够:
<MarqueeText text="618大促预热开启,全场满减等你来" :speed="50" direction="left" />需要富内容的时候就写插槽:
<MarqueeText :speed="30"> <span class="notice-item"> <span class="notice-icon">NEW</span> 系统例行维护通知已发布,请关注运维群 </span> </MarqueeText>两个入口互不干扰,也没有额外的样式污染。组件内部默认的字体大小、颜色都尽量继承外部环境,这也是为什么这个组件能直接塞进商城、后台、大屏等风格差异极大的页面的原因。
5. 生产环境才会逼你处理的那些细节
5.1 动态内容切换:改文本不能抖一下
跑马灯内容在真实项目里不会是静态的。某后台的公告栏每5分钟拉取一次新公告,第一版组件直接把text作为响应式依赖,内容一变就重新渲染,结果整个跑马灯"跳"了一下。原因是文本变了之后,旧动画没有取消,新文本又和旧文本临时重叠,视觉上就会产生闪烁。
正确的做法是监听内容的变化,然后主动重建动画。文本变化时先取消旧动画,清掉克隆节点,等DOM更新完成后再重新测量宽度、克隆、播放。这里还有一个细节:要等nextTick拿到最新的offsetWidth,因为Vue的DOM更新是异步的,你监听器执行时新的文本还没渲染到页面上,立刻测量拿到的是旧宽度。
watch( () => props.text, () => { nextTick(() => { restart(); }); } );5.2 页面切后台:别让组件空转
跑马灯是无限动画,页面不可见的时候动画还在每一帧地计算,白白消耗CPU。尤其是在大屏和后台这种长期开着的页面,多几个跑马灯组件叠加,资源浪费非常可观。
我只做了一件事:监听document.visibilitychange,当页面进入后台时暂停动画,回到前台时恢复。这段逻辑还要和鼠标悬停暂停区分开,不能互相覆盖——页面切回来时如果鼠标还停在组件上,应该保持暂停状态。
const handleVisibilityChange = () => { if (document.hidden) { animation?.pause(); } else if (!isHovering) { animation?.play(); } }; document.addEventListener('visibilitychange', handleVisibilityChange);更进一步的优化是用IntersectionObserver监听组件自身是否在视口内,但这个方案的复杂度比visibilitychange高,性能收益也有限,除非你的跑马灯数量真的多到离谱,否则前者足够。
5.3 无障碍、系统动效偏好与移动端体验
接屏幕阅读器的用户很不喜欢跑马灯。一个真正做到生产级的组件不能只把动画做好,还要照顾到这些体验。我的做法是给最外层容器设置一个描述性的aria-label,内容节点保持可读,克隆节点则加上aria-hidden="true";同时在样式里增加了一个视觉隐藏的文本副本,让阅读器用户可以听到完整的公告内容,而不会被滚动的文本动态刷屏。
系统级别的"减弱动态效果"偏好也必须尊重。现在很多操作系统都有这个选项,检测方式是一行代码:
const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;当用户打开这个偏好时,组件应该退化成"静态展示完整内容"的模式,不再滚动,有限的内容流场景下改为多次播放的替代方案。这不是加分项,而是影响可访问性评分的基础项。
移动端体验还有一个细节:跑马灯的滚动区域如果内嵌了连接或者需要点击的按钮,手指按住屏幕时滚动的内容会让用户根本没法稳定点击。我在touchstart时暂停,touchend时恢复,并且加了一个小的延迟,防止手指刚离开动画就开始跑,导致用户点不到想点的内容。这个交互细节在自测时最容易漏掉,但真实用户遇到一次就会觉得"这个页面好难用"。
6. 踩坑复盘:哪些决定让我后来省了很多事
6.1 一个让我改了三次的坑:先克隆再测量
回过头来看,这套组件我花时间最多的地方不是动画本身,而是数据的时序。最典型的就是前面提到的"克隆与测量顺序"问题。第二次改的时候我在想:既然文本宽度在内容变化后会变,那能不能在克隆之后再重新测一次宽度?结果发现track宽度已经变成了双份,测量结果是原有宽度的两倍,动画位移也变成了两倍,文本滚动到一半就会出现明显的空白。第三次我把流程定为"挂载后测量原始内容宽度 -> 追加克隆节点 -> 创建动画",这个顺序才彻底稳定下来。
这个问题的本质并不是代码难度,而是对浏览器布局机制的认知。offsetWidth是同步读取的布局信息,但如果你在其间做了会改变布局的DOM操作(比如追加克隆节点),你的读取结果就受到了影响。跑马灯的很多坑,说到底都是"读取布局的时机"问题。
6.2 关于性能和组件边界的几条心得
复盘这个组件,我最大的心得可以浓缩成几条:第一,动画位移一定要用transform,不要用几何属性逐帧驱动,否则滚动量大时CPU占用会直线飙升;第二,测量类逻辑必须在onMounted和内容更新后的nextTick里做,不要在setup阶段就去读offsetWidth,那时候DOM都还没渲染完;第三,是克隆节点尽量用原生DOM操作,不要试图用Vue再渲染一份同样的插槽,这会让状态和事件处理陷入混乱。
走过这几个项目之后,我越来越觉得像跑马灯这样看起来人畜无害的小组件,恰恰是考验工程细节的试金石。它不复杂,但缝里藏着浏览器渲染机制、可访问性、性能优化、业务交互边界这些东西。下次有人跟我说"跑马灯嘛,十几行CSS搞定",我都建议他把页面切到后台跑十分钟看看。组件的价值恰恰是在这些不起眼的边界里。