
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}} classmsg-scroll view wx:for{{list}} wx:keyid idmsg-{{index}} classmsg-item {{item.text}} /view /scroll-viewnext() { 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 classmsg-swiper vertical autoplay circular interval3000 duration400 swiper-item wx:for{{list}} wx:keyindex view classmsg-text{{item}}/view /swiper-item /swiper就这十几行基本功能全有了纵向滚动、自动播放、无限循环circular、切换间隔可调、动画时长可调。而且它是原生组件滚动性能是三者里最好的。4.1 vertical 与 circular 同时开启时的真实表现circular是 swiper 的循环属性开启后从最后一条切到第一条会是平滑的连续滚动不会倒着拉回去。这一点比scroll-into-view方案强太多也是我推荐 swiper 的核心理由。但要注意一个容易忽略的点circular开启后swiper 会复制首尾节点。如果你的消息内容里有wx:key用的是索引循环切换时可能出现渲染错乱。我的做法是给每条消息一个稳定的唯一 idwx:key用这个 id绝不用index。这是小程序列表渲染的通用原则不只是 swiper但在这里出问题的概率最高。另外duration别设太小。设成 200ms 时纵向滚动的位移感很强文字会糊成一团400ms 左右比较舒服跟人的阅读节奏对得上。interval我一般给 3000ms 到 4000ms短于 2500ms 用户根本读不完一条消息。4.2 点击事件绑在哪一层才不会失效需求里几乎一定会有点消息跳转到对应页面。在 swiper 里绑点击新手常犯的错是把bindtap绑在swiper外层结果拿不到具体是哪一条。正确做法是绑在swiper-item内层swiper-item wx:for{{list}} wx:keyid >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 方案麻烦一点要重新触发 autoplayonShow() { this.setData({ autoplay: false }) setTimeout(() this.setData({ autoplay: true }), 100) }看起来是土办法但实测有效比去追究底层的暂停恢复机制划算得多。这类重开一次的手段在移动端开发里是常识别嫌它丑。5.3 减少动效对可访问性的照顾这一条不是技术问题是产品责任。滚动消息条是持续运动的元素对某些用户不友好。系统的做法是提供减少动态效果的偏好设置小程序里可以通过wx.getSystemInfoSync()拿到部分信息但支持有限。我的做法是在消息条上提供一个暂停能力用户手指按住时暂停滚动松开恢复。实现很简单加两个事件view bindtouchstartonPause bindtouchendonResume bindtouchcancelonResume onPause() { clearInterval(this.timer) this.timer null }, onResume() { if (!this.timer) this.startScroll() }按住暂停这个交互还有个额外好处用户想看清某条消息时可以按住这个细节被用户夸过好几次。成本几乎为零收益很实在。6. 三种方案的选择决策树与线上监控讲完三套实现最后给一个能直接用的判断流程。我不喜欢看情况这种答案所以把条件量化了。6.1 按消息条数与高度固定性快速决策按这个顺序问自己四个问题第一个答是就停消息条数 ≤ 8且高度固定 →CSS 动画 定时切换。代码最少性能最好好维护。消息条数 8但每条消息都要能点击跳转且希望首尾无缝 →swiper 纵向模式。官方组件兜底circular直接给你无缝。消息高度不固定有的两行有的三行且条数中等 →scroll-view scroll-into-view。放弃首尾无缝接受回拉感换取高度自适应。消息是流式更新后台推送、实时刷新 →scroll-view 追加模式。这时候核心诉求是内容不断进来无缝是次要的。这张决策树我用了两年基本没失手过。唯一要提醒的是第一条和第二条的边界条数是 8 还是 10其实影响不大但如果消息里有长文本、需要换行那就别用 CSS 方案了直接跳第二条。6.2 上线后必须盯的两个指标组件写完不代表结束。这个功能的特殊性在于它一直在动性能问题不会在开发机上暴露只会在用户的长尾机型上慢慢显形。我上线后必看两个东西第一个是页面的setData频率。开发者工具的 Performance 面板里能看如果发现每秒有多次setData且不全是这个组件触发的说明有定时器泄漏。这个指标我一般要求控制在每秒 1 次以内。第二个是低端机上的帧率。开发者工具可以模拟低端机型切到性能面板看 FPS 曲线。滚动过程中的 FPS 掉到 40 以下就说明动画层有问题——大概率是位移量带小数、或者box-shadow、filter这类高开销属性参与了动画。把动画属性收敛到只有transform和opacity是保住帧率的铁律。我现在的习惯是这类一直在动的组件写完后一定用真机跑够十分钟中间穿插切页面、切后台、来回进出。开发机上十分钟看不出问题真机上十分钟能暴露八成毛病。这个方法笨但有效比上线后收用户投诉强太多。