微信小程序垂直滚动消息:CSS、scroll-view与swiper方案对比

发布时间:2026/10/31 19:30:34
微信小程序垂直滚动消息:CSS、scroll-view与swiper方案对比

1. 垂直滚动消息为什么总在小程序里翻车

先说我遇到过的真实场景。一个做本地生活服务的小程序,需要在首页放一条通知栏,滚动展示"今日有新店上线""优惠券还剩最后两小时""配送范围调整"这类消息。产品经理的原话是:"就一个往上滚的列表,随便写写。"结果第一版上线后,安卓机上还行,iOS 上一滑动就掉帧,消息滚到一半会卡住不动,用户往下拉页面时还会跟滚动动画打架。后来返工三次才稳定下来。

这个功能的正式名字叫垂直滚动消息,也有人叫它通知轮播、通告栏滚动、纵向跑马灯。它和常见的横向轮播图是两个东西:横向轮播解决的是"多张图轮流看",垂直滚动解决的是"多条文字消息轮流看"。看起来更简单,实际上坑更多,因为它是持续运动的——页面在静止状态下它也在动,页面滚动时它还要动,页面切后台再回来它还得接着动。

这篇文章针对的就是这个具体问题:在一个微信小程序里,实现一条垂直方向、逐条上滚、首尾相接无限循环的消息条或者广告位。我会从方案选型开始讲,把三套主流实现路径分别拆开,重点讲清楚为什么大多数人的第一版会卡顿、会跳帧、会内存泄漏,以及怎么用最少的改动把它们修掉。适合已经写过小程序基础页面、想把这个小组件做扎实的人看,也适合被这个需求折磨过一轮、想找标准答案的人参考。

先说结论,避免有人看完才发现方向不对:短消息列表优先用 CSS 动画 + 定时切换,长列表或需要在滚动中响应点击的用 scroll-view 配合动画,追求丝滑无缝的用 swiper 纵向模式。这三套方案没有绝对优劣,取决于你的消息条数、消息高度是否固定、是否需要点击跳转、是否需要手势干预。后面每一节我都会给出对应的取舍依据和实测数据,你可以对照自己的场景直接抄。

1.1 三套方案的成本与适用边界对比

在动手之前,我建议先把这张表看一遍。很多人一上来就抄网上的scroll-view滚动代码,抄完发现消息只有三条,滚到第二条就空了,然后开始加setInterval反复塞数据,越写越乱。选型错位是这类组件最常见的浪费。

方案核心机制适用消息条数消息高度要求点击响应实现复杂度实测流畅度
CSS 动画 + 定时切换transform: translateY配合animation,定时器切换内容2 到 8 条必须固定高度简单直接低高,iOS 上接近 60 帧
scroll-view + 滚动动画原生滚动容器,scroll-top递增或循环加节点8 条以上可不等高需额外处理中中,长列表偶有掉帧
swiper 纵向模式原生轮播组件,vertical属性开启不限固定高度原生支持低高,最省心

这张表里有个容易被忽略的维度:消息高度是否固定。如果你的消息来自后台配置,有的两行有的三行,那 CSS 动画方案会直接崩掉,因为translateY的位移量必须精确等于单条高度,高度一变位移就错位,缝隙和重叠全都出来了。这是新手第一坑,我后面会专门用一节讲死。

1.2 为什么"无限轮播"是个伪需求陷阱

产品文档里写"无限轮播"的时候,通常没想清楚"无限"到底指什么。我总结下来有三种理解,对应的实现难度差出十倍:

第一种是视觉无缝——用户看不出播到头了,滚动过程中没有空白、没有回弹、没有停顿。这个必须做,而且不难。

第二种是数据无限——消息永远播不完,后台有几百条。这个其实不需要真的无限,用户不会盯着看超过二十条,做够三十条循环就够了。

第三种是时间无限——页面开着就一直滚,几小时后还能滚。这个是真正的坑,涉及定时器管理、页面生命周期、内存回收。

绝大多数需求真实需要的只是第一种加第三种,第二种是产品经理的想象。我后来跟需求方对齐的方式很简单:问一句"你希望用户看多久"。答案通常是十几秒,那就够了。把无限的执念放掉,代码量能减掉一半。

2. CSS 动画方案:两条消息做无缝上滚的正确姿势

