
拿到 UI 稿的时候需求方特意在轮播图那块标了一句淡入淡出不要左右滑动切换。我第一反应是uniapp 的 swiper 组件都封装得这么成熟了改个动画效果应该也就一两行配置的事。结果翻完文档才发现swiper 默认只有位移切换官方压根没有提供 fade 模式想做出透明度渐隐渐现的效果要么硬改样式要么绕开 swiper 自己写。折腾一圈之后我最终选择了后者——一个用 view 层叠加上 CSS transition 实现的淡入淡出轮播组件。代码量不大但涉及自动播放、手势滑动、图片加载、生命周期清理这些细节每一个不到位都会在真机上翻车。这篇文章就是完整复盘给正在做 uni-app 轮播又不想引第三方组件的同学一个参考。1. 先搞清楚uniapp 的 swiper 为什么给不了淡入淡出1.1 swiper 的设计模型是位移不是渐变很多人第一次碰到这个问题都会跟我一样先去翻 uniapp 的 swiper 文档看看有没有类似fade、animationType这样的属性。结果文档翻完能用的只有indicator-dots、autoplay、interval、circular这些常规项它们控制的都是轮播的行为节奏而不是动画形式。原因在于 swiper 的底层模型是位移。拿 H5 端举例swiper 内部把所有轮播项横向排列在一根轴上通过修改容器的transform: translateX()来移动可视窗口微信小程序端虽然是原生组件但交互模型同样是滑动容器到指定位置。这套模型天生服务于左右滑动切换的交互位移和手势滑动是绑定在一起的。淡入淡出则是另一种动画模型它要求所有轮播项始终叠在同一位置通过调整opacity从 0 到 1 实现切换。这两者的坐标体系完全不同位移切换关心的是当前这一帧容器在哪个偏移量淡入淡出关心的是当前哪一张透明度和层级需要变化。想在位移模型里插入渐变逻辑属于两套动画体系打架。1.2 硬改 swiper 会遇到的连锁问题我在网上看到过不少绕过方案比如把 swiper 设为vertical纵向模式然后给每个 swiper-item 加 CSS 过渡模拟淡入淡出或者在外面套一层遮罩用遮罩的透明度变化来掩盖底下的滑动过程。真去试一遍就会发现这些方案在 demo 里能用一到实际需求就露馅。最典型的问题是索引和视觉不同步。swiper 内部有一套自己的索引维护逻辑它认为当前在第几张取决于容器滑动到了哪里。如果你用 CSS 强行改变透明度让视觉上看起来像第三张淡入但 swiper 内部的 current 还停留在第二张那么一旦用户手动滑动一下swiper 就会基于错误的索引去计算偏移量画面直接跳飞。第二个问题是 cross-end 差异。uniapp 的 swiper 在微信小程序端是原生组件在 H5 端是编译出来的普通 DOM在 App 端又是 webview 渲染三端的内部实现不一样。你可能在 H5 里用 CSS hack 调通了跑到小程序端一整套样式覆盖失效又要重新适配。这种为了一个动画效果去对抗框架底层设计的事维护成本会持续累积。1.3 什么时候保留 swiper什么时候放弃当然我并不是说 swiper 不能用。如果需求是标准的左右滑动轮播或者需要手势跟手拖拽、快速滑动翻页这种强交互swiper 依然是首选因为它把位移计算、边界处理、动画曲线都封装好了。但如果你明确要的是淡入淡出而且是那种不需要跟手拖拽、只负责定帧切换的展示型轮播继续依赖 swiper 反而束手束脚。判断标准很简单你有没有用到 swiper 的手势滑动能力如果轮播图只是放在首页自动播放、用户点一下指示器切换那它本质上就是一个定时换内容的容器根本不需要 swiper 的位移模型。这时候自己写一个组件把当前显示第几张这个状态完全握在自己手里后续所有逻辑都会变得清爽。2. 方案选型自研组件而不是继续魔改 swiper2.1 四种路线对比在动手之前我把市面上能走通的路子列了一遍大致是四种一种是前面说的继续在 swiper 上做 CSS 覆盖。优点是能用官方组件缺点是跨端风险大、索引错位难排查而且每次 uni-app 版本升级都可能让 hack 失效。第二种是从 uniapp 插件市场找现成的淡入淡出轮播组件。插件市场确实有一些类似实现但下载下来往往带着作者自己的设计稿假设比如图片尺寸写死、指示器样式和项目的 UI 体系对不上、事件命名和你现有的埋点格式不一致。改这些样式和接口的时间足够自己写一个了。第三种是图片预叠方案就是把所有图片按透明度叠放好定时调整透明度。这个思路和我最终选的方案很接近但大多数人只是零散地贴代码没有把自动播放、手滑、生命周期管理一起做进去导致放到项目里总有各种小毛病。第四种就是自研组件用 view 做容器所有轮播项绝对定位层叠通过动态 class 控制当前项的透明度和层级。这一条看起来最原始但恰恰因为它不依赖任何框架的轮播模型天然和淡入淡出动画匹配。2.2 为什么最终选择 view 加 CSS transition我的核心判断是淡入淡出轮播的根本逻辑其实极其简单——所有图片同时渲染在同一位置任何时候只让一张显形。这个显形的操作用 CSS 的opacity和transition就能完美表达完全不需要借助 swiper 这种重型组件。用 view 加 CSS transition 有一个被很多人忽略的好处动画过程完全暴露给你控制。你可以精确知道当前状态是正在淡入第 3 张可以在动画结束后执行自己的回调可以和指示器、埋点事件无缝对接。相比之下swiper 的动画过程是个黑盒你只知道结果不知道中间状态想插入业务逻辑非常别扭。从代码规模看一个支持自动播放、手势滑动、指示器、循环切换的淡入淡出轮播组件核心代码不超过 200 行。这点代码量换来的可控性和跨端一致性比去适配一个第三方插件要划算得多。2.3 一个关键前提本方案面向 vue 页面需要先声明一个边界这套方案依赖 CSStransition适合 uniapp 的 vue 页面也就是 H5 端和 App 端的 vue 页面以及小程序端。但如果你的项目里有 nvue 页面也就是原生渲染的页面方案需要调整。nvue 的渲染引擎是原生视图对 CSS 的支持是一套精简子集transition的支持不如 webview 完整。如果要在 nvue 里做淡入淡出通常得借助animation模块或者 vue 的transition组件配合原生驱动。我这边大部分业务页面是 vue 页面所以本文的实现都是基于 vue 页面讨论。如果你的需求刚好跑在 nvue 上可以把组件里的 CSS transition 部分替换成 animation API整体状态管理思路仍然通用。3. 核心骨架层叠布局和透明度切换究竟怎么写3.1 布局原理所有图片同时渲染但只有一张可见先把最核心的布局思路说清楚。轮播容器是一个固定宽高比的盒子设置position: relative里面每一张轮播图都用position: absolute铺满整个容器因此所有图片在空间上完全重叠。当前显示的图片opacity: 1其余图片opacity: 0同时给opacity加一个transition这样切换显示项的时候透明度会有一个渐变过程视觉效果就是淡入淡出。这里有一个关键点不能用display: none去隐藏非当前图片。display属性不能参与过渡动画一旦你把它从none切成block变化是瞬时的opacity的渐变也就被截断了。所以所有图片必须一直渲染在页面里只是透明度不同。另外图片全部叠放会带来一个交互问题最上层如果是空白或者透明区域点击事件可能穿透到下层图片。这个问题我在第五部分会细讲这里先留个印象。3.2 模板和样式代码组件模板核心部分如下我习惯把轮播单张图片的地址放在banners数组里current表示当前显示第几张。template view classfade-swiper touchstartonTouchStart touchendonTouchEnd view v-for(item, index) in banners :keyitem.id || index classfade-swiper__item :class{ fade-swiper__item--active: index current } image :srcitem.imageUrl modeaspectFill / /view view v-ifshowDots classfade-swiper__dots view v-for(item, index) in banners :keydot- index classfade-swiper__dot :class{ fade-swiper__dot--active: index current } / /view /view /template对应的样式如下。注意我把transition定义在基础类上这样每次切换active类时透明度变化都是平滑的。.fade-swiper { position: relative; width: 100%; height: 360rpx; overflow: hidden; border-radius: 16rpx; background: #f5f5f5; } .fade-swiper__item { position: absolute; top: 0; left: 0; width: 100%; height: 100%; opacity: 0; transition: opacity 0.8s ease; pointer-events: none; } .fade-swiper__item--active { opacity: 1; pointer-events: auto; } .fade-swiper__item image { width: 100%; height: 100%; } .fade-swiper__dots { position: absolute; left: 0; right: 0; bottom: 20rpx; display: flex; justify-content: center; align-items: center; } .fade-swiper__dot { width: 12rpx; height: 12rpx; border-radius: 50%; margin: 0 8rpx; background: rgba(255, 255, 255, 0.6); transition: width 0.3s ease, background 0.3s ease; } .fade-swiper__dot--active { width: 36rpx; border-radius: 8rpx; background: #ffffff; }3.3 索引切换的循环取模控制当前显示哪一张核心是一个go()方法负责计算下一个索引并更新current。methods: { go(target) { const len this.banners.length if (len 0) return this.current ((target % len) len) % len } }可能有同学不理解为什么要写得这么绕。直接target % len不行吗问题在于 JavaScript 的取模运算对负数不友好-1 % 3的结果是-1一旦target是负的current就坏掉了。((target % len) len) % len这个写法可以同时处理正数和负数比如go(-1)在长度为 3 的列表里会得到 2正好是最后一张循环逻辑就通了。目前整个组件已经能实现最基本的淡入淡出切换了那如果把go()手动调用几次确实可以看到效果。但一个轮播组件如果只能手动切还不算完整接下来得把自动播放和手势滑动接进来。4. 自动播放与手势滑动定时器和触摸事件怎么配合4.1 定时器必须在正确的生命周期里启动和清理自动播放的实现非常简单就是一个setInterval每隔固定时间调用一次go()。真正容易出问题的是定时器的启动时机和清理时机。我见过不少同学喜欢在onLoad或者created里直接启动定时器然后就不管了。这样做的一个隐患是小程序页面退出、App 页面切到后台时定时器并不会自动停掉。如果定时器里触发了this.current的更新渲染层可能还在但页面已经不可见了既浪费资源也可能在回退页面时和新页面的状态互相干扰。我的做法是把定时器统一封装成startAutoPlay和stopAutoPlay两个方法并且在 uni-app 页面级生命周期里成对调用onLoad() { this.startAutoPlay() }, onShow() { // 从后台回到前台确保自动播放重新开始 if (this.autoplay) { this.startAutoPlay() } }, onHide() { // 页面被覆盖或切到后台停掉定时器 this.stopAutoPlay() }, onUnload() { this.stopAutoPlay() }, beforeDestroy() { this.stopAutoPlay() }startAutoPlay内部先调用一次stopAutoPlay保证重复调用也不会叠加多个定时器startAutoPlay() { this.stopAutoPlay() this.timer setInterval(() { this.go(this.current 1) }, this.interval) }, stopAutoPlay() { if (this.timer) { clearInterval(this.timer) this.timer null } }这里有个容易被忽视的点onShow在页面初次加载时也会触发所以onLoad里其实可以完全不用管启动逻辑统一交给onShow就好。组件如果也会被多个页面复用一定要在组件自身销毁的时候清理定时器不能用完就扔。4.2 手势识别touchstart 到 touchend 的完整链路自动播放有了之后用户手动滑动切换也得做。原理不复杂在容器上监听touchstart和touchend两个事件记录起始坐标和结束坐标通过位移差判断用户是想看上一张还是下一张。onTouchStart(e) { this.stopAutoPlay() const touch e.touches[0] this.startX touch.clientX this.startY touch.clientY }, onTouchEnd(e) { const touch e.changedTouches[0] const deltaX touch.clientX - this.startX const deltaY touch.clientY - this.startY // 只在水平位移足够大时才切换避免误触 if (Math.abs(deltaX) 30 Math.abs(deltaX) Math.abs(deltaY)) { if (deltaX 0) { // 向右滑显示上一张 this.go(this.current - 1) } else { // 向左滑显示下一张 this.go(this.current 1) } } // 手势结束后重新开启自动播放 this.startAutoPlay() }这里有两个细节值得说明。第一为什么用changedTouches而不是touchestouchend事件触发时手指可能已经离开屏幕某些端上touches列表是空的但changedTouches一定会带上有变化的那根手指。用changedTouches在跨端场景下更安全。第二为什么要同时判断Math.abs(deltaX) 30和Math.abs(deltaX) Math.abs(deltaY)30 是一个经验阈值用来过滤掉那些轻微点按、手指抖了一下的误触。而判断斜向滑动方向是避免用户在纵向滚动页面时横向位移稍微大一点就被误判成轮播切换。如果页面本身是横向滚动容器可以按需调整方向判断。4.3 手势与自动播放的竞态自动播放和手势操作放在一起一定会遇到竞态问题用户刚按下去定时器刚好触发了一次go()画面跳了一张或者用户松手后定时器立刻又跳了一张感觉像跳过了两张。解决思路是分层处理。touchstart发生时立刻stopAutoPlay让定时器在手指接触期间完全停止工作等到touchend手势判断完成后再重新启动。重启时最好加一个短暂延迟我这里习惯延迟 300ms 再开避免松手动作和下一轮定时器贴得太近onTouchEnd(e) { // ...手势判断逻辑 clearTimeout(this.restartTimer) this.restartTimer setTimeout(() { this.startAutoPlay() }, 300) }另外记得在stopAutoPlay里也清掉restartTimer防止用户二次触摸时上一次的延迟启动任务还在排队。至此这个组件已经有了自动播放、手势滑动、循环切换、指示器基本能应付绝大多数页面需求了。但别急着上生产环境真机上一跑各种意想不到的问题才刚开始冒头。5. 真机实测三个隐蔽坑和一个性能优化清单5.1 坑一图片没加载完就切视觉上会闪底第一个坑出现在图片加载和动画切换的配合上。如果轮播图体积比较大网络又慢第一张图还没加载出来定时器就已经把它切到opacity: 1了用户看到的是一个空白的灰色底或者一张只加载了一半的图。图片真正加载完成后画面又会突然闪一下非常掉价。这个问题有几个处理手法。最简单的是给容器设置一个和图片主色调接近的背景色至少避免白底刺眼。做得再细一点可以在图片load事件后再允许切到这张图或者在组件内部维护一个loadedCount计数器首张图没加载完成前直接不启动自动播放。如果图片数量不多我更推荐预加载策略组件创建后先把下一张图的image对象在后台拉一遍。uni-app 的image组件在渲染时本身就会加载所以这个策略通常表现为所有轮播图片同时渲染但透明度为 0对切换顺序基本没有感知代价是首屏会多消耗一点流量和内存。图片数量控制在 4 张以内问题不大。5.2 坑二离开页面后定时器还在跑状态错乱这个坑在开发工具里很难发现因为开发者工具切页面不够真实但在真机上非常明显。页面 A 跳转到页面 B 后A 页面的onHide如果没写或者写了但没真正停止定时器A 页面的轮播还会在后台悄悄切换。表面上没什么影响但一旦 B 页面里也用同一个组件两个页面的定时器同时工作加上页面栈的缓存机制有时回退到 A 页面会发现指示器跳了好几张。解决办法就是严格执行进入页面启动、离开页面关闭的原则。我建议把定时器清理写在onHide和onUnload两个生命周期里beforeDestroy也要带上因为组件可能被v-if动态销毁不一定能走到页面级生命周期。清理方法要保持幂等重复调用不会有副作用这样无论哪条生命周期先触发都不会出问题。5.3 坑三点击事件穿透到了底下那张图第三坑是最典型的看着对用着错。当轮播图里需要放跳转链接时比如第三张图对应一个活动页用户点击第三张图片的按钮结果触发了第二张图或者第一张图的跳转或者干脆点不到。原因前面提过所有轮播项绝对定位叠在一起即使透明度为 0它在文档流里仍然存在仍然会接收点击事件。默认情况下最上层元素的透明区域会漏给下面的元素。解决方式是在基础样式里给非当前项设置pointer-events: none只给当前项开放交互.fade-swiper__item { opacity: 0; pointer-events: none; } .fade-swiper__item--active { opacity: 1; pointer-events: auto; }这样透明项就不会拦截任何点击事件只会命中当前正在显示的那一张。5.4 性能优化清单最后给一个我在真机上整理出来的性能优化清单不一定每条都会用到但踩到相关问题时可以直接对照排查。1. 图片数量控制在 5 张以内过多图片叠放会显著抬高内存占用。 2. 所有图片建议统一尺寸使用 modeaspectFill 避免切换时发生布局抖动。 3. 图片地址尽量用 CDN 且带合适的压缩参数轮播图体积控制在 200KB 以内。 4. 动画时长不要超过 1s一般 600ms 到 800ms 视觉最舒服太长会显得拖沓。 5. 如果在 H5 端遇到动画卡顿可以给 item 加上 transform: translateZ(0)强制走合成层。 6. 非当前项在动画结束后可以进一步设置 visibility: hidden减少不可见图层的合成开销。 7. 自动播放 interval 建议 3000ms 到 5000ms太短会让用户来不及阅读图里的信息。这些优化不是说越多越好要根据页面复杂度和真机性能来取舍。我一般会先在低端安卓机实机上过一遍如果动画帧率明显掉优先砍图片权重和数量而不是急着堆 CSS hack。6. 从能跑到好用把组件提炼成可复用的配置项6.1 做成 props 驱动的通用组件组件在单个页面上跑通以后下一步就是把它从页面里的一堆代码提炼成一个通用组件。我会把和业务无关的东西全部收进 props让页面只需要传数据、配参数就能在不同位置复用。比较合理的 props 设计大致是这样的props: { banners: { type: Array, required: true }, autoplay: { type: Boolean, default: true }, interval: { type: Number, default: 3500 }, duration: { type: Number, default: 800 }, showDots: { type: Boolean, default: true }, height: { type: String, default: 360rpx } }height用字符串类型传给外层容器方便不同页面调整轮播高度。duration可以动态传给 style让不同场景下动画的快慢可调。能抽象成配置项的尽量配置化不要写死。6.2 事件回调和对外方法组件不能光自己转业务方需要知道当前切到了第几张比如做埋点、联动下方的商品卡片。我习惯在current变化后触发一个change事件watch: { current(newVal, oldVal) { if (newVal ! oldVal) { this.$emit(change, { current: newVal, banner: this.banners[newVal] }) } } }如果有需要外部手动控制切换的场景比如点击一个下一张按钮可以通过ref调用组件内部方法this.$refs.fadeSwiper.goTo(this.$refs.fadeSwiper.current 1)这里有一个开发习惯想强调一下即使现在只有一个地方用我也建议先把事件写上。后续加需求时有事件总比没有事件要容易扩展得多而且改动成本几乎为零。6.3 还能继续扩展的方向再往上走这个组件可以扩展的东西不少。比如现在指示器是固定的小圆点但有些设计稿要求指示器是数字、进度条甚至放在图片外部。我一般会把指示器区域做成插槽让页面决定它长什么样组件只提供当前的current和总数。比如有些轮播需要支持只播一次播到最后停在最后一张就需要把循环策略抽象出来加个loop属性go()方法在非循环模式下做边界限制。还有图片懒加载。如果轮播用的是高清大图而且页面里有好几个轮播组件可以考虑只渲染当前项和下一项其他项推迟到接近切换时再渲染。但要注意这个优化会破坏所有图片同时渲染所以切换无卡顿的特性需要结合业务权衡。6.4 我的一些使用体会这个组件在项目里替换掉原来的 swiper 之后最直观的感受是样式和逻辑都在自己手里调试起来非常简单。以前用 swiper 时图片间隙、点击穿透、指示器样式这些都要一层层覆盖现在直接改组件内部样式就行代码量反而少了。如果要把这套东西迁移到别的项目我会建议保留组件内部生命周期驱动定时器这个结构它是整个组件不出乱子的底线。动画时长、手势阈值这些参数每个项目真实体验下来可能会有细微差别做成配置项之后交给产品和 UI 去反复调也不会影响核心逻辑。最后分享一个小技巧如果你在页面里同时放了两三个这种淡入淡出轮播组件每个组件自己管理定时器互不干扰。但要注意它们各自的图片加载可能同时发起多个请求图片数量多的时候建议在图片 URL 上做统一的 CDN 压缩参数否则首屏加载会比较吃力。