引言
在电商平台的商城装修功能中,"换一换"组件是提升用户体验和商品曝光的重要元素。这个看似简单的功能------展示9个商品,点击按钮后切换为另外9个商品------实际实现起来却有不少技术考量。本文将分享我在开发这一功能时的思路和技术实现方案。
功能效果展示
"换一换"组件的基本效果是:页面上初始展示9个商品,用户点击"换一换"按钮后,当前展示的商品会以一种流畅的动画效果切换为另外9个商品

实现难点分析
初看这个需求,很容易想到最直接的方案:点击按钮后,简单替换数据源并刷新视图。然而,这种实现方式会导致生硬的切换效果,用户体验欠佳。
为了实现流畅的视觉效果,我们需要考虑以下几点:
- 无缝切换:如何让商品切换时看起来自然流畅
- 性能优化:如何避免频繁的DOM操作导致的性能问题
- 数据管理:如何高效管理多组商品数据
技术实现方案
经过分析,我采用了"卡片翻转"的实现思路:
1. 双面卡片设计
每个商品展示格子实际上是一个具有正反两面的卡片:
- 正面:展示第一组商品(1-9号商品)
- 背面:展示第二组商品(10-18号商品)
当用户点击"换一换"按钮时,我们不是直接替换数据,而是触发卡片的翻转动画,将背面的内容展示出来。

