资讯详情

资讯详情

小程序商品分类页左右联动实现与性能优化实战

1. 商品分类页面的核心交互逻辑与布局选型商品分类页面在电商类小程序里属于高频入口用户点进来只有一个目的快速找到想要的品类然后进入商品列表。这个页面的交互模式经过多年验证最稳的方案就是左右两栏联动——左侧固定宽度的一级分类导航右侧展示对应分类下的二级或三级内容。听起来简单但真正动手写的时候坑比想象中多。我见过不少刚接触小程序开发的朋友第一反应是用两个scroll-view拼在一起左边一个右边一个各自独立滚动。这样确实能跑起来但联动效果很差点左边某一项右边内容不会自动定位到对应区域右边手动滚动左边高亮也不会跟着变。要做成真正好用的分类页核心在于左右两侧的滚动位置需要双向绑定而不是各滚各的。先明确这个页面的几个关键需求。第一左侧导航项数量不固定可能五六个也可能二三十个所以必须支持纵向滚动。第二右侧内容区域每个分类下的商品数量差异很大有的只有两三个有的几十个所以右侧也需要独立滚动。第三点击左侧某一项时右侧要平滑滚动到对应分类的起始位置。第四用户手动滚动右侧内容时左侧的高亮项要自动切换到当前可视区域对应的分类。这四点做到了体验才算及格。布局选型上我强烈建议用flex布局做外层容器高度设为100vh减去顶部可能存在的搜索栏或导航栏高度。左侧导航栏固定宽度一般给180rpx到200rpx之间比较合适太窄了文字放不下太宽了挤压右侧商品展示空间。右侧内容区用flex: 1自动撑满剩余宽度。两个区域都用scroll-view包裹但要注意scroll-view在纵向滚动时必须设置固定高度否则滚动不会生效——这是新手最容易忽略的一点。view classcontainer scroll-view classleft-nav scroll-y scroll-into-view{{leftIntoView}} scroll-with-animation view wx:for{{categories}} wx:keyid idleft-{{index}} classnav-item {{activeIndex index ? active : }} bindtaponTapCategory >onTapCategory(e) { const index e.currentTarget.dataset.index; if (index this.data.activeIndex) return; this.setData({ activeIndex: index, rightIntoView: , leftIntoView: left-${index} }); wx.nextTick(() { this.setData({ rightIntoView: right-${index} }); }); }左侧的leftIntoView也要同步更新目的是当选中项在可视区域之外时左侧导航自动滚动把它露出来。比如分类有二十个用户点了第十五个左侧如果不滚动用户看不到高亮效果体验就很割裂。右侧滚动监听是更棘手的部分。bindscroll事件会频繁触发每次触发都去计算当前分类会导致性能问题。我的做法是加一个节流比如100ms内只处理一次。计算逻辑是在onReady阶段获取每个分类区块的顶部偏移量存到一个数组sectionTops里。滚动时拿到scrollTop遍历这个数组找到最后一个top小于等于scrollTop的索引就是当前应该高亮的分类。onReady() { this.computeSectionTops(); } computeSectionTops() { const query wx.createSelectorQuery(); this.data.categories.forEach((_, index) { query.select(#right-${index}).boundingClientRect(); }); query.select(.right-content).boundingClientRect(); query.exec((res) { const containerTop res[res.length - 1].top; const tops res.slice(0, -1).map(rect rect.top - containerTop); this.sectionTops tops; }); } onRightScroll(e) { if (this.scrollTimer) return; this.scrollTimer setTimeout(() { this.scrollTimer null; const scrollTop e.detail.scrollTop; let index 0; for (let i 0; i this.sectionTops.length; i) { if (scrollTop this.sectionTops[i] - 10) { index i; } else { break; } } if (index ! this.data.activeIndex) { this.setData({ activeIndex: index, leftIntoView: left-${index} }); } }, 100); }这里有个容易踩的坑boundingClientRect获取的是相对于视口的位置而scrollTop是相对于滚动容器顶部的距离。所以计算sectionTops时要用每个区块的top减去容器的top得到相对于容器的偏移量。如果不做这个减法滚动位置会整体偏移高亮切换就会错位。另一个坑是图片懒加载导致的高度变化。如果右侧商品图片是异步加载的初始计算sectionTops时图片还没撑开高度等图片加载完后每个区块的实际位置都变了但sectionTops还是旧值联动就会越来越不准。解决方案有两个一是给图片设置固定宽高比占位高度提前确定二是在图片加载完成后重新计算一次sectionTops。我一般两个都做双保险。提示scroll-into-view的目标id不能以数字开头所以用left-0、right-0这种前缀加索引的命名方式避免部分机型上识别不到。3. 分类数据结构的组织与渲染性能优化数据结构设计得好不好直接决定了后面代码写起来顺不顺。我见过有人把分类和商品拍平成一个数组每个商品带着分类id渲染的时候再分组这样每次滚动都要做大量过滤计算性能很差。合理的做法是嵌套结构一级分类数组每个分类对象里再挂一个商品数组。categories: [ { id: c1, name: 新鲜水果, products: [ { id: p1, name: 红富士苹果, price: 12.9, image: ... }, { id: p2, name: 海南香蕉, price: 8.5, image: ... } ] }, { id: c2, name: 时令蔬菜, products: [...] } ]这种结构在wxml里用两层wx:for就能渲染出来逻辑清晰。但要注意如果分类和商品数量都很大比如三十个分类、每个分类下五十个商品总共一千五百个节点小程序的渲染压力会很大。这时候需要做分页加载或者虚拟列表。分页加载的思路是初始只加载前几个分类的商品数据当用户滚动到接近底部时再加载后续分类的数据。实现上监听bindscroll的scrollTop当它接近scrollHeight - clientHeight时触发加载。但这里有个矛盾如果后续分类还没加载sectionTops就不完整联动会出问题。所以分页加载的粒度最好以分类为单位加载完一个分类就重新计算一次sectionTops。虚拟列表是更彻底的方案但实现复杂度高很多。对于大多数中小型电商小程序来说分类页的商品数量不会太夸张做好图片懒加载和分页加载基本就够了。图片懒加载用lazy-load属性配合固定高度的占位图能显著减少首屏渲染时间。还有一个优化点是setData的频率。右侧滚动时如果每次都setData更新activeIndex数据量虽然小但频繁调用也会造成卡顿。我的做法是在滚动处理函数里先比较新旧activeIndex只有真正变化了才setData。另外leftIntoView的更新也可以延迟一点等滚动停下来再设置避免左侧导航频繁跳动。onRightScroll(e) { const scrollTop e.detail.scrollTop; let index 0; for (let i 0; i this.sectionTops.length; i) { if (scrollTop this.sectionTops[i] - 10) { index i; } else { break; } } if (index ! this.data.activeIndex) { this.setData({ activeIndex: index }); clearTimeout(this.leftTimer); this.leftTimer setTimeout(() { this.setData({ leftIntoView: left-${index} }); }, 150); } }左侧导航的滚动延迟设置是为了避免用户快速滑动右侧时左侧导航跟着疯狂滚动视觉上很乱。等滚动稍微稳定后再把选中项滚入可视区域体验更自然。表格对比一下不同数据量下的方案选择分类数商品总数推荐方案注意事项10以内100以内全量渲染注意图片懒加载10-20100-500全量渲染分页分页以分类为单位20以上500以上虚拟列表或分页需重新计算sectionTops4. 滚动穿透、吸顶与边界情况的处理分类页面在实际使用中会遇到一些边界情况不处理的话用户操作起来会很别扭。第一个是滚动穿透问题。如果分类页面上有弹窗或者筛选面板弹窗打开时手指在弹窗上滑动底层的分类列表也跟着滚这就是滚动穿透。解决办法是在弹窗打开时给底层scroll-view设置scroll-y{{false}}关闭时再恢复。第二个是右侧内容不足时的表现。如果某个分类下商品很少右侧内容总高度还没达到容器高度这时候滚动监听计算出来的sectionTops可能不准确因为最后一个区块的底部没有足够的滚动空间让它到达顶部。这种情况下用户滚动到底部时最后一个分类可能永远无法高亮。处理方式是在计算sectionTops时判断如果最后一个区块的top加上它的高度小于容器高度就把最后一个区块的top设为scrollHeight - clientHeight保证它能被选中。computeSectionTops() { const query wx.createSelectorQuery(); this.data.categories.forEach((_, index) { query.select(#right-${index}).boundingClientRect(); }); query.select(.right-content).boundingClientRect(); query.select(.right-content).scrollOffset(); query.exec((res) { const containerRect res[res.length - 2]; const scrollInfo res[res.length - 1]; const containerTop containerRect.top; const containerHeight containerRect.height; const scrollHeight scrollInfo.scrollHeight; let tops res.slice(0, -2).map(rect rect.top - containerTop); const lastTop tops[tops.length - 1]; const maxScrollTop scrollHeight - containerHeight; if (lastTop maxScrollTop) { tops[tops.length - 1] maxScrollTop; } this.sectionTops tops; }); }第三个是吸顶效果。右侧每个分类的标题在滚动到顶部时最好能吸顶显示让用户始终知道当前在看哪个分类。实现方式是用position: sticky给.section-title设置position: sticky; top: 0; z-index: 10;。但要注意sticky在scroll-view里的兼容性在部分安卓机型上有问题如果发现不生效可以改用监听滚动动态设置fixed定位的方案不过那样代码会复杂一些。第四个是快速滑动时的抖动。用户快速滑动右侧列表时bindscroll触发频率很高即使加了节流高亮切换也可能出现闪烁。我的经验是给activeIndex的更新加一个最小间隔比如80ms内不重复更新同时左侧导航的滚动动画时间设置短一点200ms左右让切换更跟手。注意position: sticky在scroll-view组件内使用时需要确保父级没有设置overflow: hidden否则会失效。如果实在不生效退而求其次用固定定位加占位元素模拟。还有一个细节是空分类的处理。如果某个分类下没有商品右侧对应区块高度很小滚动时很容易被跳过。可以在空分类下显示一个“暂无商品”的提示并给它一个最小高度保证联动时能正常选中。5. 从分类页延伸出去的几个实用改进基础功能跑通之后还有一些改进能让分类页更好用。第一个是左侧导航的角标提示。如果某个分类下有促销活动或者新品的数量可以在分类名称旁边加一个小红点或数字角标吸引用户点击。实现上就是在分类数据里加一个badge字段渲染时判断有值就显示。第二个是右侧商品卡片的点击反馈。用户点击商品进入详情页时最好有一个短暂的按压态让用户知道点击生效了。用hover-class给卡片加一个背景变暗的效果hover-stay-time设置100ms左右体验会好很多。第三个是分类页的缓存策略。分类数据一般不会频繁变动可以在onLoad时先读缓存渲染同时发起网络请求更新请求成功后再刷新数据并重新计算sectionTops。这样用户再次进入分类页时几乎秒开不用等接口返回。onLoad() { const cached wx.getStorageSync(category_data); if (cached) { this.setData({ categories: cached }, () { this.computeSectionTops(); }); } this.fetchCategories(); } fetchCategories() { wx.request({ url: https://api.example.com/categories, success: (res) { const data res.data; this.setData({ categories: data }, () { this.computeSectionTops(); }); wx.setStorageSync(category_data, data); } }); }第四个是搜索入口的整合。很多用户进分类页其实是想搜某个具体商品所以在分类页顶部加一个搜索框点击跳转到搜索页能减少用户的操作路径。搜索框不需要在分类页做实际搜索逻辑跳转过去就行保持分类页的轻量。第五个是骨架屏。分类数据加载需要时间如果白屏等待用户会觉得卡。用骨架屏占位灰色方块模拟分类和商品的布局数据回来后再替换感知上会快很多。骨架屏的实现可以用wx:if判断数据是否加载完成加载中显示骨架结构加载完显示真实内容。view wx:if{{loading}} classskeleton view classskeleton-left view classskeleton-nav-item wx:for{{8}} wx:key*this/view /view view classskeleton-right view classskeleton-title/view view classskeleton-grid view classskeleton-card wx:for{{6}} wx:key*this/view /view /view /view骨架屏的灰色块用#f2f2f2到#e0e0e0之间的颜色加一个从左到右的渐变动画看起来更自然。动画用CSS animation实现不要用JS定时器性能更好。最后说一个我踩过的坑scroll-view的scroll-into-view在页面onShow时如果数据还没渲染完就调用会静默失败。所以如果分类页是从其他页面返回的需要在onShow里加一个延迟等渲染完成后再恢复滚动位置。我一般用wx.nextTick加setTimeout双重保障虽然不够优雅但实测最稳。onShow() { if (this.data.activeIndex 0) { const index this.data.activeIndex; this.setData({ rightIntoView: }); wx.nextTick(() { setTimeout(() { this.setData({ rightIntoView: right-${index} }); }, 50); }); } }这套方案我在几个项目中都用过从十几个分类到三十多个分类的场景都跑过稳定性没问题。核心就是抓住联动这条主线把滚动位置计算准把边界情况处理干净剩下的就是细节打磨了。
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →