文章目录
- 前言
- 什么是 SKU
- 规格矩阵的核心算法
- 选择联动
- 半模态 SKU 选择器
- 使用方式
- 踩过的坑
前言
SKU 选择器应该是电商 App 里算法含量最高的组件之一了。规格排列组合、库存联动、无库存自动置灰——这几个需求放一块儿,第一次写的时候我折腾了两天。今天把思路彻底捋清楚,保证你看完能自己写出来。
什么是 SKU
先对齐概念。SPU 是标准化产品单元,比如"iPhone 16"。SKU 是最小库存单元,比如"iPhone 16 / 黑色 / 256GB"。一个 SPU 下面可能有几十种 SKU 组合,每种 SKU 的价格和库存都不一样。
数据结构我这样设计:
// 规格值interfaceSpecValue{id:stringname:string// "黑色"、"白色"、"XL"}// 规格组interfaceSpecGroup{id:stringname:string// "颜色"、"尺码"values:SpecValue[]}// SKU 条目interfaceSkuItem{id:stringspecIds:string[]// 对应每个规格组的选中值 id,如 ["color_black", "size_xl"]price:numberstock:numberimageUrl:string}// 完整的 SKU 数据interfaceSkuData{specGroups:SpecGroup[]skuList:SkuItem[]}关键是specIds这个数组,它定义了每个 SKU 对应的规格组合。后面判断某个规格组合有没有库存,就靠它。
规格矩阵的核心算法
这是整个 SKU 选择器最难的部分。用户在"颜色"里选了"黑色",我需要判断"尺码"里哪些选项还有货。比如"黑色+XL"有货,但"黑色+XXL"没货,那 XXL 就得置灰。
核心思路:对于每个未选的规格组,遍历它的每个规格值,检查"当前已选规格 + 这个规格值"能否组成一个有效 SKU(库存>0)。
// 判断某个规格组合是否可选isSpecValueAvailable(currentSelected:Map<string,string>,// groupId -> valueIdgroupId:string,valueId:string):boolean{// 构建一个临时的选择状态,把当前要判断的规格值也加进去consttestSelected=newMap(currentSelected)testSelected.set(groupId,valueId)// 遍历所有 SKU,看有没有匹配的且有库存的returnthis.skuData.skuList.some(sku=>{if(sku.stock<=0)returnfalse// 检查这个 SKU 是否包含所有已选的规格值for(const[gid,vid]oftestSelected.entries()){constgroup=this.skuData.specGroups.find(g=>g.id===gid)if(!group)continueconstidx=this.skuData.specGroups.indexOf(group)if(sku.specIds[idx]!==vid)returnfalse}returntrue})}这段代码跑通了你会发现一个问题:规格组很多的时候性能有点慢。实际上三到四个规格组、每组十来个规格值的情况完全够用,因为总计算量也就几百次遍历。但如果你的商品规格特别多(比如定制类商品),可以考虑提前构建一个"规格组合->SKU"的映射表来加速。
选择联动
用户选完所有规格后,要联动展示价格、库存和商品图。逻辑很简单——找到完全匹配的那个 SKU:
// 找到匹配的 SKUfindMatchedSku():SkuItem|null{constallSelected=this.selectedMap.size===this.skuData.specGroups.lengthif(!allSelected)returnnullreturnthis.skuData.skuList.find(sku=>{returnthis.skuData.specGroups.every((group,idx)=>{returnsku.specIds[idx]===this.selectedMap.get(group.id)})})??null}// 选中后联动onSpecSelect(groupId:string,valueId:string){// 切换选中if(this.selectedMap.get(groupId)===valueId){this.selectedMap.delete(groupId)// 取消选择}else{this.selectedMap.set(groupId,valueId)}// 联动更新constmatched=this.findMatchedSku()if(matched){this.currentPrice=matched.pricethis.currentStock=matched.stockthis.currentImage=matched.imageUrl}}半模态 SKU 选择器
UI 层面用bindSheet做半模态弹出,体验跟淘宝、京东一样:
@Componentstruct SkuSelector{@PropskuData:SkuData@StateselectedMap:Map<string,string>=newMap()@StatecurrentImage:string=''@StatecurrentPrice:number=0@Statequantity:number=1onConfirm?:(skuId:string,qty:number)=>voidbuild(){Column(){// 顶部商品信息区Row(){Image(this.currentImage||this.skuData.skuList[0]?.imageUrl).width(100).height(100).borderRadius(8)Column(){Text(`¥${(this.currentPrice/100).toFixed(2)}`).fontSize(20).fontColor('#FF4D4F').fontWeight(FontWeight.Bold)Text(`库存:${this.currentStock}`).fontSize(12).fontColor('#999').margin({top:4})Text(`已选:${this.getSelectedText()}`).fontSize(12).fontColor('#666').margin({top:4})}.alignItems(HorizontalAlign.Start).margin({left:12})}.width('100%').padding(16)Divider()// 规格选择区Scroll(){Column(){ForEach(this.skuData.specGroups,(group:SpecGroup)=>{Column(){Text(group.name).fontSize(14).fontWeight(FontWeight.Medium).margin({bottom:8})Flex({wrap:FlexWrap.Wrap}){ForEach(group.values,(val:SpecValue)=>{Text(val.name).fontSize(13).padding({left:14,right:14,top:6,bottom:6}).borderRadius(16).backgroundColor(this.getSpecBg(group.id,val.id)).fontColor(this.getSpecColor(group.id,val.id)).margin({right:8,bottom:8}).onClick(()=>this.onSpecSelect(group.id,val.id))})}}.width('100%').padding({left:16,right:16,top:12})})// 数量选择Row(){Text('数量').fontSize(14)Blank()QuantityStepper({value:this.quantity,max:this.currentStock})}.width('100%').padding(16)}}.layoutWeight(1)// 确认按钮Button('确定').width('92%').height(44).borderRadius(22).backgroundColor('#FF4D4F').fontColor('#FFFFFF').margin({bottom:20}).onClick(()=>this.handleConfirm())}.width('100%')}}规格按钮的样式根据选中状态和无库存状态变化,这部分抽成方法读起来更清晰:
getSpecBg(groupId:string,valueId:string):ResourceColor{if(this.selectedMap.get(groupId)===valueId)return'#FFE8E8'if(!this.isSpecValueAvailable(this.selectedMap,groupId,valueId))return'#F5F5F5'return'#FFFFFF'}getSpecColor(groupId:string,valueId:string):ResourceColor{if(this.selectedMap.get(groupId)===valueId)return'#FF4D4F'if(!this.isSpecValueAvailable(this.selectedMap,groupId,valueId))return'#CCCCCC'return'#333333'}使用方式
商品详情页弹出 SKU 选择器,用bindSheet一行搞定:
Image($r('app.media.btn_buy')).onClick(()=>{this.showSkuSheet=true}).bindSheet($$this.showSkuSheet,SkuSelector({skuData:this.skuData,onConfirm:(skuId,qty)=>{this.addToCart(skuId,qty)}}),{height:SheetSize.FIT_CONTENT,dragBar:true})踩过的坑
取消选中导致已选规格无效的问题。用户在"颜色"里从"黑色"切换到"白色",这时候之前选的"XL"可能没库存了。切换规格后要重新校验其他规格组里的选中值,如果失效就自动清空。
只有一组规格的时候。别写死两层嵌套的逻辑,用ForEach动态渲染规格组,代码通用性好很多。
SKU 选择器写好了是个很通用的组件,稍微改改能用在各种需要"多维规格选择"的场景。下一篇我们进入订单确认页,那个页面的难点在优惠券计算逻辑,比 SKU 好搞多了。