
前言
在电商平台的开发中,商品SKU
选择器是一个看似简单却蕴含复杂逻辑的组件。传统的实现方式往往依赖于大量的条件判断和嵌套循环,不仅代码臃肿,而且在处理多规格商品时容易产生性能问题和逻辑漏洞。本文将介绍一种基于数学集合理论的创新实现方式,下文将使用uniapp
和利用ES6 的Set
数据结构,快速构建一个优雅的SKU
选择器。
集合的特性
首先简单了解一下集合的特性。集合的特性具有确定性、互异性、无序性。利用集合我们可以不用关注已选中的规格中是否具有相同的规格属性、选择规格的顺序等不确定性,可以很大程度上为我们省去一大部分的计算工作。集合中的交集概念,大家应该都了解吧,这里就不多加赘述了。
一个小栗子,图文展示实现原理
假设我们有一个商品名为 "智能手机" ,该手机具有三种规格:" 颜色 (红色、紫色)、套餐 (套餐一、套餐二)、内存 (64G、128G)",且当前可选 的单品 有三个,单品一 (红色, 套餐一、64G)、单品二 (紫色, 套餐一、128G)、单品三(紫色, 套餐二、128G)。
我们以集合的视角来直观地描绘题中的关键信息:

上图中主要描绘了单个属性是否可选的判断流程,关键在于把属性自身和当前已选中的属性组成一个新集合,然后遍历单品清单,新集合逐个和单品集合进行取交集,如果存在一个交集长度和新集合长度相等 ,则代表新集合 可以和单品集合完全相交,即该属性是可选的,反之则不可选。
核心实现代码
页面结构渲染
html
<!-- 渲染规格选择器 -->
<view class="item" v-for="(item, index) in specList" :key="index">
<view class="tag-list-title">{{item.title}}</view>
<view class="tag-list">
<view class="tag" v-for="(name, j) in item.list" :key="j">
<uni-tag inverted :text="name" :type="isActive(item.title, name)"
:disabled="!isAble(item.title, name)" @click="onChange(item.title, name)"
></uni-tag>
</view>
</view>
</view>
js
// 展示的规格选择器数据结构
specList: [{
title: "颜色",
list: ["红色", "紫色", "白色", "黑色"]
},
{
title: "套餐",
list: ["套餐一", "套餐二", "套餐三", "套餐四"]
},
{
title: "内存",
list: ["64G", "128G", "256G"]
}
],
// 商品数据结构
data: {
id: 6262,
title: '智能手机',
icon: '/static/logo.png',
skuList: [
{
id: 1608188117178,
specs: ["红色", "套餐一", "128G"],
stock: 1,
price: 10
},
{
id: 1608188117179,
specs: ["红色", "套餐一", "256G"],
stock: 2,
price: 10
},
]
}
业务逻辑处理
js
// 当前选中的规格映射
selectedSpecificationObj: {
// '颜色': '红色',
// '套餐': '套餐一',
// '内存': '64G'
},
// 判断当前规格是否可选方法
isAble(key, value) {
// 复制一份已选择的规格
const currentAndSelectedSpecificationObj = {...this.selectedSpecificationObj}
// 把当前判断的值替换到相同规格下
currentAndSelectedSpecificationObj[key] = value
// 创建集合,看是否具有sku和当前选中的规格相交。如何能全部相交,则代表当前值可以选择
const currentSpecificationSet = new Set(Object.values(currentAndSelectedSpecificationObj).filter(Boolean))
const skus = this.data.skuList
for(let i = 0; i < skus.length; i++) {
const sku = skus[i]
const specSet = new Set(sku.specs)
// 判断当前规格和选中的规格集合是否全部相交
if(currentSpecificationSet.size === specSet.intersection(currentSpecificationSet).size) {
return true
}
}
return false
},
// 获取当前规格是否选中状态
isActive(key, value) {
return this.selectedSpecificationObj[key] === value ? 'warning' : ''
},
Set实例方法intersection
intersection()
是Set
原型上的一个方法,该方法可以接收一个Set
实例参数,并返回一个包含此集合和给定集合中元素的新集合,也就是返回两个集合的交集。
SKU选择器完整实现图解