2. 分析完毕,直接上干货
html
<!-- 换一换组件 -->
<view>
<view class="huan-wrapper">
<view class="huan-main">
<view wx:for="{{renderImgList}}" wx:key="index" class="huan-cell" style="{{item.boxStyle}}">
<view class="huan-img" style="background-image: url({{item.image_url}});" />
</view>
<!-- /9宫格 -->
<view class="huan-btn" style="{{screenBtn.style}}" bindtap="handleFlip">
<image class="btn-img" src="/btn.png" mode="widthFix" />
</view>
<!-- /按钮 -->
</view>
</view>
</view>
css
/*盒子样式*/
.huan-wrapper {
padding: 0 24rpx;
overflow: hidden;
background-size: cover;
background-repeat: no-repeat;
background-position: center;
padding-top: 24rpx;
height: 656px;
background-color: #FFFFFF;
}
.huan-main {
position: relative;
}
.huan-main .huan-cell {
position: absolute;
transition-property: all;
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
backface-visibility: hidden; /*关键代码*/
will-change: transform;
}
/*每个格子样式*/
.huan-cell .huan-img {
width: 100%;
height: 160px;
background-size: cover;
background-position: center;
background-repeat: no-repeat;
}
/*按钮样式*/
.huan-btn {
position: absolute;
z-index: 9;
top: 546px;
left: 50%;
transform: translateX(-50%);
text-align: center;
width: 167px;
}
.huan-btn .btn-img {
width: 100%;
display: block;
}
js
Component({
ready() {
// 初始化渲染列表
this.initRenderList();
},
data: {
TEM_HEIGHT: 0, // 图片高度
GAPRIGHT: 0, // 图片左右间距
GAPBOTTOM: 0, // 图片底部间距
COLS: 3, // 3列
SCREENWIDTH: 750, // 屏幕宽度
imagesPerScreen: 9, // 每屏图片数量默认9
isFlipped: false, // 控制反转状态
isFlipping: false, // 是否正在翻转中
currentIndex: 0, // 当前显示的数据起始索引
// 所有图片列表
allImageList: [
{ image_url: '1.png' },
{ image_url: '2.png' },
{ image_url: '3.png' },
// ...假设有27张图片
],
renderImgList: [], // 渲染图片列表
},
methods: {
// 点击按钮换一换
handleFlip() {
// 如果正在翻转中,则不处理
if (this.data.isFlipping) return;
console.log('点击了换一换');
// 切换翻转状态
const isFlipped = !this.data.isFlipped;
// 标记正在翻转
this.setData({
isFlipped,
isFlipping: true
});
// 应用翻转效果
this.applyFlipEffect();
// 等待翻转动画完成后,再更新背面的图片内容
setTimeout(() => this.updateBackfaceImages(), 800);
},
// 应用翻转效果
applyFlipEffect() {
// 重新计算每个图片的样式,应用翻转效果
const renderImgList = this.data.renderImgList.map((item, index) => {
item.boxStyle = this.calcBoxStye(index);
return item;
});
// 更新渲染列表,实现翻转效果
this.setData({ renderImgList });
},
// 更新背面图片
updateBackfaceImages() {
const { allImageList, currentIndex, renderImgList, imagesPerScreen, isFlipped } = this.data;
// 确定哪些图片现在在背面(即将在下次翻转时显示)
const backfaceIndices = this.getBackfaceIndices(isFlipped, imagesPerScreen);
// 获取当前渲染列表的副本
let newRenderImgList = [...renderImgList];
// 加载新的图片替换背面的图片
if (allImageList.length) {
// 计算新数据的起始位置,循环使用所有图片
let newIndex = currentIndex % allImageList.length;
// 替换背面的图片
for (let i = 0; i < backfaceIndices.length; i++) {
const replaceIndex = backfaceIndices[i];
// 确保不超出数组范围,循环使用数据
// 这里是关键:当i超过可用图片数量时,我们从头开始取
const sourceIndex = (newIndex + i) % allImageList.length;
// 复制新图片数据
const newImage = { ...allImageList[sourceIndex] };
// 保留原始位置信息,但更新图片内容
newRenderImgList[replaceIndex] = {
...newImage,
boxStyle: newRenderImgList[replaceIndex].boxStyle, // 保留原样式
};
}
// 更新下一次加载的起始位置
newIndex = (newIndex + imagesPerScreen) % allImageList.length;
// 更新数据,但不触发翻转动画
this.setData({
renderImgList: newRenderImgList,
currentIndex: newIndex,
isFlipping: false // 重置翻转状态
});
} else {
// 如果图片不足,只重置翻转状态
this.setData({
isFlipping: false
});
}
},
// 获取背面图片索引
getBackfaceIndices(isFlipped, imagesPerScreen) {
const backfaceIndices = [];
for (let i = 0; i < imagesPerScreen * 2; i++) {
if (isFlipped && i < imagesPerScreen) {
backfaceIndices.push(i); // 前一屏在背面
} else if (!isFlipped && i >= imagesPerScreen) {
backfaceIndices.push(i); // 后一屏在背面
}
}
return backfaceIndices;
},
// 计算盒子样式
calcBoxStye(index) {
let { COLS, GAPRIGHT, GAPBOTTOM, SCREENWIDTH, isFlipped, ITEM_HEIGHT, imagesPerScreen } = this.data;
// 计算行列位置(从0开始)
const row = Math.floor(index / COLS);
const col = index % COLS;
// 计算基础位置
const baseRow = row % (imagesPerScreen / 3);
// 计算样式
const top = baseRow * (ITEM_HEIGHT + GAPBOTTOM);
const boxWidth = (SCREENWIDTH - GAPRIGHT * 2 - 48) / COLS;
const left = col * (boxWidth + GAPRIGHT);
// 根据翻转状态计算角度
let deg = 0;
if (isFlipped) {
deg = index < imagesPerScreen ? 180 : 0;
} else {
deg = index >= imagesPerScreen ? 180 : 0;
}
// 设置第1,5,6 格子 过渡慢点,使整体效果看起来不会太死板
let duration = 500; // 30%概率是800ms,70%概率是500ms
if ([1, 5, 6].includes(index % imagesPerScreen)) {
duration = 800;
}
return `top: ${top}rpx; left: ${left}rpx; transition-duration: ${duration}ms; transform: rotateY(${deg}deg);width: ${boxWidth}rpx`;
},
// 初始化渲染列表
initRenderList() {
const { allImageList, imagesPerScreen } = this.data;
// 假设需要18张图片
const totalNeeded = 18;
let renderImgList = [];
// 填充图片列表,如果图片不足,则循环使用
for (let i = 0; i < totalNeeded; i++) {
const sourceIndex = i % allImageList.length;
const imgItem = { ...allImageList[sourceIndex] };
renderImgList.push(imgItem);
}
console.log('renderImgList', renderImgList);
// 计算样式
renderImgList.forEach((e, i) => {
e.boxStyle = this.calcBoxStye(i);
});
this.setData({
renderImgList,
});
},
}
})
最终实现效果✔️
