blog
blog copied to clipboard
微信小程序产品页改版分享
前言
需求描述
小程序产品列表和 APP 接口统一,实现产品轮播页单页楼梯导航效果
效果预览
App 与 小程序产品页交互效果查看
实现思路
1. 改造数据
// clearnData 方法
Page({
clearnData(data) {
// ...
data.forEach((o, dataIndex) => {
objList.push({
key: o[0].moduleKey, // allHotProd
name: o[0].parentTitle, // 全部
currentLabel: "page" + dataIndex + "_" + o[0].moduleKey, // page0_allHotProd
info: [...o], // 所有数据集合 - dataList 分组集合
});
});
},
this.setData(
{
swiperList: objList,
currentType: objList[0].key,
}
);
});
2. 基本实现:scroll-into-view + 滚动监听
点击上面的 label 已经可以正确定位了,如何滚动监听回显?
需要的是详细的高度信息。
<!-- ! swiperList 页切换 -->
<scroll-view scroll-x="true" class="head-type">
<view
class="type-item {{item.key == currentType ? 'active' : ''}}"
wx:for="{{swiperList}}"
wx:key="key"
bindtap="changeType"
data-type="{{item.key}}"
>
<view class="type-item-name">{{item.name}}</view>
<view class="type-item-line"></view>
</view>
</scroll-view>
<!-- swiper 实际区域 -->
<swiper>
<swiper-item
wx:for="{{swiperList}}"
wx:key="key"
wx:for-index="pageIndex"
wx:for-item="page"
>
<view class="label-content">
<!-- 子标签切换 -->
<view
wx:for="{{page.info}}"
wx:for-item="info"
wx:key="key"
wx:for-index="infoIndex"
hidden="{{info.dataList.length === 0 || info.show === 'false'}}"
class="label-item {{info.key == page.currentLabel ? 'active' : ''}}"
data-label="{{info.key}}"
bindtap="changeLabel"
>{{info.title}}</view
>
<scroll-view
class="scrollBox"
scroll-y="true"
scroll-into-view="{{toView}}"
scroll-with-animation="true"
enable-back-to-top="true"
bindscroll="listenScroll"
>
<view
class="group"
id="{{infoitem.key}}"
wx:for="{{page.info}}"
wx:for-item="infoitem"
wx:key="key"
data-rol="{{infoitem.key}}"
wx:for-index="groupIndex"
hidden="{{infoitem.dataList.length === 0 || infoitem.show === 'false'}}"
>
<!-- 组标题:爆款热销 -->
<!-- - 广告 ads -->
<!-- - 产品 product -->
<!-- - 产品 product -->
<!-- - 产品 product -->
</view>
</scroll-view>
</view>
</swiper-item>
</swiper>
Page({
data: {
swiperList: [], // 新的数据容器
toView: "", // 滚动目标 scroll-into-view 的 id
currentType: "all",
currentTypeIndex: 0, //当前轮播图的第几页
},
//切换标签
changeLabel(e) {
let label = e.currentTarget.dataset.label;
let param = "swiperList[" + this.data.currentTypeIndex + "].currentLabel";
this.setData({
[param]: label,
toView: label,
isClickLabel: true,
});
},
listenScroll: debounce(function (e) {
this.scrollFn(e);
}, 8),
scrollFn(e) {
...
const infoList = swiperList[currentTypeIndex].info;
let init = 0;
// [521, 686, 1184, 1685, 1850, 2015, 2180, 2345]
const heightMap = infoList.map((v, index) => {
if (v.height === 0) {
return 0;
}
init += v.height;
return init;
});
let heightMapIndex = heightMap.findIndex((v) => v > e.detail.scrollTop);
...
},
});
3. 如何获取集合所有分组的高度?
先动态计算再进行精度调整
Page({
data: {
heightInfo: {
// (参照单位 : width 375 的设计稿)
title: 86, // 标题种类 1:仅有标题
desc: 126, // 标题种类 2:带有标题描述
ads: 228, // 广告高度
product: 222, // 产品的高度
gap: 22, // 间隔线
},
},
getGroupHeight(datas, descText) {
const { title, desc, gap, ads, product } = this.data.heightInfo;
let base = descText ? desc + gap : title + gap;
let num_ads = datas.filter((m) => m.itemType === "ads").length;
let num_products = datas.filter((m) => m.itemType === "product").length;
if (datas.length > 0) {
return Number(
Math.floor((base + num_ads * ads + product * num_products) / 2)
);
} else {
return 0;
}
},
calculationAccuracy() {
let query = wx.createSelectorQuery();
query
.select(".page0_firstGroup")
.boundingClientRect((rect) => {
if (rect === null) {
console.error("首次获取dom失败");
} else if (rect.height) {
let height = rect.height;
const default_firstGroupHeight = this.data.swiperList[0].info[0]
.height;
let scale = toFixed(height / default_firstGroupHeight, 3);
// 更新 swiperList 的真实的高度
}
})
.exec();
},
});
4. 什么时机去更新
(1) 延时 seTimeout
Page({
onReady() {
setTimeout(() => {
// ! 响应式屏幕精度调整(时机: 页面完全渲染完后)
this.calculationAccuracy();
}, 2000);
},
});
(2) 多个延时 等待更新
Page({
onReady() {
// ! 响应式屏幕精度调整(时机: 页面完全渲染完后)
this.calculationAccuracy();
},
calculationAccuracy() {
let query = wx.createSelectorQuery();
const calc = () => {
return new Promise((resolve, reject) => {
// ! 取第一个 group 实际高度与理论高度获取精度差
query
.select(".page0_firstGroup")
.boundingClientRect((rect) => {
if (rect === null) {
reject("首次获取dom失败");
} else if (rect.height) {
resolve(rect.height);
}
})
.exec();
});
};
const resetHeightInfo = (height) => {
const default_firstGroupHeight = this.data.swiperList[0].info[0].height;
let scale = toFixed(height / default_firstGroupHeight, 3);
console.log("精度比例:", scale);
const temp = deepCopy(this.data.swiperList);
temp.forEach((v) => {
v.info.forEach((m) => {
m.height = Math.floor(m.height * scale);
});
});
console.log("调整精度后设置 swiperList", temp);
this.setData({
scale: scale,
swiperList: temp,
});
};
setTimeout(() => {
calc()
.then((first_height) => {
resetHeightInfo(first_height);
})
.catch((error) => {
console.log("error", error, "尝试第二次调整精度");
setTimeout(() => {
calc()
.then((second_height) => {
resetHeightInfo(second_height);
})
.catch((err) => {
console.log("error", error, "尝试第三次调整精度");
setTimeout(() => {
calc()
.then((third_height) => {
resetHeightInfo(third_height);
})
.catch(() => {
throw new Error("三次调整精度失败!!!请过会儿再试");
});
}, 2000);
});
}, 1500);
});
}, 1500);
},
});
(3) 真正的更新时机
Page({
data: {
text: "This is page data.",
},
onLoad: function (options) {
// 页面创建时执行*是在首次数据渲染之前执行),非阻断性逻辑尽量不要写在这里,会影响首屏数据渲染的时间
},
onReady: function () {
// 页面首次渲染完毕时执行,一个页面只会调用一次,代表页面已经准备妥当(可以操作 Dom)
// ! 如果需要获取 ajax 动态渲染的 dom 元素,可以在获取数据后设置 setData 时的回调中获取
},
clearnData(data) {
this.setData(
{
swiperList: objList,
currentType: objList[0].key,
},
() => {
// ! 响应式屏幕精度调整(时机: 页面完全渲染完后)
this.calculationAccuracy();
}
);
},
calculationAccuracy() {
// 可以同步计算精度差
},
});
5. 直接计算 dom 高度
Page({
clearnData(data) {
this.setData(
{
swiperList: objList,
currentType: objList[0].key,
},
() => {
this.setRelDomHeight();
}
);
},
setRelDomHeight() {
// 获取真实 dom 高度后设置 swiperList
const temp = deepCopy(this.data.swiperList);
temp.forEach((v) => {
v.info.forEach(async (m) => {
m.height = await this.getGroupHeight2(m.key);
});
});
this.setData({
swiperList: temp,
});
},
// 单个 dom 高度获取 by id
getGroupHeight2(id) {
let query = wx.createSelectorQuery();
return new Promise((resolve, reject) => {
query
.select(`#${id}`)
.boundingClientRect((rect) => {
try {
let height = rect.height;
resolve(height);
} catch (error) {
reject("获取 dom #" + id + "失败");
}
})
.exec();
});
},
});
性能测试优化
存在问题
- 存在渲染界面的耗时过长的情况;
渲染界面的耗时过长会让用户觉得卡顿,体验较差,出现这一情况时,需要校验下是否同时渲染的区域太大 ---pages/productList/productList
解决方案:页面数据过大,一次渲染耗费时长,按模块分多次渲染,加入骨架屏
- 存在 setData 的数据过大
由于小程序运行逻辑线程与渲染线程之上,setData 的调用会把数据从逻辑层传到渲染层,数据太大会增加通信时间 pages/productList/productList : swiperList、currentType,变量单次赋值 598k;
解决方案:产品列表页所有数据来源一个接口,数据过大,大量冗余字段;先是接口拆分,然后数据清洗,去除无用字段,减少数据大小,一般不超过 256k;
- 滚动区域没有开启惯性滚动
惯性滚动会使滚动比较顺畅,在安卓下默认有惯性滚动,而在 iOS 下需要额外设置 -webkit-overflow-scrolling: touch
的样式
pages/productList/productList
解决方案:使用 scroll-view 组件,添加-webkit-overflow-scrolling: touch
的样式
- 存在将未绑定在 WXML 的变量传入 setData
setData 操作会引起框架处理一些渲染界面相关的工作,一个未绑定的变量意味着与界面渲染无关,传入 setData 会造成不必要的性能消耗
解决方案:按要求处理,减少 setData 的调用 (可以使用 this.data 存储不需要在 wxml 中展示的变量)
- 存在短时间内发起太多的图片请求
短时间内发起太多图片请求会触发浏览器并行加载的限制,可能导致图片加载慢,用户一直处理等待。应该合理控制数量,可考虑使用雪碧图技术、拆分域名或在屏幕外的图片使用懒加载
解决方案:懒加载需要监听滚动的高度,计算当前 dom 的高度,调用 setData 改变图片的显隐状态,会增加另外性能损失,再考虑...
- 存在 setData 的调用过于频繁
setData 接口的调用涉及逻辑层与渲染层间的线程通过,通信过于频繁可能导致处理队列阻塞,界面渲染不及时而导致卡顿,应避免无用的频繁调用 pages/home/home:onPageScroll 方法 38 次/秒,touchEnd 方法 26 次/秒
解决方案:滚动监听处理数据,使用节流处理;页面其他多次调用,减少非必要的调用,非数据绑定的使用常规赋值方法;
Page({
data: {
swiperList: [], // 新的数据容器,开关 isRender 来控制渲染
currentType: "all", // 轮播大 标签
currentTypeIndex: 0, //当前轮播图的第几页
domHeightList: [], // 单独维护所有高度
isClickLabel: false,
},
clearnData(data) {
this.setData(
{
swiperList: objList,
currentType: objList[0].key,
"swiperList[0].isRender": true,
},
() => {
this.setRelDomHeight(0);
}
);
},
// 设置 dom 高度
setRelDomHeight(index) {
let arr = [];
this.data.swiperList[index].info.forEach(async (v) => {
let height = await this.getGroupHeight2(v.moduleKey);
if (height) {
if (arr.length == 0) {
arr.push(height);
} else {
arr.push(arr[arr.length - 1] + height);
}
}
});
this.data.domHeightList[index] = arr;
},
//切换标签
changeLabel(e) {
let label = e.currentTarget.dataset.label;
let param = "swiperList[" + this.data.currentTypeIndex + "].currentLabel";
this.setData({
[param]: label,
toView: label,
});
this.data.isClickLabel = true;
},
//切换类型
changeType(e) {
let type = e.currentTarget.dataset.type;
let index = this.data.swiperList.findIndex((item) => item.key === type);
let isRender = "swiperList[" + index + "].isRender";
if (this.data.swiperList[index].isRender) {
this.setData({
currentType: type,
currentTypeIndex: index,
});
} else {
this.setData(
{
currentType: type,
currentTypeIndex: index,
[isRender]: true,
},
() => {
this.setRelDomHeight(index);
}
);
}
},
// 轮播图切换
switchType(e) {
var source = e.detail.source;
// FIX 轮播切换 bug
if (source === "autoplay" || source === "touch") {
let current = e.detail.current;
let type = this.data.swiperList[current].key;
let isRender = "swiperList[" + current + "].isRender";
if (this.data.swiperList[current].isRender) {
this.setData({
currentType: type,
currentTypeIndex: current,
});
} else {
this.setData(
{
currentType: type,
currentTypeIndex: current,
[isRender]: true,
},
() => {
this.setRelDomHeight(current);
}
);
}
}
},
// 滚动监听 scroll-view 回显 label
listenScroll: debounce(function (e) {
this.scrollFn(e);
}, 50),
scrollFn(e) {
const { isClickLabel, swiperList, currentTypeIndex } = this.data;
// 点击 label 不需要加入滚动监听
if (isClickLabel) {
this.data.isClickLabel = false;
return;
}
const infoList = swiperList[currentTypeIndex].info;
let heightMapIndex = this.data.domHeightList[currentTypeIndex].findIndex(
(v) => v > e.detail.scrollTop
);
// ! 设置 page.currentLabel && toView
if (heightMapIndex === -1) {
heightMapIndex = this.data.domHeightList[currentTypeIndex].length - 1;
}
let param = "swiperList[" + this.data.currentTypeIndex + "].currentLabel";
let currentLabel = this.data[param];
let infoKey = infoList[heightMapIndex].moduleKey;
let currentIndex = infoList.findIndex((v) => v.moduleKey === currentLabel);
if (currentIndex !== heightMapIndex) {
this.setData({
[param]: infoKey,
});
}
},
// 跳转到搜索页
toSearch() {
wx.navigateTo({
url: "/pages/search/search",
});
},
});
拓展
- setData API 如何做到的异步渲染回调?
- Vue 源码详解之 nextTick:MutationObserver 只是浮云,microtask 才是核心!
参考
- https://developers.weixin.qq.com/miniprogram/dev/component/scroll-view.html
- https://developers.weixin.qq.com/miniprogram/dev/reference/api/Page.html