前言
在最近做小程序的时候,遇到了轮播图卡顿的情况。有些情况下,轮播图需要展示上千个元素,如果使用原生的 Swiper
组件,渲染这么多元素会导致严重的卡顿问题。
原生的 Swiper
组件无法做到仅展示当前元素和前后两个元素。虽然你可以控制 swiper-item
的内容不展示,但 swiper-item
这个 DOM 节点仍然会被渲染。因此,在几千个元素的情况下,轮播图组件依然会非常卡顿。
开始介绍解决方法前先给大家看看我实现的效果如何:
优化原理
优化的核心思想是按需渲染,即只渲染当前可见的区域以及前后两张卡片,确保滚动时不会出现空白或卡顿。这种方式类似于懒加载,动态加载和卸载内容,从而减少 DOM 节点的数量,提升性能。
我们需要在一个有限的屏幕上展示大量图片,但无法一次性加载所有图片。于是,我们只加载当前屏幕可见的图片以及前后几张图片,当用户滑动时,动态加载新的图片并卸载不再可见的图片。这样既能保证流畅的滑动体验,又能避免一次性加载过多内容导致的性能问题。
当用户滑动到第一百张卡片时,我们依然只渲染三张卡片:当前卡片、前一张卡片和后一张卡片。屏幕外的卡片会在滑动时动态加载和卸载:
实现
接下来,我将介绍如何实现一个支持按需渲染的轮播图组件。下面的代码已经将业务逻辑抽离,只保留了核心代码来展示整体实现思路。
虚拟列表渲染
ts
// 只渲染当前页及其相邻页面
const getRenderItems = (current: number, list: any[]) => {
const minIndex = Math.max(0, current - 1)
const maxIndex = Math.min(list.length, current + 1)
return list.slice(minIndex, maxIndex + 1).map(item => ({
...item,
index: list.findIndex(i => i.id === item.id)
}))
}
- 只渲染当前页及其相邻页面:通过计算最小和最大索引,确保只渲染当前页及其前后相邻的页面,减少不必要的渲染开销。
布局结构
ts
<template>
<view class="container" @touchstart="onTouchStart" @touchmove="onTouchMove" @touchend="onTouchEnd">
<!-- 滑动容器 -->
<view
class="slider"
:class="[!initializing && 'transition', isDragging && 'dragging']"
:style="{ transform: `translateX(${translateX}px)` }"
>
<!-- 占位容器 -->
<view
class="placeholder"
:style="{ width: `${(currentIndex - 1) * width}px` }"
/>
<!-- 渲染项 -->
<view
v-for="item in renderItems"
:key="item.id"
:style="{ width: `${width}px` }"
>
<slot :item="item" />
</view>
</view>
</view>
</template>
- 滑动容器 :通过
transform
实现水平滑动,使用translateX
控制位移。 - 占位容器:用于保持滑动时的正确位置偏移,避免页面跳动。
- 渲染项 :根据
renderItems
动态渲染当前页及其相邻页面的内容。
关于占位容器,这里我解释一下它的作用方便大家理解,随着页面不断往左边滚动,轮播图的整体容器会向左偏移越来越多,为了让当前轮播元素展示在正中间,我们需要在左侧增加一个动态增大的占位容器,下面我画了一个图:
这个状态是滚动到卡片2时,实际的元素渲染状态,如果我们要让容器平滑的往右滚动查看卡片3,当卡片3出现的时候,卡片1就消失了,并且需要提前渲染卡片4,此时就需要占位容器来把卡片1的位置占住,这样我们的 translateX
偏移效果才能刚好让卡片 3 展示出来
同样的,继续往后滚动到卡片4的时候,继续加大占位容器的宽度:
除了占位容器的方法,你也可以使用 absolute
布局,动态设置卡片的 left
偏移,这样就不需要增加占位容器,也能实现平滑的滚动效果。不过,我当时觉得占位容器的方式更简单,且最多只渲染四个元素,因此选择了这种方式。这里提供的只是一个思路。
触摸事件处理
ts
// 状态管理
const state = reactive({
startX: 0,
translateX: 0,
isDragging: false,
currentIndex: 0
})
// 触摸开始
const onTouchStart = (e: TouchEvent) => {
state.startX = e.touches[0].clientX
state.isDragging = true
}
// 触摸移动
const onTouchMove = (e: TouchEvent) => {
if (!state.isDragging) return
const deltaX = e.touches[0].clientX - state.startX
const moveRatio = 0.5 // 移动阻尼系数
const edgeRatio = 0.12 // 边缘阻尼系数
// 边缘阻尼处理
if ((state.currentIndex === 0 && deltaX > 0) ||
(state.currentIndex === list.length - 1 && deltaX < 0)) {
state.translateX = -state.currentIndex * width + deltaX * edgeRatio
} else {
state.translateX = -state.currentIndex * width + deltaX * moveRatio
}
}
// 触摸结束
const onTouchEnd = (e: TouchEvent) => {
if (!state.isDragging) return
const deltaX = e.changedTouches[0].clientX - state.startX
const threshold = width * 0.1 // 滑动阈值
let newIndex = state.currentIndex
// 判断是否切换页面
if (Math.abs(deltaX) > threshold) {
if (deltaX > 0 && state.currentIndex > 0) {
newIndex--
} else if (deltaX < 0 && state.currentIndex < list.length - 1) {
newIndex++
}
}
state.isDragging = false
state.translateX = -newIndex * width
if (newIndex !== state.currentIndex) {
state.currentIndex = newIndex
emit('change', newIndex)
}
}
- 添加移动阻尼,提升滑动体验:通过阻尼系数控制滑动速度,使滑动更加平滑。
- 边缘回弹效果:当滑动到列表边缘时,添加回弹效果。
- 基于阈值的页面切换判断:根据滑动距离是否超过阈值来决定是否切换页面。
- 使用 transform 代替 left/top 实现位移 :通过
transform
实现位移,性能更好。 - 拖动时禁用过渡动画:在拖动过程中禁用过渡动画,避免动画干扰拖动效果。
过渡动画处理
css
.slider {
display: flex;
height: 100%;
}
.slider.transition {
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.slider.dragging {
transition: none;
}
初次进入页面
要维护一个状态,初次进入页面时不执行动画,否则如果初次进入页面查看的是很靠后的卡片,会有一个快速滑动到当前卡片的效果,卡片太多时会闪烁一下,体验不太好。
ts
// 添加一个控制初始化动画的变量
const initializing = ref(true)
onLoad((query: { index: string; }) => {
// 设置初始位置(无动画)
translateX.value = -(parseInt(query.index) || 0) * windowWidth
// 等待下一帧后启用动画
nextTick(() => {
initializing.value = false
})
总结
通过按需渲染的方式,我们成功极大轮播图的性能,避免了在渲染大量元素时的卡顿问题。这种方式不仅适用于小程序,也可以应用于其他前端框架。
如果你想实际体验一下实现的效果,可以在学习卡盒小程序,在任意卡盒中的卡片点击后进入大卡片页面,左右滑动一下看看是不是你想要的效果。希望这篇文章能为你提供一些有用的思路和实现方法!