- 初始化时,每个属性默认会执行一遍
isAble()
方法,对自身进行初始化。计算出自身可选或不可选。 - 初始化完成,用户选择可选的属性并保存到已选中的属性当中。
- 已选中属性改变,自动触发
isAble()
方法重新计算。在方法中copy
一份已选中属性,并把已选中属性相同规格的key
值替换为参数的属性值,最后通过组合的对象创建一个新集合。 - 遍历单品集合。如果存在,新集合的长度
=
新集合和单品集合取交集的长度,则单品中存在该属性与已选中属性组合的单品,即该属性可选,反之则不可选。 isAble()
方法执行结束并返回该属性是否可选。
执行性能优化
使用Map
对已计算过的规格组合进行缓存,已计算过的规格组合作为key
,值为计算的结果(true
或false
)。在执行计算前,先把当前组合的规格key
从Map
中取计算过的值,如果存在直接返回该结果,如果不存在,则正常遍历所有单品进行计算。
js
// 缓存已经做过判断的规格
cacheSelectedSpecificationMap: new Map()
// 判断当前规格是否可选方法
isAble(key, value) {
// 复制一份已选择的规格
const currentAndSelectedSpecificationObj = {...this.selectedSpecificationObj}
// 把当前判断的值替换到相同规格下
currentAndSelectedSpecificationObj[key] = value
// 缓存的对象字符串key值
const cacheKey = JSON.stringify(currentAndSelectedSpecificationObj)
// 已缓存,则使用缓存中的值
if(this.cacheSelectedSpecificationMap.has(cacheKey)) {
return this.cacheSelectedSpecificationMap.get(cacheKey)
}
// console.log('isAble')
// 创建集合,看是否具有sku和当前选中的规格相交。如何能全部相交,则代表当前值可以选择
const currentSpecificationSet = new Set(Object.values(currentAndSelectedSpecificationObj).filter(Boolean))
const skus = this.data.skuList
for(let i = 0; i < skus.length; i++) {
const sku = skus[i]
const specSet = new Set(sku.specs)
// 判断当前规格和选中的规格集合是否全部相交
if(currentSpecificationSet.size === specSet.intersection(currentSpecificationSet).size) {
// 缓存计算结果
this.cacheSelectedSpecificationMap.set(cacheKey, true)
return true
}
}
// 缓存计算结果
this.cacheSelectedSpecificationMap.set(cacheKey, false)
return false
},
总结
上文主要通过单个属性 自行和已选中的属性 进行组合 ,使用Set
(集合)的新特性intersection
(取交集)方法,使组合 后的新集合 与可选单品的属性集合 进行取交集 ,最后取交集长度 和组合的集合长度 进行比较,如果长度相等 则代表该属性可选。其中关键在于正确记录已选中规格的数据,每个属性的判断都得依托已选中的规格。
有什么问题,欢迎大家评论区进行讨论,谢谢大家!
(uniapp)组件实现源码
js
<template>
<uni-popup ref="popup" type="center">
<view class="card">
<view class="close-box" @click="close">
<view class="close-icon">
<uni-icons type="close" :size="26"></uni-icons>
</view>
</view>
<view class="goods-info">
<view class="img" @click="previewIcon" v-if="goodsIcon">
<image :src="goodsIcon"></image>
</view>
<view class="title-box">
<text class="title">{{data.title}}</text>
<text>总库存: {{totalSkuStock}}</text>
</view>
</view>
<view class="scroll">
<!-- 渲染规格选择器 -->
<view class="item" v-for="(item, index) in specList" :key="index">
<view class="tag-list-title">{{item.title}}</view>
<view class="tag-list">
<view class="tag" v-for="(name, j) in item.list" :key="j">
<uni-tag inverted :text="name" :type="isActive(item.title, name)"
:disabled="!isAble(item.title, name)" @click="onChange(item.title, name)"
></uni-tag>
</view>
</view>
</view>
</view>
<view class="footer">
<view class="handle-num">
<view class="stock-info">
<text>单品库存:</text>
<text v-if="selectedSku.stock === undefined">-</text>
<text v-else>{{selectedSku.stock}}</text>
</view>
<view class="number-box">
<text>购买数量:</text>
<uni-number-box v-model="buyAmount" :min="0" :max="selectedSku.stock" :disabled="!isCompeleteSelectSpecification"></uni-number-box>
</view>
</view>
<view class="">
<view class="left">
<view class="total-price price" v-if="totalPrice">
<text>总价: ¥</text>
<text class="price">{{totalPrice}}</text>
</view>
<view class="single-price price" v-else>
<text>单价: ¥</text>
<view style="display: inline-block;">
<!-- 最小价格为0不显示,所以商品价格不能设置为0 -->
<text v-if="minPrice !== maxPrice">{{minPrice}}</text>
<text v-if="maxPrice && minPrice !== maxPrice">-</text>
<text>{{maxPrice}}</text>
</view>
</view>
</view>
<view class="right">
<uni-tag text="加入购物车" type="warning" @click="addCart"></uni-tag>
</view>
</view>
</view>
</view>
</uni-popup>
</template>
<script>
export default {
props: {
// 商品数据{ icon: '', titile: '', skuList: [{specs: ["红色", "套餐一", "128G"], stock: 1, price: 10}] }
data: {
type: Object,
default: () => ({})
},
// 规格数据
specList: {
type: Array,
default: []
}
},
data() {
return {
// 当前选中的规格映射
selectedSpecificationObj: {
// '颜色': '红色',
// '套餐': '套餐一',
// '内存': '64G'
},
// 购买数量
buyAmount: 0,
// 缓存已经做过判断的规格
cacheSelectedSpecificationMap: new Map()
}
},
computed: {
// 所有sku的价格清单
skuPriceList() {
return this.data.skuList.map(item => item.price)
},
// sku的最低价
minPrice() {
return Math.min(...this.skuPriceList)
},
// sku的最高价
maxPrice() {
return Math.max(...this.skuPriceList)
},
// 所有单品总库存
totalSkuStock() {
return this.data.skuList.reduce((total, cur) => total + cur.stock, 0)
},
// 选中规格的集合
selectedSpecificationSet() {
return new Set(Object.values(this.selectedSpecificationObj).filter(Boolean))
},
// 规格是否选择完成
isCompeleteSelectSpecification() {
return this.specList.length === this.selectedSpecificationSet.size
},
// 当前选中的单品信息
selectedSku() {
if(!this.isCompeleteSelectSpecification) {
return {}
}
return this.data.skuList.find(sku => {
const specSet = new Set(sku.specs)
return this.selectedSpecificationSet.size === specSet.intersection(this.selectedSpecificationSet).size
}) || {}
},
// 当前选中的单品总价
totalPrice() {
return this.selectedSku.price * this.buyAmount
},
goodsIcon() {
return this.data.icon || this.selectedSku.icon
}
},
methods: {
// 初始化已选规格映射对象方法
init() {
// 初始化所有的可选的规格映射
const obj = {}
this.specList.forEach(item => {
obj[item.title] = ''
})
this.selectedSpecificationObj = obj
},
// 选择规格完成处理方法
addCart() {
console.log(this.cacheSelectedSpecificationMap)
if(!this.isCompeleteSelectSpecification) {
for(const key in this.selectedSpecificationObj) {
if(!this.selectedSpecificationObj[key]) {
return uni.showToast({
title: `请选择${key}`,
icon: 'none'
})
}
}
}
if(!this.buyAmount) {
return uni.showToast({
title: `购买数量必须大于0`,
icon: 'none'
})
}
this.$emit('addCart', {
sku: { ...this.selectedSku },
buyAmount: this.buyAmount
})
this.close()
},
// 选中规格改变方法
onChange(key, value) {
if (this.selectedSpecificationObj[key] === value) {
this.selectedSpecificationObj[key] = ''
} else {
this.selectedSpecificationObj[key] = value
}
// 规格改变重置购买数量
this.buyAmount = this.isCompeleteSelectSpecification ? 1 : 0
},
// 判断当前规格是否可选方法
isAble(key, value) {
// 复制一份已选择的规格
const currentAndSelectedSpecificationObj = {...this.selectedSpecificationObj}
// 把当前判断的值替换到相同规格下
currentAndSelectedSpecificationObj[key] = value
// 缓存的对象字符串key值
const cacheKey = JSON.stringify(currentAndSelectedSpecificationObj)
// 已缓存,则使用缓存中的值
if(this.cacheSelectedSpecificationMap.has(cacheKey)) {
return this.cacheSelectedSpecificationMap.get(cacheKey)
}
// console.log('isAble')
// 创建集合,看是否具有sku和当前选中的规格相交。如何能全部相交,则代表当前值可以选择
const currentSpecificationSet = new Set(Object.values(currentAndSelectedSpecificationObj).filter(Boolean))
const skus = this.data.skuList
for(let i = 0; i < skus.length; i++) {
const sku = skus[i]
const specSet = new Set(sku.specs)
// 判断当前规格和选中的规格集合是否全部相交
if(currentSpecificationSet.size === specSet.intersection(currentSpecificationSet).size) {
// 缓存计算结果
this.cacheSelectedSpecificationMap.set(cacheKey, true)
return true
}
}
// 缓存计算结果
this.cacheSelectedSpecificationMap.set(cacheKey, false)
return false
},
// 获取当前规格是否选中状态
isActive(key, value) {
return this.selectedSpecificationObj[key] === value ? 'warning' : ''
},
open() {
this.$refs.popup.open()
},
close() {
this.$refs.popup.close()
},
previewIcon() {
console.log('previewIcon')
uni.previewImage({
current: 0,
urls: [this.goodsIcon]
})
}
},
created() {
this.init()
}
}
</script>
<style lang="scss" scoped>
.price {
color: #ff5500;
}
.card {
display: flex;
flex-direction: column;
position: relative;
width: 650rpx;
max-height: 80vh;
box-sizing: border-box;
// height: 800rpx;
background-color: #FFFFFF;
padding: 20rpx;
padding-top: 0;
border-radius: 6rpx;
// overflow-y: auto;
overflow: hidden;
.goods-info {
display: flex;
.img {
display: inline-block;
margin-right: 20rpx;
image {
width: 200rpx;
height: 200rpx;
}
}
.title-box {
display: flex;
flex-direction: column;
justify-content: space-between;
padding: 10rpx 0;
box-sizing: border-box;
// align-items: ;
// align-items: center;
.title {
font-weight: 600;
font-size: 36rpx;
margin-bottom: 20rpx;
text-overflow: -o-ellipsis-lastline;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 2;
line-clamp: 2;
-webkit-box-orient: vertical;
box-sizing: border-box;
}
}
}
.scroll {
// padding: 20rpx;
margin: 20rpx 0;
flex: 1;
overflow: hidden auto;
.tag-list-title {
padding: 20rpx 0;
}
.tag-list {
display: flex;
flex-wrap: wrap;
gap: 20rpx;
}
// .tag {
// margin: 10rpx 20rpx 10rpx 0;
// }
}
.footer {
height: 80px;
border-top: 1px solid #F0F0F0;
// display: flex;
// justify-content: space-between;
// align-items: center;
position: relative;
bottom: 0;
left: 0;
right: 0;
padding: 10rpx 20rpx;
>view {
display: flex;
justify-content: space-between;
align-items: center;
}
.handle-num {
margin-bottom: 20rpx;
font-size: 26rpx;
.number-box {
display: flex;
align-items: center;
}
}
}
}
.close-box {
width: 100%;
text-align: right;
padding-top: 10rpx;
position: sticky;
top: 0;
background-color: #FFFFFF;
.close-icon {
// display: inline-block;
// width: 80rpx;
// height: 80rpx;
// border-radius: 50%;
// border: 2rpx solid #FFFFFF;
// font-size: 50rpx;
// color: #FFFFFF;
// text-align: center;
// background-color: rgba($color: #fff, $alpha: .2);
// margin-top: 10rpx;
}
}
</style>