HarmonyOS7 SKU 选择器为什么总写崩?规格组合和库存联动这次讲清
前言
SKU 选择器应该是电商 App 里算法含量最高的组件之一了。规格排列组合、库存联动、无库存自动置灰——这几个需求放一块儿,第一次写的时候我折腾了两天。今天把思路彻底捋清楚,保证你看完能自己写出来。
什么是 SKU
先对齐概念。SPU 是标准化产品单元,比如"iPhone 16"。SKU 是最小库存单元,比如"iPhone 16 / 黑色 / 256GB"。一个 SPU 下面可能有几十种 SKU 组合,每种 SKU 的价格和库存都不一样。
数据结构我这样设计:

// 规格值
interface SpecValue {
id: string
name: string // "黑色"、"白色"、"XL"
}
// 规格组
interface SpecGroup {
id: string
name: string // "颜色"、"尺码"
values: SpecValue[]
}
// SKU 条目
interface SkuItem {
id: string
specIds: string[] // 对应每个规格组的选中值 id,如 ["color_black", "size_xl"]
price: number
stock: number
imageUrl: string
}
// 完整的 SKU 数据
interface SkuData {
specGroups: SpecGroup[]
skuList: SkuItem[]
}
关键是 specIds 这个数组,它定义了每个 SKU 对应的规格组合。后面判断某个规格组合有没有库存,就靠它。
规格矩阵的核心算法
这是整个 SKU 选择器最难的部分。用户在"颜色"里选了"黑色",我需要判断"尺码"里哪些选项还有货。比如"黑色+XL"有货,但"黑色+XXL"没货,那 XXL 就得置灰。
核心思路:对于每个未选的规格组,遍历它的每个规格值,检查"当前已选规格 + 这个规格值"能否组成一个有效 SKU(库存>0)。
// 判断某个规格组合是否可选
isSpecValueAvailable(
currentSelected: Map<string, string>, // groupId -> valueId
groupId: string,
valueId: string
): boolean {
// 构建一个临时的选择状态,把当前要判断的规格值也加进去
const testSelected = new Map(currentSelected)
testSelected.set(groupId, valueId)
// 遍历所有 SKU,看有没有匹配的且有库存的
return this.skuData.skuList.some(sku => {
if (sku.stock <= 0) return false
// 检查这个 SKU 是否包含所有已选的规格值
for (const [gid, vid] of testSelected.entries()) {
const group = this.skuData.specGroups.find(g => g.id === gid)
if (!group) continue
const idx = this.skuData.specGroups.indexOf(group)
if (sku.specIds[idx] !== vid) return false
}
return true
})
}

这段代码跑通了你会发现一个问题:规格组很多的时候性能有点慢。实际上三到四个规格组、每组十来个规格值的情况完全够用,因为总计算量也就几百次遍历。但如果你的商品规格特别多(比如定制类商品),可以考虑提前构建一个"规格组合->SKU"的映射表来加速。
选择联动
用户选完所有规格后,要联动展示价格、库存和商品图。逻辑很简单——找到完全匹配的那个 SKU:
// 找到匹配的 SKU
findMatchedSku(): SkuItem | null {
const allSelected = this.selectedMap.size === this.skuData.specGroups.length
if (!allSelected) return null
return this.skuData.skuList.find(sku => {
return this.skuData.specGroups.every((group, idx) => {
return sku.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)
}
// 联动更新
const matched = this.findMatchedSku()
if (matched) {
this.currentPrice = matched.price
this.currentStock = matched.stock
this.currentImage = matched.imageUrl
}
}

半模态 SKU 选择器
UI 层面用 bindSheet 做半模态弹出,体验跟淘宝、京东一样:
@Component
struct SkuSelector {
@Prop skuData: SkuData
@State selectedMap: Map<string, string> = new Map()
@State currentImage: string = ''
@State currentPrice: number = 0
@State quantity: number = 1
onConfirm?: (skuId: string, qty: number) => void
build() {
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 好搞多了。
更多推荐
所有评论(0)