ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

小程序商城首页三件套:分类联动、猜你喜欢与骨架屏实战

小程序商城首页三件套:分类联动、猜你喜欢与骨架屏实战 1. 从商品列表一把梭到商城首页三件套的进化做微信小程序商城这个事儿我最早是从一个很朴素的页面开始的轮播图底下怼一个商品列表用户往下刷刷完拉倒。那时候觉得商城嘛能下单不就完了。直到被用户骂了一顿——你家首页怎么跟个货架子似的我想买啥还得自己翻半天。痛定思痛之后我开始研究那些头部商城小程序的首页结构发现它们几乎都有三个共同点分类导航让用户快速找到目标类目猜你喜欢承接无限流量的个性化推荐骨架屏解决首屏加载时白屏的尴尬。这三个东西单独拆开都不算难但组合在一起互相之间的数据流转、状态管理、渲染时机其实藏着不少坑。这篇文章我就把我在真实项目中落地这三块功能的完整过程写下来。代码基于微信小程序原生语法不依赖 uni-app 或第三方 UI 框架适合已经会写基础页面、想往像个正经商城方向进阶的开发者。你不需要有推荐算法的背景因为猜你喜欢这种功能在小程序端能落地的形态更多是规则缓存排序的工程问题而不是算法问题。先说结论这三个功能看上去各自独立实际上可以串成一条数据链路——分类决定当前展示的商品池猜你喜欢从这个池子里按规则捞商品骨架屏决定这些商品在数据回来之前长什么样。理解了这条链路你就理解了商城首页的骨架。2. 商城分类的几种形态和我为什么选了侧边栏内容区微信小程序里的商城分类我见过的大概有四类形态每种都有它的适用场景但坑也不少我挨个说。2.1 分类形态对比与选型逻辑第一类顶部 Tab 切换分类。这种最简单分类少比如三四类、层级浅的时候很合适。但一旦分类超过六个顶部的横向滑动区域就很局促用户记不住第几个是哪类体验直线下降。第二类九宫格入口。适合做活动入口而非真实分类体系比如新品秒杀满减这种营销向分类。但严格来说它承担不了商品归类职能用户点进去大概率是活动页不是分类页。第三类侧边栏一级分类右侧双层列表。这就是主流商城京东、淘宝、拼多多的经典布局。左侧是纵向滚动的一级分类右侧对应展示该分类下的二级分类和商品。好处是单屏信息密度大用户左滑右手路径极短。缺点是需要两级分类数据支持分类体系得完整。第四类悬浮球/弹窗分类。适合做快捷跳转不适合做主导航一般作为辅助。我最终选了第三类。原因有三一是我的 sku 数据本身就有完整的 category 树形结构不用额外造数据二是移动端单手操作场景下侧边栏模式拇指覆盖率高三是微信小程序的 scroll-view 原生组件足够支撑左右两个滚动区域的独立滚动性能上限比我之前担心的要高。2.2 分类页面的数据结构设计分类页面要撑起来数据结构必须先理清。我踩过的第一个坑就是用扁平数组存分类前端再自己组装树形结构。当时图省事后端给的数据是[{id: 1, parentId: 0, name: 手机数码}, {id: 2, parentId: 1, name: 手机}, ...]前端拿过来循环filter拼树。数据量小还行量一大那filter的嵌套循环性能惨不忍睹。后来后端改成直接返回树形结构前端只做一次遍历。如果你后端的接口暂时改不了必须在客户端做扁平转树也有一个不算复杂的办法只遍历一次就行我后续单独说。分类字段我建议至少包含这几个字段类型说明idNumber/String分类唯一标识nameString分类名称parentIdNumber/String父分类 ID顶级分类为 0levelNumber层级1 表示一级2 表示二级iconUrlString分类图标sortOrderNumber排序权重越小越靠前statusNumber1 启用0 禁用childrenArray子分类数组叶子节点为空右侧商品区直接用分类下的商品列表或者该分类下所属的商品聚合卡片来渲染。我项目里是右侧上半部放二级分类的图标宫格下半部放该分类下的热门商品卡片用户点商品卡片直接进详情页。2.3 左侧滑动联动右侧渲染的实现思路核心交互是点击左侧分类右侧滚动到对应内容。这一步实现起来不复杂但有一个关键细节必须处理数据请求的竞态问题。用户快速点击左侧不同分类时如果每次都发请求且响应时间不一致后点但先返回的数据可能覆盖掉先点但后返回的数据页面就会显示错乱。我第一版就是踩了这个坑用户手速一快右边显示的是上一个分类的商品左侧高亮却是当前分类排查了半天。解决方案有两种我用的是第二种禁用式点击后加 loading 遮罩数据没回来之前不可继续点击。简单粗暴但对用户不友好快速切换场景下体验很差。请求序号标记每次点击把当前请求序号querySeq自增请求返回时对比序号只有最新一次请求的结果才允许覆盖渲染。这个方案我强烈推荐。let querySeq 0; function loadCategoryProducts(categoryId) { const currentSeq querySeq; wx.showNavigationBarLoading(); request({ url: /api/products, data: { categoryId } }).then(res { if (currentSeq ! querySeq) { // 说明用户已经点了别的分类这次结果丢弃 return; } renderProductList(res.data); }).finally(() { wx.hideNavigationBarLoading(); }); }这套做法成本极低却能把竞态问题彻底干掉。凡是做左侧导航右侧内容这种结构的我建议都加这个序号标记防范于未然。2.4 分类数据缓存与更新策略分类数据基本属于低频变动数据没必要每次进页面都从服务端拉。微信小程序原生的wx.setStorageSync/wx.getStorageSync就够了不需要引额外的缓存库。我的策略是首次进入分类页先从 storage 读取旧缓存渲染同时发请求拉最新数据。请求成功且返回的数据结构和旧数据不一致更新 storage 并重新渲染。设置一个有效期比如 24 小时过期后强制重新拉取不展示旧缓存。const CATEGORY_CACHE_KEY category_tree_cache; const CACHE_EXPIRE 24 * 60 * 60 * 1000; // 24小时 function getCategoryData() { const cached wx.getStorageSync(CATEGORY_CACHE_KEY); if (cached Date.now() - cached.timestamp CACHE_EXPIRE) { return Promise.resolve(cached.data); } return request({ url: /api/categories }).then(res { wx.setStorageSync(CATEGORY_CACHE_KEY, { timestamp: Date.now(), data: res.data }); return res.data; }); }顺手说一句缓存字段里务必带上timestamp别只存数据本身。不然你没法判断这个缓存放了多久过期机制就形同虚设了。3. 猜你喜欢从随机推荐到带权重的规则排序猜你喜欢这四个字听着像算法活实际上在小程序前端能做的空间非常有限。服务端如果有推荐系统那前端只需要正确传参和渲染但大部分中小型项目根本没有推荐系统这个模块的前端逻辑就必须扛起还算像样的推荐这个责任。3.1 无推荐系统时的兜底策略我在没有后端推荐接口的情况下设计了一套前端侧可执行的规则推荐方案核心是按权重打分排序用户行为权重用户最近浏览过的商品同分类商品权重 40用户加购/收藏过的同分类商品权重 30用户搜索过的关键词命中的商品标题/标签权重 20商品自身热度权重销量按设定的区间线性映射到 0~20 分浏览量同上好评率高于 95% 额外 10低于 90% 扣 5新鲜度权重上架 7 天内的新品 10上架超过 30 天的商品不加权最终得分 行为权重分 热度权重分 新鲜度权重分。按得分降序取出前 20 个商品渲染。这些数据从哪里来浏览记录、搜索记录、收藏记录全部存在本地 storage关联分类通过商品上的 categoryId 关联。每次请求猜你喜欢接口时把本地算好的用户偏好分类序列作为参数传给服务端服务端从这个池子里捞商品返回。3.2 服务端接口设计前端把用户信号传给后端前端算权重只是为了让用户信号更明确最终真正起作用的是服务端。我推荐的接口设计POST /api/recommend/guess-you-like 请求体: { userId: xxx, preferredCategoryIds: [3, 5, 7], // 按权重降序 excludedProductIds: [p1, p2], // 已曝光商品去重 page: 1, pageSize: 10 } 响应: { list: [...], hasMore: true }其中preferredCategoryIds是关键前端把本地算出来的偏好分类序列传过去后端就能按这个优先级加权查询。还有一点很重要已经曝光过的商品不能重复推荐。不然用户下拉刷新看到的永远是同一批商品很快会失去兴趣。所以我维护了一个excludedProductIds数组比如当前页和上两页已经展示过的商品 ID 都传过去。3.3 下拉加载更多的分页处理与去重猜你喜欢天然是瀑布流形态分页逻辑跑不掉。我项目里用的是onReachBottom触底加载每次加载下一页。但这里有个高频坑不同批次的推荐数据交叉重复。分页接口虽然服务端通常会保证页码间的数据不重复但如果前端并发触发加载用户快速滚动onReachBottom 连续触发就有可能导致同一页的数据被请求多次或者翻页参数错乱。我用的防重方案let loadingMore false; let currentPage 1; function loadMore() { if (loadingMore) return; loadingMore true; request({ url: /api/recommend/guess-you-like, data: { page: currentPage 1, pageSize: 10 } }).then(res { // 手动过滤掉已经渲染过的商品ID const existedIds new Set(this.data.recommendList.map(item item.id)); const newList (res.data.list || []).filter(item !existedIds.has(item.id)); this.setData({ recommendList: this.data.recommendList.concat(newList) }); currentPage 1; }).finally(() { loadingMore false; }); }即使后端已经去重前端再过滤一遍属于双保险成本极低收益却扎实。另外loadingMore标记必须放在请求开始前置为 true在 finally 里重置这个顺序错了会导致重复请求。3.4 推荐位的用户反馈埋点一个容易被忽略但长期很重要的点推荐效果的反馈数据。推荐推荐的准不准不能靠感觉得有数据。我在猜你喜欢模块里对每个商品卡片加了曝光埋点和点击埋点。曝光埋点卡片进入用户可视区域后上报recommend_expose事件带上商品 ID、推荐位 ID、当前页码。点击埋点用户点击卡片进入详情时上报recommend_click事件同样带推荐位和商品信息。// 通过 IntersectionObserver 监听卡片曝光 const observer wx.createIntersectionObserver(this); observer.relativeToViewport({ bottom: 0 }).observe(.recommend-card, (res) { if (res.intersectionRatio 0) { const dataset res.dataset; reportExpose({ productId: dataset.productId, position: guess_you_like }); } });有了这两类数据运营就可以分析推荐位的点击率每个商品在推荐位的转化表现不同分类偏好下推荐命中率等指标后期迭代就有据可循了。没有埋点的推荐模块就是盲人摸象。4. 骨架屏别让用户在首屏白屏上干瞪眼骨架屏解决的是内容未到、页面先有结构的问题。微信小程序首屏加载如果要等接口全部返回再渲染用户看到的就是白屏或者加载中菊花转圈。骨架屏的核心价值在于让用户感知到页面结构是存在的内容正在加载进来从而降低焦虑感和跳出率。4.1 为什么骨架屏比 loading 组件更适合商城首页loading 组件转圈和骨架屏占位结构是两种完全不同的体验。转圈loading给用户的暗示是系统正在忙你等着就行。但商城首页用户其实想的是我要看看有没有我想要的东西。如果一片空白只有转圈用户很难有耐心等下去尤其是移动端网速不稳定的时候。骨架屏给用户的暗示是页面长这样具体的内容马上就来。用户虽然看到的也是占位灰块但大脑已经能预判出页面的布局结构。这在用户感知里是内容即将呈现而不是系统卡住了。从实现成本看骨架屏并不比 loading 组件复杂多少。它本质上就是一套静态的、不参与交互的占位视图。所以我强烈建议商城首页、分类页、商品详情页这类核心页面都用骨架屏而不是转圈。4.2 骨架屏的三种实现方案对比微信小程序里实现骨架屏主流有三种方案我逐一评估过方案一纯手写 WXML WXSS。每个区块用灰色圆角矩形模拟。优点是完全可控、零依赖缺点是要为每个页面单独编写一份骨架屏代码工作量不小而且页面结构一变骨架屏也得跟着改。方案二快照生成骨架屏静态图片或 SVG。用页面完整渲染后的截图/矢量图做骨架屏。优点是视觉还原度高缺点是快照的数据是死的如果接口返回了真实数据后发现布局和快照不一致需要刷新。而且快照没法表达交互区域的语义用户以为能点其实点了没反应。方案三基于基础组件的动态骨架屏封装。把骨架屏封装成一个组件通过配置项描述结构行数、列数、宽度、高度、间距、圆角组件内部循环渲染。优点是复用好改结构只改配置缺点是首次封装有学习成本。我最终选了方案一的改良版——不追求用工具自动生成而是用人肉精确控制哪些区域显示骨架、哪些区域不显示。因为商城首页结构相对固定搜索框、轮播图、分类入口、商品卡片列表手动写骨架屏反而最直观。后续如果页面多了再考虑封装成组件。4.3 商城首页骨架屏的具体实现首页的骨架屏我拆成了四个区块搜索框骨架一个高度 64rpx 的圆角灰色长条。轮播图骨架一个宽度 750rpx、高度 320rpx 的圆角矩形。分类宫格骨架一行四个圆角方块一共两行。猜你喜欢骨架两列瀑布流每列三个卡片占位。关键点是骨架屏区块的尺寸和真实内容的尺寸必须严格一致否则真实数据渲染时页面会跳体验反而更差。WXML 结构类似这样view classskeleton wx:if{{loading}} !-- 搜索框 -- view classskeleton-search/view !-- 轮播图 -- view classskeleton-banner/view !-- 分类宫格 -- view classskeleton-grid view classgrid-item wx:for{{8}} wx:keyindex view classgrid-circle/view view classgrid-text/view /view /view !-- 猜你喜欢占位 -- view classskeleton-products view classproduct-card wx:for{{6}} wx:keyindex view classproduct-img/view view classproduct-line/view view classproduct-line short/view /view /view /view view wx:else !-- 真实内容区域 -- /viewWXSS 里给每个骨架区块加灰色渐变背景并且加一个从浅灰到中灰再回浅灰的平移动画模拟呼吸感。.skeleton-search { width: 690rpx; height: 64rpx; margin: 20rpx auto; border-radius: 32rpx; background: linear-gradient(90deg, #f2f2f2 25%, #e6e6e6 37%, #f2f2f2 63%); background-size: 400% 100%; animation: skeleton-loading 1.4s ease infinite; } keyframes skeleton-loading { 0% { background-position: 100% 50%; } 100% { background-position: 0 50%; } }4.4 骨架屏切换的真实时机loading 状态如何管理骨架屏的显示和隐藏时机是让很多人翻车的地方。我的经验是不能简单用onLoad开始时loading true、接口返回后loading false因为首页有多个并行请求。我把首页的请求分为两类关键请求和非关键请求。关键请求轮播图、分类、猜你喜欢第一屏全部返回后才隐藏骨架屏非关键请求比如底部加载更多不影响骨架屏状态。const keyRequests [ fetchBanners(), fetchCategories(), fetchGuessYouLike({ page: 1 }) ]; Promise.all(keyRequests).then(() { this.setData({ loading: false }); }).catch(() { // 就算部分请求失败骨架屏也不应该一直转超时兜底 this.setData({ loading: false }); });这里有两个坑要提醒不要用setTimeout做兜底延迟。如果 300ms 内接口就返回了你却还在等 800ms 的延迟用户会看到骨架屏闪一下。正确的兜底是给Promise.all加超时机制比如 10 秒内没全部完成就强制显示真实内容哪怕部分是空的避免无限转骨架。骨架屏消失后的内容跳动。如果真实内容的图片本身没有设置固定宽高图片加载完成后会把文字顶下去整个页面跳一下。解决办法是给商品图片容器提前预留固定宽高比例比如宽 345rpx、高 345rpx或者用modeaspectFill配合固定容器。这属于骨架屏之外的必备细节但直接关系到骨架屏切换是否丝滑。5. 三个功能串联起来的整体性能调优分类、猜你喜欢、骨架屏三个功能拆开都好做真正考验功夫的是合体后的整体性能。下面几个点是真实项目里踩过、后面反复验证有效的调优手段。5.1 setData 批量更新与 Principal 数据剪裁微信小程序性能瓶颈大部分出在setData。一次性 setData 大量数据或者频繁 setData都会导致渲染卡顿。先说批量更新。首页数据回来之后我一开始是这样写的this.setData({ banners: res1.data }); this.setData({ categories: res2.data }); this.setData({ recommendList: res3.data });三次 setData三次渲染触发。改成一次this.setData({ banners: res1.data, categories: res2.data, recommendList: res3.data });看似小改动性能提升是实打实的。再说数据剪裁。接口返回的商品字段可能很多标题、描述、价格、原价、销量、库存、图片地址、标签、上架时间、评分……但首页猜你喜欢卡片上只需要标题、价格、图、销量。如果直接把整个商品对象塞给 setData数据量大了很浪费。const lightweightList rawList.map(item ({ id: item.id, title: item.title, price: item.price, coverImage: item.coverImage, sales: item.sales }));5.2 图片懒加载与占位图猜你喜欢列表是长列表图片数量多。微信小程序有两个相关属性lazy-loadimage 组件和wx.lazyCodeLoading。我建议两处都开。image组件的懒加载image src{{item.coverImage}} lazy-load{{true}} modeaspectFill classproduct-img /另外所有图片的src最好统一走一个包装方法图片加载失败时自动替换为项目内的默认占位图避免裂图影响整体质感。5.3 滚动容器选择scroll-view 还是页面滚动分类页的左侧和右侧都要独立滚动只能选scroll-view。而首页的猜你喜欢瀑布流我建议直接使用页面滚动即onReachBottom不要去套一个巨大的scroll-view。原因scroll-view的高度必须显式指定一旦页面结构复杂scroll-view内部的内容高度计算不及时会出现滚不动或者到底了却没法触发加载更多这种问题。而页面级别的滚动配合onReachBottom语义天然正确性能也更稳定。唯一要小心的是分类页左侧scroll-view和右侧scroll-view要分别设scroll-y并且给外层容器设死高度否则左右两侧会整个页面一起滚那就没意义了。5.4 从 storage 读取缓存后依然要展示骨架屏的细节开头我说了缓存策略是先渲染缓存再拉新数据但这里有个细节容易踩坑如果缓存命中页面到底要不要显示骨架屏我测试下来的建议是不显示。既然缓存能立刻渲染出真实内容就没必要先用骨架占位再替换。缓存读取是同步操作几乎零延迟用户看到的就是秒开效果。骨架屏只用于没有任何可用数据、必须等待网络请求返回的场景。这样才能把骨架屏的价值最大化它服务的是首次冷启动和弱网用户而不是每次进页面都让用户看一遍灰块。6. 扁平分类数据转树的经典实现刚才提到了如果接口只能给扁平数组前端需要自己转树这里补一个我项目里实际用过的实现。微信小程序基础库对 ES6 的支持程度已经不错但在老版本上仍需留意兼容性。我写的这个函数没有依赖Object.fromEntries之类的高版本 API稳一点。function buildCategoryTree(list) { const map {}; const roots []; list.forEach(item { map[item.id] { ...item, children: [] }; }); list.forEach(item { if (item.parentId 0 || item.parentId null || item.parentId undefined) { roots.push(map[item.id]); } else if (map[item.parentId]) { map[item.parentId].children.push(map[item.id]); } }); return roots; }这个方案只遍历两次效率比嵌套filter方案高一个量级。数据量上千分类都没压力。如果你还要在树里做排序需要在第二次遍历前先按sortOrder把同级数组排好序再挂载 children。7. 我在反复打磨这三个功能时积累的琐碎经验经过两个版本的迭代下面这些琐碎经验我认为值得单独列出来。第一分类页的高亮状态不要用currentIndex简单判断。左右两侧滚动时右侧滚动位置会改变左侧高亮如果完全依赖点击事件用户滑右侧时左侧不会联动。更完整的手法是监听右侧scroll-view的scroll事件根据其滚动的 offsetTop 落在哪个区块区间内反推左侧应该高亮哪个分类。这个联动逻辑加完之后分类页的体验才真正接近主流商城。第二猜你喜欢的商品卡片要统一暴露不喜欢/不感兴趣入口。如果用户对某个推荐商品不感兴趣点掉之后前端要把这个商品 ID 存进本地黑名单后续所有推荐请求都排除掉这些 ID。不做这个功能推荐只会越来越失真用户越看越烦。第三骨架屏的动画帧率要克制。我把animation的时长设在 1.4s 左右并且尽量让动画只作用在background-position和opacity这类轻量属性上不要去做大范围的transform: translateX动画因为会造成整页重排在低端安卓机上表现特别糟糕。第四接口超时处理与错误提示要区别对待。骨架屏阶段如果接口超时不要直接弹网络错误的 toast那样用户会被吓跑。更好的方式是骨架屏强制隐藏页面显示真实内容区域哪怕没数据在内容区域放一个轻量级的错误占位和点击重试按钮。等用户主动点击重试时再弹 loading这比首屏直接 toast 友好得多。第五缓存清理的优先级。分类缓存和用户行为本地缓存浏览记录、搜索记录、收藏记录要设置合理的存储上限。分类缓存走上面的 24 小时过期策略用户行为缓存我控制在 50 条以内超出后按时间戳剔除最早的数据。这样 localStorage 占用不会无限膨胀也避免行为数据太旧影响推荐的实时性。这三个功能组合下来商城首页的完整交互链路就通了用户一进来看到的是带呼吸动画的骨架屏骨架屏消失后是分类侧边栏猜你喜欢瀑布流点击左侧分类右侧联动切货下滑到底自动加载更多推荐每张卡片点击都有埋点上报。整个体验谈不上惊艳但已经比单个商品列表硬怼的版本要像样得多。如果你正在做自己的小程序商城我建议按这个顺序去落地先补分类联动再做猜你喜欢的数据流最后套骨架屏收尾每一步都能独立验证效果不会互相纠缠。
返回列表