消息条数少、高度固定的场景,我一直首选纯 CSS 方案。它不依赖任何原生组件,不受scroll-view各种奇怪的回弹和惯性影响,性能也最好——因为动画跑在合成层上,不触发重排。代价是逻辑得自己写,边界情况得自己兜。

核心思路就一句话:容器只露出一条的高度,内部所有消息纵向排列,整组内容向上位移一整条的高度,位移完成的瞬间把第一条消息移到末尾,位移归零。这样用户看到的永远是"上滚一条、新一条从下面进来",而实际操作是"整组内容瞬移回来",因为内容长得一样,用户察觉不到。

2.1 translateY 位移量的精确计算

这里最容易出错的就是位移量。假设单条消息高度是40px,容器高度也是40px,内部有 N 条消息,那整组内容的总高度是N * 40。上滚一条,位移量必须是-40px,不是 -41,不是 -39,更不能写成百分比。

写成translateY(-100%)会怎样?百分比是相对于元素自身高度计算的。内部容器的高度是N * 40,所以 -100% 就是往上移整个内容的高度,直接跳过所有消息,用户看到的是一条都留不住。我第一次写的时候就犯了这个错,心想"一条就是 100% 嘛",结果整块内容瞬间滚没了,还以为是动画没生效。

正确写法有两种,我更推荐第二种:

/* 方式一:写死像素,适合单条高度固定的场景 */ .msg-inner { animation: scrollUp 3s linear infinite; } @keyframes scrollUp { 0% { transform: translateY(0); } 100% { transform: translateY(-40px); } }
/* 方式二:用负 margin 或变量控制,改高度时不用改动画 */ .msg-inner { --item-h: 40px; animation: scrollUp 3s linear infinite; } @keyframes scrollUp { 0% { transform: translateY(0); } 100% { transform: translateY(calc(var(--item-h) * -1)); } }

方式二的好处是,后期产品说"消息高度改成 48px",你只改一个变量就行。如果用的是rpx,注意rpx在动画里也是可以用的,但不同机型换算比例不同,位移量会有亚像素误差,偶尔出现 1px 的缝隙。我的做法是:布局用 rpx,动画位移用 px,在 JS 里通过getSystemInfoSync().windowWidth换算一次,写进内联样式。多写五行程总是值得的。

2.2 用定时器切换内容替代真正的位移动画

CSS 方案还有一种变体,我觉得比纯动画更可控:不做位移动画,而是定时把数组第一个元素移到最后一个,配合一次快速的translateY过渡。这样做的优势是内容切换的时机完全在我手里,想加淡出淡入、想加暂停、想加点击统计都方便。

具体做法是:数据源是一个数组,渲染时只渲染前两条(当前条和下一条)。定时器每隔 3 秒触发一次,先把容器向上位移一条高度,过渡时间 300ms,过渡结束后立刻把数组第一个元素shift()掉再push()回去,同时把位移重置为 0。因为重置发生在过渡结束的瞬间,且内容完全一样,肉眼看不到跳动。

Page({ data: { list: ['消息一', '消息二', '消息三'], offset: 0, animating: false }, onLoad() { this.timer = setInterval(() => this.next(), 3000) }, next() { if (this.data.animating) return this.setData({ animating: true, offset: -40 }) setTimeout(() => { const list = this.data.list.slice() list.push(list.shift()) this.setData({ list, offset: 0, animating: false }) }, 320) // 比过渡时间多 20ms,确保动画真的结束 }, onUnload() { clearInterval(this.timer) } })

这段代码里有三个细节值得单独说。第一,animating锁必须有。定时器是 3000ms,过渡是 300ms,理论上不会撞车,但页面卡顿或者系统调度延迟时,两次切换可能叠在一起,导致数组错乱。加个锁成本极低。第二,重置的延迟要比过渡时间长一点点,我写 320ms,多出来的 20ms 是给渲染留的余量。写 300ms 会偶发重置早于过渡结束,出现一帧的空白。第三,onUnload里必须清定时器,否则用户来回切页面,定时器越积越多,页面会越来越卡——这是小程序里最隐蔽的性能问题之一,下面单独讲。

2.3 定时器泄漏:小程序最容易被忽视的性能杀手

我排查过一个线上反馈:某用户在小程序里逛了十分钟,手机发烫、页面明显变卡。查下来就是首页那个滚动消息条的定时器没清。用户每次进出首页(比如点进详情再返回),onLoad都会重新执行一次,又新建一个定时器,旧的因为没清还在跑。逛十次就有十个定时器同时setData,而setData是跨线程通信,是最贵的数据操作之一。

处理原则很简单:定时器跟着页面生命周期走。onLoad或onShow里创建,onHide或onUnload里清除。但这里有个容易搞混的地方:onHide要不要清?

我的经验是分场景。如果滚动消息条在首页,用户点进详情页再返回,你肯定希望它继续滚,不希望回来发现消息停在原地。那onHide就不清,只在onUnload清。但这样又有个问题:用户切到微信聊天界面,页面进后台了,定时器还在跑,浪费电。这时候靠的是小程序的机制——页面隐藏超过一定时间后,JS 会挂起,定时器自然停。所以实际影响不大。

真正要防的是同一页面内的重复创建。我现在的写法是加一个标记:

onLoad() { this.startScroll() }, onShow() { if (!this.timer) this.startScroll() }, startScroll() { if (this.timer) return this.timer = setInterval(() => this.next(), 3000) }, onUnload() { clearInterval(this.timer) this.timer = null }

这样无论生命周期函数怎么组合调用,定时器永远只有一个。看起来啰嗦,但线上稳不稳,往往就差这几行。

3. scroll-view 长列表方案:为什么要给滚动做"手动限速"

消息条数一旦超过十条,纯 CSS 方案就开始难受了:DOM 节点多了、transform内容一大、切换时整个列表重新渲染,低端机上明显能感觉到一卡。这时候该换scroll-view。scroll-view是小程序提供的原生滚动容器,滚动由原生层处理,比 JS 驱动流畅得多。

但直接用它做"无限上滚"也有坑。最常见的是把scroll-top设成一个递增的值,定时器每 3 秒加 40,看起来就是往上滚一条。问题在于:scroll-view的scroll-top是绝对值,会一直累加,滚到几万以后精度出问题,而且滚到底部就停住了,根本没有"无限"。

3.1 scroll-top 累加法的三个致命缺陷

我把踩过的雷按严重程度排一下:

缺陷表现根因修法
滚到底部卡死滚到最后一个消息后不动了scroll-top超过最大可滚距离,被容器夹住到达底部前重置到 0
累加值溢出长时间运行后滚动位置错乱scroll-top无限增长,精度丢失定期归零并重排数据
整块跳动重置到 0 时视觉上闪一下归零是瞬间的,用户能看见用相同内容做遮盖或改用动画

第一个和第二个其实是同一个问题的两面。解决办法是在重置之前先补数据:当滚动位置接近底部时,把数组头部的一部分消息再追加到尾部,同时把scroll-top减去一个整数倍的单条高度,让内容和位置同步回退。只要内容长得一样,用户就看不出归零。

scrollToNext() { const itemH = 40 const total = this.data.list.length let top = this.data.scrollTop + itemH // 距离底部不足两条时,做一次内容循环 if (top >= (total - 2) * itemH) { const list = this.data.list.concat(this.data.list.slice(0, 3)) this.setData({ list, scrollTop: this.data.scrollTop }) return } this.setData({ scrollTop: top }) }

这段逻辑的意图是:只追加、不回退。每次都往尾部补,scroll-top就一直增长,列表总高度也在增长,永远滚不到底。代价是list会越来越长,内存持续上涨。所以还得加一道闸:当list超过三十条时,把头部的若干条删掉,同时把scroll-top减去相应的像素。这一步必须和setData在同一次操作里完成,否则会出现"内容变了位置没变"或"位置变了内容没变"的一帧错位。

3.2 用 scroll-into-view 替代 scroll-top 的巧劲

上面那套加减法写起来繁琐,也容易算错。后来我换了思路,用scroll-into-view属性:不给绝对位置,而是给"滚到哪个 id"。这样我只需要维护一个当前索引,滚完了索引加一,滚到底了索引归零,位置的事交给组件自己算。

<scroll-view scroll-y scroll-with-animation scroll-into-view="{{currentId}}" class="msg-scroll" > <view wx:for="{{list}}" wx:key="id" id="msg-{{index}}" class="msg-item"> {{item.text}} </view> </scroll-view>
next() { const idx = (this.data.currentIndex + 1) % this.data.list.length this.setData({ currentIndex: idx, currentId: `msg-${idx}` }) }

这套写法的好处非常明显:不用算像素、不用管高度、不用管总长度。高度不等也能工作,因为在哪滚由组件负责。唯一要注意的是scroll-with-animation必须开,否则是瞬间跳过去,没有滚动感。

但它有个明显的短板:从最后一条回到第一条时,是倒着滚回去的。因为scroll-into-view滚到msg-0是从当前位置向上滚,用户会看到列表哗一下往反方向拉回去,这个体验是硬的。要解决就得做首尾复制:尾部复制前几条、头部复制后几条,滚到复制的节点时瞬间无动画跳到真实节点。这套逻辑能做,但复杂度上来了,代码量是纯 CSS 方案的三倍。

我的判断标准是:消息条数在八条以内、高度固定,别用这套。超过八条且必须用scroll-view的,那首尾复制的成本就得认。

3.3 长列表下的 setData 频率控制

scroll-view方案在长列表场景还有个隐形开销:每次切换都要setData。如果消息有一百条,每次setData都把整个数组传过去,通信数据量很大。小程序的setData有个性能红线——单次传输超过 1MB 或频繁调用,就会明显掉帧。

优化手段有三个,按性价比排序:

  • 只传变化的部分。用this.setData({ 'scrollTop': top })这种路径写法,而不是把整个data对象重构后传。数组追加用list[${i}]的路径形式,能省不少。
  • 减少 DOM 节点数量。只渲染可见区域附近的消息,滚动时动态替换,这在小程序里实现成本较高,一般消息条数在几十条时没必要。
  • 降低切换频率。切换间隔从 3 秒改成 4 秒,用户感知不大,但setData次数少了三分之一。

我实测过一个一百条消息的列表,scroll-view加全量setData,在中端安卓机上切换时有明显的 100ms 级卡顿;改成路径写法后,卡顿基本消失。这个改动的收益比换方案大得多。

4. swiper 纵向模式:被低估的官方答案

聊了两套手写方案,最后说个"偷懒"的办法:微信小程序的swiper组件本身就支持vertical属性,开起来就是纵向轮播。很多人不知道这个,或者觉得"swiper 是给轮播图用的,文字不合适",其实它对文字的适配非常好。

<swiper class="msg-swiper" vertical autoplay circular interval="3000" duration="400" > <swiper-item wx:for="{{list}}" wx:key="index"> <view class="msg-text">{{item}}</view> </swiper-item> </swiper>

就这十几行,基本功能全有了:纵向滚动、自动播放、无限循环(circular)、切换间隔可调、动画时长可调。而且它是原生组件,滚动性能是三者里最好的。

4.1 vertical 与 circular 同时开启时的真实表现

circular是 swiper 的循环属性,开启后从最后一条切到第一条会是平滑的连续滚动,不会倒着拉回去。这一点比scroll-into-view方案强太多,也是我推荐 swiper 的核心理由。

但要注意一个容易忽略的点:circular开启后,swiper 会复制首尾节点。如果你的消息内容里有wx:key用的是索引,循环切换时可能出现渲染错乱。我的做法是给每条消息一个稳定的唯一 id,wx:key用这个 id,绝不用index。这是小程序列表渲染的通用原则,不只是 swiper,但在这里出问题的概率最高。

另外duration别设太小。设成 200ms 时,纵向滚动的位移感很强,文字会糊成一团;400ms 左右比较舒服,跟人的阅读节奏对得上。interval我一般给 3000ms 到 4000ms,短于 2500ms 用户根本读不完一条消息。

4.2 点击事件绑在哪一层才不会失效

需求里几乎一定会有"点消息跳转到对应页面"。在 swiper 里绑点击,新手常犯的错是把bindtap绑在swiper外层,结果拿不到具体是哪一条。

正确做法是绑在swiper-item内层:

<swiper-item wx:for="{{list}}" wx:key="id" >onMsgTap(e) { const idx = e.currentTarget.dataset.index const msg = this.data.list[idx] if (!msg.url) return wx.navigateTo({ url: msg.url }) }

这里有个细节:>.msg-wrap { position: relative; overflow: hidden; } .msg-wrap::before, .msg-wrap::after { content: ''; position: absolute; left: 0; right: 0; height: 12px; z-index: 2; pointer-events: none; } .msg-wrap::before { top: 0; background: linear-gradient(to bottom, #fff, rgba(255,255,255,0)); } .msg-wrap::after { bottom: 0; background: linear-gradient(to top, #fff, rgba(255,255,255,0)); }

注意:这里的#fff必须和容器实际背景色一致。如果容器是浅灰底或者放在卡片上,遮罩色也得跟着改,否则会出现一条白边。这个细节我在评审时经常看到漏改。

第二个细节是消息之间留一点间隙。不留间隙时,上一条刚滚出、下一条紧跟着贴上来,视觉上像一整块文字在移动;留 4px 到 8px 的间隙,能明确传达"这是一条一条的消息"。间隙可以从 padding 里出,不要用 margin,因为 margin 会影响位移量的计算。

5.2 页面切后台再回来时的状态修复

这是个低频但杀伤力大的问题:用户看消息看到一半,切去回微信消息,过几分钟回来,发现滚动停住了,或者更糟——停在两条消息中间,只露出半条。

原因在于小程序进后台后 JS 会挂起,定时器暂停;回到前台时定时器恢复,但transform的动画状态未必同步。CSSanimation方案受影响最大,因为动画是浏览器层跑的,和 JS 层的暂停恢复不同步。

我的处理办法是在onShow里做一次状态归零:

onShow() { if (!this.timer) this.startScroll() // 强制归位,避免停在半条的位置 this.setData({ offset: 0, animating: false }) }

如果是 swiper 方案,麻烦一点,要重新触发 autoplay:

onShow() { this.setData({ autoplay: false }) setTimeout(() => this.setData({ autoplay: true }), 100) }

看起来是土办法,但实测有效,比去追究底层的暂停恢复机制划算得多。这类"重开一次"的手段在移动端开发里是常识,别嫌它丑。

5.3 减少动效对可访问性的照顾

这一条不是技术问题,是产品责任。滚动消息条是持续运动的元素,对某些用户不友好。系统的做法是提供"减少动态效果"的偏好设置,小程序里可以通过wx.getSystemInfoSync()拿到部分信息,但支持有限。我的做法是在消息条上提供一个暂停能力:用户手指按住时暂停滚动,松开恢复。

实现很简单,加两个事件:

<view bindtouchstart="onPause" bindtouchend="onResume" bindtouchcancel="onResume" >
onPause() { clearInterval(this.timer) this.timer = null }, onResume() { if (!this.timer) this.startScroll() }

按住暂停这个交互还有个额外好处:用户想看清某条消息时可以按住,这个细节被用户夸过好几次。成本几乎为零,收益很实在。

6. 三种方案的选择决策树与线上监控

讲完三套实现,最后给一个能直接用的判断流程。我不喜欢"看情况"这种答案,所以把条件量化了。

6.1 按消息条数与高度固定性快速决策

按这个顺序问自己四个问题,第一个答"是"就停:

  1. 消息条数 ≤ 8,且高度固定 →CSS 动画 + 定时切换。代码最少,性能最好,好维护。
  2. 消息条数 > 8,但每条消息都要能点击跳转,且希望首尾无缝 →swiper 纵向模式。官方组件兜底,circular直接给你无缝。
  3. 消息高度不固定(有的两行有的三行),且条数中等 →scroll-view + scroll-into-view。放弃首尾无缝,接受回拉感,换取高度自适应。
  4. 消息是流式更新(后台推送、实时刷新) →scroll-view + 追加模式。这时候核心诉求是"内容不断进来",无缝是次要的。

这张决策树我用了两年,基本没失手过。唯一要提醒的是第一条和第二条的边界:条数是 8 还是 10,其实影响不大,但如果消息里有长文本、需要换行,那就别用 CSS 方案了,直接跳第二条。

6.2 上线后必须盯的两个指标

组件写完不代表结束。这个功能的特殊性在于它一直在动,性能问题不会在开发机上暴露,只会在用户的长尾机型上慢慢显形。我上线后必看两个东西:

第一个是页面的setData频率。开发者工具的 Performance 面板里能看,如果发现每秒有多次setData且不全是这个组件触发的,说明有定时器泄漏。这个指标我一般要求控制在每秒 1 次以内。

第二个是低端机上的帧率。开发者工具可以模拟低端机型,切到"性能"面板看 FPS 曲线。滚动过程中的 FPS 掉到 40 以下,就说明动画层有问题——大概率是位移量带小数、或者box-shadow、filter这类高开销属性参与了动画。把动画属性收敛到只有transform和opacity,是保住帧率的铁律。

我现在的习惯是,这类"一直在动"的组件写完后,一定用真机跑够十分钟,中间穿插切页面、切后台、来回进出。开发机上十分钟看不出问题,真机上十分钟能暴露八成毛病。这个方法笨,但有效,比上线后收用户投诉强太多。