前言

SKU 选择器应该是电商 App 里算法含量最高的组件之一了。规格排列组合、库存联动、无库存自动置灰——这几个需求放一块儿,第一次写的时候我折腾了两天。今天把思路彻底捋清楚,保证你看完能自己写出来。

什么是 SKU

先对齐概念。SPU 是标准化产品单元,比如"iPhone 16"。SKU 是最小库存单元,比如"iPhone 16 / 黑色 / 256GB"。一个 SPU 下面可能有几十种 SKU 组合,每种 SKU 的价格和库存都不一样。

数据结构我这样设计:

A clean Notion-style technical diagram illustratin

// 规格值
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
  })
}

A logic flowchart designed in a Notion aesthetic e

这段代码跑通了你会发现一个问题:规格组很多的时候性能有点慢。实际上三到四个规格组、每组十来个规格值的情况完全够用,因为总计算量也就几百次遍历。但如果你的商品规格特别多(比如定制类商品),可以考虑提前构建一个"规格组合->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
  }
}

A UI wireframe sketch in Notion style depicting a

半模态 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 好搞多了。

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