ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

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

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

1. 商品分类页面的核心交互逻辑与布局选型

商品分类页面在电商类小程序里属于高频入口,用户点进来只有一个目的:快速找到想要的品类,然后进入商品列表。这个页面的交互模式经过多年验证,最稳的方案就是左右两栏联动——左侧固定宽度的一级分类导航,右侧展示对应分类下的二级或三级内容。听起来简单,但真正动手写的时候,坑比想象中多。

我见过不少刚接触小程序开发的朋友,第一反应是用两个scroll-view拼在一起,左边一个右边一个,各自独立滚动。这样确实能跑起来,但联动效果很差:点左边某一项,右边内容不会自动定位到对应区域;右边手动滚动,左边高亮也不会跟着变。要做成真正好用的分类页,核心在于左右两侧的滚动位置需要双向绑定,而不是各滚各的。

先明确这个页面的几个关键需求。第一,左侧导航项数量不固定,可能五六个,也可能二三十个,所以必须支持纵向滚动。第二,右侧内容区域每个分类下的商品数量差异很大,有的只有两三个,有的几十个,所以右侧也需要独立滚动。第三,点击左侧某一项时,右侧要平滑滚动到对应分类的起始位置。第四,用户手动滚动右侧内容时,左侧的高亮项要自动切换到当前可视区域对应的分类。这四点做到了,体验才算及格。

布局选型上,我强烈建议用flex布局做外层容器,高度设为100vh减去顶部可能存在的搜索栏或导航栏高度。左侧导航栏固定宽度,一般给180rpx到200rpx之间比较合适,太窄了文字放不下,太宽了挤压右侧商品展示空间。右侧内容区用flex: 1自动撑满剩余宽度。两个区域都用scroll-view包裹,但要注意,scroll-view在纵向滚动时必须设置固定高度,否则滚动不会生效——这是新手最容易忽略的一点。

<view class="container"> <scroll-view class="left-nav" scroll-y scroll-into-view="{{leftIntoView}}" scroll-with-animation> <view wx:for="{{categories}}" wx:key="id" id="left-{{index}}" class="nav-item {{activeIndex === index ? 'active' : ''}}" bindtap="onTapCategory" >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以上虚拟列表或分页需重新计算sectionTops

4. 滚动穿透、吸顶与边界情况的处理

分类页面在实际使用中会遇到一些边界情况,不处理的话用户操作起来会很别扭。第一个是滚动穿透问题。如果分类页面上有弹窗或者筛选面板,弹窗打开时手指在弹窗上滑动,底层的分类列表也跟着滚,这就是滚动穿透。解决办法是在弹窗打开时给底层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}}" class="skeleton"> <view class="skeleton-left"> <view class="skeleton-nav-item" wx:for="{{8}}" wx:key="*this"></view> </view> <view class="skeleton-right"> <view class="skeleton-title"></view> <view class="skeleton-grid"> <view class="skeleton-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); }); } }

这套方案我在几个项目中都用过,从十几个分类到三十多个分类的场景都跑过,稳定性没问题。核心就是抓住联动这条主线,把滚动位置计算准,把边界情况处理干净,剩下的就是细节打磨了。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进