我最早做滚动字幕,其实是被一个特别土的运营需求逼的:公告栏要放一段平台通知,文案不长不短,放静态文本怕用户看不到,放弹窗又太打扰。当时查了一圈,发现uniapp里做滚动字幕的姿势五花八门——有人用swiper组件硬翻页,有人用CSS animation硬怼,还有人直接用定时器改偏移量。每种方案都能跑,但真放到多端环境里,坑一个接一个。
这篇文章就是来填坑的。我会从最基础的CSS transform方案讲起,跳到动态时长计算、无缝循环、真机适配这几个绕不开的点,最后给你一个可以直接抄进项目的通用组件。无论你是在做公告栏、跑马灯提示、歌词滚动还是资讯轮播,这套思路都能复用。内容适配小程序、H5和App三端,不需要你懂原生开发,有vue基础就能跟着走。
1. 滚动字幕的需求场景与方案选型心态
先说清楚什么是滚动字幕。它本质上是一段超长文本在有限宽度内循环平移,让用户能完整读完内容。最常见的形态是横向跑马灯,也就是文字从右往左持续移动,到末尾后重新开始。
这个需求在uniapp项目里出现的频率比你想的高很多。首页公告、支付成功页的活动提示、签到页的规则说明、信息流里的热点播报,全是滚动字幕的典型战场。很多开发者第一反应是:这个东西简单,不就是一个带动画的text标签吗?但真正动手后才会发现,难点从来不在动画本身,而在下面三个问题:
- 文案长度不固定,动画时长怎么配?
- 滚动到末尾后如何做到无缝衔接?
- 多端(小程序/H5/App)表现不一致怎么办?
先说方案选型的大方向。滚动字幕主流实现方式有三种:CSS animation、swiper组件、定时器修改偏移量。我直接给你结论:常规场景优先选CSS animation,原因后面会细说;swiper适合整屏切换的轮播,不适合连续平移的单条字幕;定时器方案能做到类似效果,但性能和流畅度在低端安卓机上会被CSS动画按在地上摩擦。
另外要提前给你们打个预防针:uniapp的滚动字幕在小程序端和H5端的实现逻辑是通用的,但真正的分水岭在文本宽度的获取方式上。小程序不允许直接操作DOM,你没法像H5那样挂个ref就能拿到元素宽度。这个核心差异直接决定你后续怎么写代码。
在开始写代码前,我还想纠正一个很多人会踩的坑:千万不要一上来就搜一个现成插件然后往项目里塞。滚动字幕的代码量撑死一百多行,你自己写一遍,后面适配需求变化(比如点击暂停、变速滚动、双行交替)会顺手得多。插件往往是黑盒,改一个样式都可能要翻半天源码。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 核心实现方案:CSS transform动画实现滚动字幕
2.1 为什么不用marquee标签
老一代人写网页可能见过<marquee>这个标签,它确实能实现滚动效果,但早就被标准淘汰了,uniapp里更不可能支持。在uniapp里跑滚动字幕,最靠谱的基础是transform: translateX()配合animation。
CSS动画的优势有三个:
- 流畅度高:transform的平移不会触发页面重排(reflow),性能开销小
- 代码精简:一个@keyframes就能搞定,不需要手动维护定时器
- 可控性强:动画的时长、延迟、循环次数、缓动函数全部可以用CSS属性控制
2.2 最基础的滚动字幕代码
这是最原始的版本,先跑通再说:
html复制<template>
<view class="scroll-container">
<view class="scroll-text" :style="animationStyle">
{{ text }}
</view>
</view>
</template>
<script>
export default {
data() {
return {
text: '这是一段测试文本,用来检验滚动字幕的效果',
animationStyle: ''
};
},
onReady() {
this.animationStyle = `animation: scrollMove 8s linear infinite;`;
}
};
</script>
<style scoped>
.scroll-container {
width: 100%;
overflow: hidden;
white-space: nowrap;
}
.scroll-text {
display: inline-block;
padding-left: 100%;
/* 动画名称、时长、线性运动、无限循环 */
animation: scrollMove 8s linear infinite;
}
@keyframes scrollMove {
0% {
transform: translateX(0);
}
100% {
transform: translateX(-100%);
}
}
</style>
这段代码的思路是:把滚动文本放在一个display: inline-block的容器里,初始padding-left: 100%让文本从容器右侧外部开始,动画播放时把文本向左平移自身的100%宽度——也就是刚好把整段文本移出左侧。
需要注意,translateX(-100%)移动的距离是元素自身宽度的100%,比如文本宽度是600rpx,它就会向左移600rpx。这个逻辑配合padding-left: 100%,就能形成"从右边进、从左边出"的效果。
但是,如果你直接复制这段代码跑起来,会发现滚动到末尾后会出现一段空窗期——文本完全消失,要等很久才从右边重新出现。这是因为你在padding-left: 100%的基础上又走了文本自身的宽度,中间有一段"空白平移"的时间。这个问题我们放到下一节解决。
2.3 关键参数:动画时长与文本长度的匹配
上面代码里的8s是写死的,真正项目里不能让用户觉得字幕走得忽快忽慢。这里有个简单的换算关系:动画时长 = 文本宽度 / 期望速度。
如果文本短、速度固定,但动画时长也写死,就会产生两种体验:
- 时长太短,文本滚动太快,用户没看清内容就没了。
- 时长太长,文本滚动太慢,用户等得不耐烦。
所以一般在封装组件时,会把duration作为props传进来,由业务方决定。但更智能的做法是提供一个"速度"参数,比如每秒滚动多少像素,然后根据文本宽度动态计算时长。
下一节我来讲怎么动态计算时长,这也是这个组件最核心的地方。
3. 动态计算动画时长与无缝循环的实现
3.1 获取文本实际宽度的姿势
动画时长要靠文本宽度算,那第一步就是拿到文本渲染后的宽度。这在H5端很简单,挂个ref用offsetWidth就行。但小程序端有他自己的一套API,叫uni.createSelectorQuery()。
看代码:
javascript复制getTextWidth() {
return new Promise((resolve) => {
const query = uni.createSelectorQuery().in(this);
query.select('.scroll-text').boundingClientRect((rect) => {
resolve(rect ? rect.width : 0);
}).exec();
});
}
uni.createSelectorQuery()可以在小程序端获取节点信息,.in(this)是指定在当前组件的范围内查找,避免和其他页面元素重名冲突。这个API在H5端也兼容,所以可以一套代码跑三端。
获取到宽度后,动画时长就能动态算了:
javascript复制async startScroll() {
const textWidth = await this.getTextWidth();
const containerWidth = await this.getContainerWidth();
// 只在文本比容器宽的时候才滚动
if (textWidth > containerWidth) {
const duration = Math.round(textWidth / this.speed); // speed 单位:px/s
this.duration = duration;
}
}
核心判断:如果文本宽度小于容器宽度,直接不滚动。这个逻辑很关键,因为实际项目里文案经常会被运营改短,短文本静止展示比强行滚动舒服得多。
3.2 无缝循环的两种方案对比
无缝循环是滚动字幕最容易翻车的地方。市面上主要两种做法:
方案A:复制一份文本
把同样的文本渲染两份,并排放在一起,动画时让整体向左平移一半宽度,然后循环。因为两份文本长得一模一样,所以滚动完第一份时第二份已经接上了,视觉上没有断层。
关键代码长这样:
html复制<view class="scroll-row">
<view class="scroll-item" v-for="(item, index) in list" :key="index">{{ item }}</view>
</view>
javascript复制this.list = [this.text, this.text];
css复制.scroll-row {
display: flex;
width: max-content;
animation: scrollMove var(--duration) linear infinite;
}
@keyframes scrollMove {
0% {
transform: translateX(0);
}
100% {
transform: translateX(-50%);
}
}
这个方案的原理是:整个scroll-row是一行,里面包含两份相同的文本。动画只移动总宽度的一半(也就是一份文本的宽度),当第一份移出视野时,第二份恰好出现在同样的位置。因为是无限循环,视觉上就无缝了。
方案B:只渲染一份文本,用padding和百分比循环
就是我在2.2节写的那个思路,但需要更精细的配比。比如初始padding-left: 100%,动画把文本移出后,通过重置动画或让padding起作用来循环。但这个方案难以做到真正无缝,因为"文本自身宽度"和"容器宽度"的比例没法保证闭环,所以我不推荐,这里不继续展开了。
所以最终我选择方案A,实测下来是所有方案里代码最少、效果最稳定的。
3.3 完整的动态无缝滚动组件代码
下面给出一个可以直接用的基础版组件:
html复制<template>
<view class="scroll-container" :style="{ width: containerWidth + 'px' }">
<view
class="scroll-row"
v-if="list.length"
:style="rowStyle"
>
<view class="scroll-item" v-for="(item, index) in list" :key="index">
{{ item }}
</view>
</view>
</view>
</template>
<script>
export default {
name: 'ScrollMarquee',
props: {
text: {
type: String,
default: ''
},
speed: {
type: Number,
default: 40 // 滚动速度:每秒多少像素
}
},
data() {
return {
containerWidth: 0,
textWidth: 0,
duration: 0,
isScrolling: false,
list: []
};
},
computed: {
rowStyle() {
return {
animationDuration: this.duration + 's',
animationPlayState: this.isScrolling ? 'running' : 'paused',
width: this.textWidth * 2 + 'px'
};
}
},
watch: {
text: {
immediate: true,
handler() {
this.init();
}
}
},
methods: {
init() {
if (!this.text) return;
this.list = [this.text, this.text];
// 等DOM渲染完成后再测量
this.$nextTick(() => {
this.measureTextWidth();
});
},
measureTextWidth() {
const query = uni.createSelectorQuery().in(this);
query.select('.scroll-item').boundingClientRect((rect) => {
if (rect) {
this.textWidth = rect.width;
this.duration = Math.round(this.textWidth / this.speed);
this.isScrolling = true;
}
}).exec();
}
}
};
</script>
<style scoped>
.scroll-container {
overflow: hidden;
white-space: nowrap;
}
.scroll-row {
display: flex;
flex-wrap: nowrap;
animation: scrollMove linear infinite;
}
.scroll-item {
flex-shrink: 0;
padding-right: 50px; /* 两段文本之间的间隔,防止首尾黏连 */
}
@keyframes scrollMove {
0% {
transform: translateX(0);
}
100% {
transform: translateX(-50%);
}
}
</style>
这个组件有几个细节值得说明:
scroll-row的宽度直接设为textWidth * 2,并且flex-shrink: 0保证两份文本不会压缩变形。- 两份文本之间加了
padding-right: 50px,这样第一份滚完时,第二份离右边还有50px,不会出现首尾紧贴的突兀感。这个值你可以按设计稿调。 - 动画
translateX(-50%)移动的是整个scroll-row宽度的一半,也就是一份文本+一个间隔的距离,这样每次循环的起点都精确对齐。 isScrolling字段用来控制动画暂停/运行,后面做点击暂停时会用到。
4. 封装通用组件:从单行字幕到多态展示
4.1 组件API设计
既然是通用组件,就得考虑不同业务方的使用方式。根据我过手的项目经验,滚动字幕的需求通常跑不出下面几种形态:
| 场景 | 需求描述 | 关键参数 |
|---|---|---|
| 首页公告 | 单条消息横向滚动 | text, speed |
| 证券/行情提示 | 多条消息依次滚动 | list, interval |
| 歌词类 | 文本随进度滚动 | text, progress |
| 自动轮播公告 | 多条消息纵向翻页 | list, interval, direction |
我建议组件至少支持这些props:
text:单条文本内容list:多条文本数组(一旦传入list,就切换到多条模式)speed:滚动速度,单位px/sdirection:滚动方向,默认left,可配right/upduration:动画时长,传入时优先使用,不传则根据speed自动计算paused:是否暂停动画,父组件可控制
注意,direction为up时,就是纵向滚动,动画keyframes要改成translateY(-50%),文本排列方式改成纵向flex排列。代码逻辑完全类似,只是方向换了。
4.2 多条消息的展示逻辑
多条消息的滚动,常见的交互是:每间隔几秒切换一条,新消息从右侧滑入。这个需求用CSS动画也能做,但会复杂一些。我的建议是,多条轮播用swiper组件的vertical模式配合autoplay,比硬用CSS写要稳得多。
swiper的每条swiper-item里放一条静态文本,视觉上就是公告轮播的效果:
html复制<swiper
class="notice-swiper"
vertical
circular
:autoplay="true"
:interval="3000"
:duration="500"
>
<swiper-item v-for="(item, index) in list" :key="index">
<view class="notice-item">{{ item }}</view>
</swiper-item>
</swiper>
这种方案不用关心文本宽度,也不用算动画时长,非常适合多条公告的竖向交替展示。
但如果是多条消息连续横向滚动(第一条滚完接第二条),那就不能用swiper了,要把多条消息拼成字符串后当作一条文本处理。这里的关键是拼接时需要加分隔符,比如"【】"或"——",视觉上才能区分开。
4.3 点击事件与交互扩展
运营经常提的需求是:点公告跳转详情页。所以组件必须支持点击事件。
html复制<view class="scroll-container" @click="handleClick">
<!-- 滚动文本区域 -->
</view>
javascript复制handleClick() {
this.$emit('click', this.currentItem);
}
还有一个常见的交互:鼠标悬停/触摸时暂停,离开后继续。这个在小程序端和H5端有差异:
- H5端用
@mouseenter和@mouseleave - 小程序端用
@touchstart和@touchend
统一封装的写法:
html复制<view
@touchstart="pauseScroll"
@touchend="resumeScroll"
@mouseenter="pauseScroll"
@mouseleave="resumeScroll"
>
javascript复制pauseScroll() {
this.isScrolling = false;
this.$emit('pause');
},
resumeScroll() {
this.isScrolling = true;
this.$emit('resume');
}
isScrolling绑到animation-play-state上,值为paused时动画冻结在当前位置,值为running时继续动。这个交互在用户需要仔细阅读公告信息时非常有用,长期体验下来能明显减少"字还没看完就滚走了"的投诉。
4.4 动态数据刷新时的注意事项
如果通过接口拉取公告,每隔一段时间文案会变化。这时组件需要监听text的变化,重新测量宽度、重置动画。
这里最常犯的错是:只更新了文本,没有重置动画。结果文本内容变了,动画还是在按旧的时长和位置滚动,看起来非常割裂。
正确做法:
javascript复制watch: {
text: {
handler(newVal, oldVal) {
if (newVal !== oldVal) {
this.resetScroll();
}
}
}
},
methods: {
resetScroll() {
this.isScrolling = false;
this.list = [this.text, this.text];
this.$nextTick(() => {
this.measureTextWidth();
});
}
}
如果你希望文本切换时有一个过渡效果,可以先把动画暂停,等新文本渲染好后再重新启动。实测下来,this.$nextTick可以保证DOM更新完成后再去测量,这个时序问题一定不能省。
5. 真机测试与踩坑记录:多端适配的五个关键问题
滚动字幕这种UI组件,最怕的不是逻辑写错,而是写的时候没毛病、一端到另一端就翻车。下面几个坑是我实际一个个踩出来的,每一个都有血泪教训。
5.1 小程序端拿不到节点宽度的时序问题
小程序端createSelectorQuery拿节点宽度的时机非常微妙。如果你在onReady里立刻调用,很可能拿到的是0,因为此时节点还没完成渲染。
我的经验是:
- 在
onReady里调用时,先await nextTick(),再查询。 - 如果数据是异步加载的,等
text赋值后再查询,不要在created里查询。
5.2 rpx和px的换算陷阱
如果你在样式中写了padding-left: 100rpx,但JS里用boundingClientRect拿到的是px值,这两者之间有个换算关系。在不同屏宽下,rpx转px的结果不一样(设计稿按750rpx算)。
所以凡是参与动画距离计算的数值,要么统一用px,要么统一用rpx,千万不要混用。我建议在measureTextWidth里把拿到的px值统一存起来,动画时长和位移量都用它计算,避免换算误差导致两端文本错位。
5.3 H5端white-space: nowrap失效
H5端偶尔会出现文本换行、滚动区域高度塌陷的问题。原因通常是view标签默认的white-space样式被全局样式覆盖了。解决方法是给.scroll-container和.scroll-item同时加white-space: nowrap;,并且在scroll-row上加上display: flex; flex-wrap: nowrap;双保险。
5.4 低端安卓机的动画闪烁
部分安卓机在CSS动画播放时会出现文字闪烁或锯齿。这个问题的根因是transform动画在部分WebView上没有走GPU加速。解决办法:
css复制.scroll-row {
will-change: transform;
/* 某些安卓机型需要加这个,强制GPU合成层 */
transform: translateZ(0);
}
will-change: transform提示浏览器提前优化,translateZ(0)是强制开一个合成层的老办法。实测在部分千元机上能明显减少闪烁。但是不要滥用,开太多合成层会吃内存。
5.5 App端与小程序端动画时长的差异
同一段代码,在App端(特别是用nvue时)的渲染机制不同,动画时长可能需要微调。如果你发现App端滚动速度明显比小程序端快或慢,可以检查一下App端是不是走了uni-app x或者renderjs的渲染路径。
如果你用的是vue页面而不是nvue,CSS动画在App端的表现和小程序端基本一致,一般不需要特殊处理。但nvue页面里animation的兼容性差一些,建议用vue页面做滚动字幕组件,需要高性能时再单独考虑原生方案。
6. 横向对比:swiper、定时器、CSS动画到底怎么选
最后做一个系统的方案对比,方便你遇到具体场景时快速拍板。
| 实现方案 | 适用场景 | 优点 | 缺点 |
|---|---|---|---|
| CSS animation | 单条/双条横向滚动 | 性能好、代码少、流畅 | 无法直观控制每一帧位置 |
| swiper组件 | 多条消息竖向轮播 | 自带切换动画和自动播放 | 不适合连续平移字幕 |
| 定时器+偏移量 | 需要精确控制位置或做拖拽 | 位置可控、交互灵活 | 性能差,JS频繁操作样式 |
| canvas绘制 | 特效字幕(描边、阴影) | 视觉酷炫、渲染稳定 | 开发成本高、文本测量繁琐 |
以我这几年的使用体感来说:
- 90%的横向滚动字幕,CSS动画方案都能搞定。
- 需要多条消息纵向交替时,swiper是更省事的选择。
- 只有在动画里需要实时响应拖拽、手动控制进度这类场景,才考虑定时器方案。
- 如果是在App端做那种带渐变、阴影的炫酷字幕,canvas确实能做出CSS做不到的效果,但成本高,常规项目没必要。
7. 从滚动字幕到通用动画组件:节奏控制的进阶心得
组件能跑只是第一步,做得"顺眼"才是加分项。这里分享几个能直接提升观感的小技巧。
7.1 动画时长与视觉舒适度
滚动速度不是越快越好,也不是越慢越好。根据我的实测,横向滚动字幕的舒适速度大概在每秒30-50px之间。低于30px用户会等得无聊,高于60px用户会看不清文字。
如果拿不准,可以做个简单的分段:
| 使用场景 | 推荐速度 |
|---|---|
| 公告栏(文字不长) | 30-40px/s |
| 跑马灯提示(紧急通知) | 50-60px/s |
| 歌词/字幕 | 20-30px/s |
7.2 动画缓动函数的选择
很多人写滚动字幕喜欢加ease-in-out缓动,觉得视觉上更舒服。但如果你的字幕是持续循环的,缓动函数反而会让节奏变得奇怪——每轮回合开始时加速、结束时减速,看起来像"一抽一抽"的。
所以无缝循环的滚动字幕一定要用linear线性运动。只有单次入场动画(比如弹窗里的提示滚入)才适合用ease-out。
7.3 减少渲染负担的细节
滚动字幕区域只在可见时才需要渲染。如果你的页面里同时有好几个滚动字幕,建议在页面onHide时把动画暂停,onShow时再恢复。这个小优化能显著降低多字幕页面的CPU占用,尤其是在App端。
7.4 无障碍与文本截断的妥协
滚动字幕本质上是"移动的文本",对阅读能力弱的用户并不友好。如果产品允许,给每条滚动公告配一个可点击展开的静态版本,会比强制滚动体验好得多。另外,如果文本包含价格、数字、日期这类关键信息,滚动速度一定要放慢,否则用户截图都来不及。
我遇到过最离谱的需求是运营把一串手机号放进滚动公告里,这个场景下滚动速度再快也不会有人看清。建议组件里加一个disabled开关,某些特殊内容直接静态展示。
8. 完整版通用组件源码与调用示例
把前面所有知识点揉到一起,整理成一个功能完整的ScrollMarquee.vue组件。这个组件支持横向滚动、点击暂停/恢复、动态更新文案、自动计算时长,适配三端。
html复制<template>
<view
class="sm-container"
@touchstart="pauseScroll"
@touchend="resumeScroll"
@mouseenter="pauseScroll"
@mouseleave="resumeScroll"
@click="handleClick"
>
<view class="sm-row" :style="rowStyle">
<view
class="sm-item"
v-for="(item, index) in renderList"
:key="index"
>{{ item }}</view>
</view>
</view>
</template>
<script>
export default {
name: 'ScrollMarquee',
props: {
text: {
type: String,
default: ''
},
speed: {
type: Number,
default: 40
},
duration: {
type: Number,
default: 0
},
direction: {
type: String,
default: 'left' // left / right / up
},
disabled: {
type: Boolean,
default: false
}
},
data() {
return {
renderList: [],
textWidth: 0,
containerWidth: 0,
animateduration: 8,
isPaused: false
};
},
computed: {
rowStyle() {
const duration = this.duration > 0 ? this.duration : this.animateduration;
const directionKey =
this.direction === 'up'
? 'translateY'
: this.direction === 'right'
? 'translateX'
: 'translateX';
const distance = this.direction === 'up' ? '-50%' : '-50%';
return {
animationDuration: duration + 's',
animationPlayState: this.isPaused ? 'paused' : 'running',
animationDirection: this.direction === 'right' ? 'reverse' : 'normal',
transform: `${directionKey}(0)`
};
}
},
watch: {
text: {
immediate: true,
handler() {
this.init();
}
}
},
methods: {
init() {
if (!this.text) return;
this.renderList = [this.text, this.text];
if (this.disabled) return;
this.$nextTick(() => {
this.measureWidth();
});
},
measureWidth() {
const query = uni.createSelectorQuery().in(this);
query.select('.sm-item').boundingClientRect((rect) => {
if (rect) {
const itemWidth = rect.width;
const containerQuery = uni.createSelectorQuery().in(this);
containerQuery.select('.sm-container').boundingClientRect((containerRect) => {
this.containerWidth = containerRect ? containerRect.width : 0;
if (itemWidth > this.containerWidth) {
this.textWidth = itemWidth;
if (!this.duration) {
this.animateduration = Math.round(itemWidth / this.speed);
}
} else {
this.animateduration = 0;
}
}).exec();
}
}).exec();
},
pauseScroll() {
this.isPaused = true;
},
resumeScroll() {
this.isPaused = false;
},
handleClick() {
this.$emit('click');
}
}
};
</script>
<style scoped>
.sm-container {
width: 100%;
overflow: hidden;
white-space: nowrap;
}
.sm-row {
display: flex;
flex-wrap: nowrap;
width: max-content;
animation-name: sm-scroll;
animation-timing-function: linear;
animation-iteration-count: infinite;
}
.sm-item {
flex-shrink: 0;
padding-right: 50px;
white-space: nowrap;
}
@keyframes sm-scroll {
0% {
transform: translateX(0);
}
100% {
transform: translateX(-50%);
}
}
</style>
页面里调用:
html复制<template>
<view>
<ScrollMarquee
:text="noticeText"
:speed="40"
@click="goDetail"
/>
</view>
</template>
<script>
import ScrollMarquee from '@/components/ScrollMarquee.vue';
export default {
components: { ScrollMarquee },
data() {
return {
noticeText: '平台公告:本周五凌晨2点至6点系统升级,期间暂停服务,请提前安排相关操作。'
};
},
methods: {
goDetail() {
uni.showToast({ title: '点击公告', icon: 'none' });
}
}
};
</script>
这个组件虽然基础,但已经能覆盖绝大多数业务需求了。剩下要扩展的比如多行滚动、变速滚动、纵向滚动,套路都是一样的:改flex方向、换keyframes方向、调动画时长。
最后说一个我在多个项目里反复测试得到的体会:滚动字幕这个需求,技术上真的不难,真正耗费精力的是各种边缘情况——文本超短不滚动、动态数据刷新、多端渲染统一、交互暂停恢复。如果你一开始就把这些边界考虑清楚,后面基本不用回头补课。自己封装一个组件,比每次从插件市场拉一个现成的然后被坑得死去活来要省心得多。
