前言

sku算法主要用于 电商业务中,只要有商品就有sku商品多规格

本文sku算法主要利用质数()、笛卡尔积、邻接矩阵。(同时参考掘金作者文章

)

1.笛卡尔积

笛卡尔乘积是指在数学中,两个[集合] XY 的笛卡尔积(Cartesian product),又称 [ 直积 ] ,表示为 X × Y,第一个对象是 X 的成员而第二个对象是 Y 的所有可能 [ 有序对 ] 的其中一个成员

假设集合 A = { a, b },集合 B = { 0, 1, 2 },则两个集合的笛卡尔积为 { ( a, 0 ), ( a, 1 ), ( a, 2), ( b, 0), ( b, 1), ( b, 2) }

1.1 
/**
 * 笛卡尔积组装
 * @param {Array} list
 * @returns []
 */
function descartes(list) {
// parent 上一级索引;count 指针计数
let point = {}; // 准备移动指针
let result = []; // 准备返回数据
let pIndex = null; // 准备父级指针
let tempCount = 0; // 每层指针坐标
let temp = []; // 组装当个 sku 结果

// 一:根据参数列生成指针对象
for (let index in list) {
if (typeof list[index] === 'object') {
point[index] = { parent: pIndex, count: 0 };
pIndex = index;
}
}

// 单维度数据结构直接返回
if (pIndex === null) {
return list;
}

// 动态生成笛卡尔积
while (true) {
// 二:生成结果
let index;
for (index in list) {
tempCount = point[index].count;
temp.push(list[index][tempCount]);
}
// 压入结果数组
result.push(temp);
temp = [];

// 三:检查指针最大值问题,移动指针
while (true) {
if (point[index].count + 1 >= list[index].length) {
point[index].count = 0;
pIndex = point[index].parent;
if (pIndex === null) {
return result;
}
// 赋值 parent 进行再次检查
index = pIndex;
} else {
point[index].count++;
break;
}
}
}
}
// eg
const type = ['男裤','女裤']
const color = ['黑色', '白色']
const size = ['S','L']
console.log(this.descartes([type, color,size]))





// 2.例2

// 计算每个sku后面有多少项
export function getLevels(tree) {
let level = []
for (let i = tree.length - 1; i >= 0; i--) {
if (tree[i + 1] && tree[i + 1].specValue) {
level[i] = tree[i + 1].specValue.length * level[i + 1] || 1
} else {
level[i] = 1
}
}
return level
}

/**
* 笛卡尔积运算
* @param {[type]} tree [description]
* @param {Array} stocks [description]
* @return {[type]} [description]
*/
export function flatten(tree, stocks = [], options) {
let { optionValue = 'id', optionText = 'name' } = options || {}
let result = []
let skuLen = 0
let stockMap = {} // 记录已存在的stock的数据
const level = getLevels(tree)
if (tree.length === 0) return result
tree.forEach(sku => {
const { specValue } = sku
if (!specValue || specValue.length === 0) return true
skuLen = (skuLen || 1) * specValue.length
})
// 根据已有的stocks生成一个map
stocks.forEach(stock => {
let { specValueList, ...attr } = stock
stockMap[specValueList.map(item => `${item.specId}_${item.specValueId}`).join('|')] = attr
})
for (let i = 0; i < skuLen; i++) {
let specValueList = []
let mapKey = []
tree.forEach((sku, column) => {
const { specValue } = sku
let item = {}
if (!specValue || specValue.length === 0) return true
if (specValue.length > 1) {
let row = parseInt(i / level[column], 10) % specValue.length
item = tree[column].specValue[row]
} else {
item = tree[column].specValue[0]
}
if (!sku[optionValue] || !item[optionValue]) return
mapKey.push(`${sku[optionValue]}_${item[optionValue]}`)
specValueList.push({
specId: sku[optionValue],
specName: sku[optionText],
id: item[optionValue],
specValue: item[optionText]
})
})
let { ...data } = stockMap[mapKey.join('|')] || {}
// 从map中找出存在的sku并保留其值
result.push({ ...data, specValueList })
}
return result
}

/**
* 判断两个sku是否相同
* @param {[type]} prevSKU [description]
* @param {[type]} nextSKU [description]
* @return {Boolean} [description]
*/
export function isEqual(prevSKU, nextSKU, options) {
let { optionValue = 'id' } = options || {}
return (
nextSKU.length === prevSKU.length &&
nextSKU.every(({ specValue = [] }, index) => {
let prevLeaf = prevSKU[index].specValue || []
return (
prevSKU[index][optionValue] === nextSKU[index][optionValue] &&
specValue.length === prevLeaf.length &&
specValue.map(item => item[optionValue]).join(',') ===
prevLeaf.map(item => item[optionValue]).join(',')
)
})
)
}

/*规格值 specList*/
[
{
"id": "1723895088427335682",
"name": "内存",
"specValue": [
{
"id": "1723895088943235074",
"specId": "1723895088427335682",
"name": "256G"
},
{
"id": "1723895088687382530",
"specId": "1723895088427335682",
"name": "128G"
}
]
},
{
"id": "1770697669254090754",
"name": "巧克力套餐",
"specValue": [
{
"id": "1770697669317005314",
"specId": "1770697669254090754",
"name": "套餐B"
},
{
"id": "1770697669291839490",
"specId": "1770697669254090754",
"name": "套餐A"
},
{
"id": "1770697669337976834",
"specId": "1770697669254090754",
"name": "套餐C"
}
]
}
]

itemSpecDetailList = deepClone(flatten(specList));

//生成的规格值 itemSpecDetailList
[
{
"specValueList": [
{
"specId": "1723895088427335682",
"specName": "内存",
"id": "1723895088943235074",
"specValue": "256G"
},
{
"specId": "1770697669254090754",
"specName": "巧克力套餐",
"id": "1770697669317005314",
"specValue": "套餐B"
}
],
},
{
"specValueList": [
{
"specId": "1723895088427335682",
"specName": "内存",
"id": "1723895088943235074",
"specValue": "256G"
},
{
"specId": "1770697669254090754",
"specName": "巧克力套餐",
"id": "1770697669291839490",
"specValue": "套餐A"
}
],
},
{
"specValueList": [
{
"specId": "1723895088427335682",
"specName": "内存",
"id": "1723895088943235074",
"specValue": "256G"
},
{
"specId": "1770697669254090754",
"specName": "巧克力套餐",
"id": "1770697669337976834",
"specValue": "套餐C"
}
],
},
{
"specValueList": [
{
"specId": "1723895088427335682",
"specName": "内存",
"id": "1723895088687382530",
"specValue": "128G"
},
{
"specId": "1770697669254090754",
"specName": "巧克力套餐",
"id": "1770697669317005314",
"specValue": "套餐B"
}
],
},
{
"specValueList": [
{
"specId": "1723895088427335682",
"specName": "内存",
"id": "1723895088687382530",
"specValue": "128G"
},
{
"specId": "1770697669254090754",
"specName": "巧克力套餐",
"id": "1770697669291839490",
"specValue": "套餐A"
}
],
},
{
"specValueList": [
{
"specId": "1723895088427335682",
"specName": "内存",
"id": "1723895088687382530",
"specValue": "128G"
},
{
"specId": "1770697669254090754",
"specName": "巧克力套餐",
"id": "1770697669337976834",
"specValue": "套餐C"
}
],
}
]

实现思路

2.邻接矩阵

  • 用一个二维数组存放顶点间关系(边或弧)的数据,这个二维数组称为邻接矩阵。
  • 逻辑结构分为两部分:V 和 E 集合,其中,V 是顶点,E 是边。因此,用一个一维数组存放图中所有顶点数据

例如总的sku如下所示

[
["男裤", "黑色", "S"], // S 无号
["男裤", "黑色", "L"],
["男裤", "白色", "S"], // S 无号
["男裤", "白色", "L"],
["女裤", "黑色", "S"], // S 无号
["女裤", "黑色", "L"],
["女裤", "白色", "S"], // S 无号
["女裤", "白色", "L"],
]

当整条规格选中都是 1,才会使整条 SKU 链路可选。

具体内容可阅读倔金作者文章

3.全部代码如下

/* eslint-disable no-underscore-dangle */
/**
* support two level array clone
* @param {*} o
* @returns
*/
function cloneTwo(o) {
const ret = [];
for (let j = 0; j < o.length; j++) {
const i = o[j];
ret.push(i.slice ? i.slice() : i);
}
return ret;
}

/**
* 准备质数
* @param {Int} num 质数范围
* @returns []
*/
export function getPrime(total) {
// 从第一个质数2开始
let i = 2;
const arr = [];
/**
* 检查是否是质数
* @param {Int} number
* @returns
*/
const isPrime = (number) => {
for (let ii = 2; ii < number; ++ii) {
if (number % ii === 0) {
return false;
}
}
return true;
};
// 循环判断,质数数量够完成返回
for (i; arr.length < total; ++i) {
if (isPrime(i)) {
arr.push(i);
}
}
// 返回需要的质数
return arr;
}

/**
* @param {Array} maps 所有得质数集合,每个质数代表一个规格ID
* @param {*} openWay 可用的 SKU 组合
*/
export class PathFinder {
constructor(maps, openWay) {
this.maps = maps;
this.openWay = openWay;
this._way = {};
this.light = [];
this.selected = [];
this.init();
}
/**
* 初始化,格式需要对比数据,并进行初始化是否可选计算
*/
init() {
this.light = cloneTwo(this.maps, true);
const light = this.light;

// 默认每个规则都可以选中,即赋值为1
for (let i = 0; i < light.length; i++) {
const l = light[i];
for (let j = 0; j < l.length; j++) {
this._way[l[j]] = [i, j];
l[j] = 1;
}
}

// 得到每个可操作的 SKU 质数的集合
for (let i = 0; i < this.openWay.length; i++) {
// eslint-disable-next-line no-eval
this.openWay[i] = this.openWay[i].reduce(function (prev, cur) {
return prev * cur
}, 1)
// this.openWay[i] = eval(this.openWay[i].join('*'));
}
// return 初始化得到规格位置,规格默认可选处理,可选 SKU 的规格对应的质数合集
this._check();
}

/**
* 选中结果处理
* @param {Boolean} isAdd 是否新增状态
* @returns
*/
_check(isAdd) {
const light = this.light;
const maps = this.maps;

for (let i = 0; i < light.length; i++) {
const li = light[i];
const selected = this._getSelected(i);
for (let j = 0; j < li.length; j++) {
if (li[j] !== 2) {
// 如果是加一个条件,只在是light值为1的点进行选择
if (isAdd) {
if (li[j]) {
light[i][j] = this._checkItem(maps[i][j], selected);
this.count++;
}
} else {
light[i][j] = this._checkItem(maps[i][j], selected);
this.count++;
}
}
}
}
return this.light;
}
/**
* 检查是否可选内容
* @param {Int} item 当前规格质数
* @param {Array} selected
* @returns
*/
_checkItem(item, selected) {
// 拿到可以选择的 SKU 内容集合
const openWay = this.openWay;
const val = item * selected;
// 拿到已经选中规格集合*此规格集合值
// 可选 SKU 集合反除,查询是否可选
for (let i = 0; i < openWay.length; i++) {
this.count++;
if (openWay[i] % val === 0) {
return 1;
}
}
return 0;
}

/**
* 组合中已选内容,初始化后无内容
* @param {Index} xpath
* @returns
*/
_getSelected(xpath) {
const selected = this.selected;
const _way = this._way;
const retArr = [];
let ret = 1;
if (selected.length) {
for (let j = 0; j < selected.length; j++) {
const s = selected[j];
// xpath表示同一行,当已经被选择的和当前检测的项目再同一行的时候
// 需要忽略。
// 必须选择了 [1, 2],检测的项目是[1, 3],不可能存在[1, 2]和[1, 3]
// 的组合,他们在同一行
if (_way[s][0] !== xpath) {
ret *= s;
retArr.push(s);
}
}
}

return ret;
}

/** 选择可选规格后处理
* @param {array} point [x, y]
*/
add(point) {
point = point instanceof Array ? point : this._way[point];
const val = this.maps[point[0]][point[1]];

// 检查是否可选中
if (!this.light[point[0]][point[1]]) {
throw new Error(
'this point [' + point + '] is no availabe, place choose an other'
);
}

if (this.selected.includes(val)) return;

const isAdd = this._dealChange(point, val);
this.selected.push(val);
this.light[point[0]][point[1]] = 2;
this._check(!isAdd);
}

/**
* 判断是否同行选中
* @param {Array} point 选中内容坐标
* @returns
*/
_dealChange(point) {
const selected = this.selected;
// 遍历处理选中内容
for (let i = 0; i < selected.length; i++) {
// 获取刚刚选中内容的坐标,属于同一行内容
const line = this._way[selected[i]];
if (line[0] === point[0]) {
this.light[line[0]][line[1]] = 1;
selected.splice(i, 1);
return true;
}
}

return false;
}

/**
* 移除已选规格
* @param {Array} point
*/
remove(point) {
point = point instanceof Array ? point : this._way[point];
const val = this.maps[point[0]][point[1]];
if (!val) {
return;
}

if (val) {
for (let i = 0; i < this.selected.length; i++) {
if (this.selected[i] === val) {
const line = this._way[this.selected[i]];
this.light[line[0]][line[1]] = 1;
this.selected.splice(i, 1);
}
}

this._check();
}
}
/**
* 获取当前可用数据
* @returns []
*/
getWay() {
const light = this.light;
const way = cloneTwo(light);
for (let i = 0; i < light.length; i++) {
const line = light[i];
for (let j = 0; j < line.length; j++) {
if (line[j]) way[i][j] = this.maps[i][j];
}
}
return way;
}
}

/**
* 笛卡尔积组装
* @param {Array} list
* @returns []
*/
export function descartes(list) {
// parent上一级索引;count指针计数
const point = {}; // 准备移动指针
const result = []; // 准备返回数据
let pIndex = null; // 准备父级指针
let tempCount = 0; // 每层指针坐标
let temp = []; // 组装当个sku结果

// 一:根据参数列生成指针对象
for (const index in list) {
if (typeof list[index] === 'object') {
point[index] = { parent: pIndex, count: 0 };
pIndex = index;
}
}

// 单维度数据结构直接返回
if (pIndex === null) {
return list;
}

// 动态生成笛卡尔积
while (true) {
// 二:生成结果
let index;
for (index in list) {
tempCount = point[index].count;
temp.push(list[index][tempCount]);
}
// 压入结果数组
result.push(temp);
temp = [];

// 三:检查指针最大值问题,移动指针
while (true) {
if (point[index].count + 1 >= list[index].length) {
point[index].count = 0;
pIndex = point[index].parent;
if (pIndex === null) {
return result;
}

// 赋值parent进行再次检查
index = pIndex;
} else {
point[index].count++;
break;
}
}
}
}

4实战 小程序版本商品sku多规格

<template>
<u-popup v-model="isShow" border-radius="20" closeable @close="handleClose" mode="bottom" :safe-area-inset-bottom="true">
<view class="goods_sku_item">
<view class="goods_main1">
<view class="goods_image">
<image mode="aspectFill" :src="emitInfo.picUrl ? emitInfo.picUrl : goodsDetail.picUrls[0]"></image>
</view>

<view class="goods_info">
<view class="goods_price" v-if="goodsDetail.specType == '1'">¥<text>{{emitInfo.salesPrice ? emitInfo.salesPrice : goodsDetail.skus[0].salesPrice}}</text></view>
<view class="goods_price" v-if="goodsDetail.specType == '0' && !isVip">¥<text>{{emitInfo.salesPrice ? emitInfo.salesPrice : goodsDetail.skus[0].salesPrice}}</text><text class="vip_price">会员价:¥{{goodsDetail.skus[0].memberPrice.toFixed(2)}}</text></view>
<view class="goods_price" v-if="goodsDetail.specType == '0' && isVip"><span class="vip_price_name">会员价: ¥</span><span class="vip_pricess">{{goodsDetail.skus[0].memberPrice.toFixed(2)}}</span><text class="line_throught">¥{{goodsDetail.skus[0].salesPrice}}</text></view>
<view v-if="goodsDetail.specType == '1'"><text style="margin-right: 8rpx;">库存</text><span>{{emitInfo.stock ? emitInfo.stock : ''}}</span></view>
<view v-if="goodsDetail.specType == '0'"><text style="margin-right: 8rpx;">库存</text><span>{{goodsDetail.skus[0].stock ? goodsDetail.skus[0].stock : 0}}</span></view>
<view class="goods_specs" v-if="goodsDetail.specType == '1'">
<text>已选</text>
<text v-for="(item,index) in selected"
:key="index">{{(index == 0) && item ? item + ';' : item + ';' }}</text>
</view>
</view>
</view>
<scroll-view scroll-y="true" style="min-height: 400rpx;">
<view class="specs_list">
<view v-for="(spec, index) in specList" class="specs_item" :key="index">
<view class="specs_title">{{spec.value}}</view>
<view class="specs_text">
<view v-for="(item, i) in spec.leaf" :key="i" :class="{'specs_text_select' : selected.includes(item.value)}"
@click="handleClick(spec, index, item, i, valueInLabel[item.value])">
<view>{{item.value}}</view>
<view class="out_stock" v-if="!unDisabled.includes(valueInLabel[item.value])">缺货</view>
</view>
</view>
</view>
</view>
</scroll-view>
<view class="goods_count UpopSku">
<text>数量</text>
<u-number-box v-model="quantity" :max="emitInfo.stock ? emitInfo.stock : 100" :min="1" bgColor="#540D8D" color="#FFFFFF"></u-number-box>
</view>
<view class="goods_sku_confirm">
<button @click="handleConfirmSelectSku">确认</button>
</view>
</view>
</u-popup>
</template>

<script>
import api from '@/utils/api';
import util from '@/utils/util.js';
import { getPrime, PathFinder,getWay, descartes } from '@/utils/sku.js'
const app = getApp();
export default {
props: {
goodsDetail: {
type: Object,
default: () => {}
},
specList: {
type: Array,
default: () => ([])
},
origin: {
type: String, // 1购物车,2立即购买
default: ''
},
selectedInfo: {
type: Array,
default: () => ([])
}
},
data() {
return {
isShow: true,
quantity: 1,
selected: [], // 已经选中的规格
unDisabled: [], // 可选规格
canUseSku: [], // 可用sku
valueInLabel: {}, // 质数,规格枚举值
// 预留sku工具包
pathFinder: '', //初始化值
types: [], // specList 扁平化
type: [], // specList 未扁平化
prime: [], // 质数
way: [], //排序坐标
sku: [], // 笛卡尔化
emitInfo: {},
isVip: false
}
},

watch: {
},
methods: {
handleClose() {
this.isShow = false;
this.$emit("close");
},
/* 选择规格*/
handleClick(item, index, el, key, prime) {
const { selected, valueInLabel, type:stateType } = this
// 检查此次选择是否在已选内容中
const sIndex = selected.indexOf(el.value);
// 获取已经有的矩阵值
const light = this.pathFinder.light;
if (sIndex > -1) {
this.pathFinder.remove(prime);
selected.splice(sIndex, 1);
} else if (light[index].includes(2)) {
// 如果同规格中,有选中,则先移除选中,
// 获取需要移除的同行规格
const removeType = stateType[index][light[index].indexOf(2)];
// 获取需要提出的同行规格质数
const removePrime = valueInLabel[removeType];
// 移除
this.pathFinder.remove(removePrime);
selected.splice(selected.indexOf(removeType), 1)
//移除同行后,添加当前选择规格
this.pathFinder.add(prime);
selected.push(el.value);
} else {
this.pathFinder.add(prime);
selected.push(el.value);
}
// 更新不可选规格
this.unDisabled = this.pathFinder.getWay().flat();
// 找到该规格对应的数据
this.sku.forEach((item, index) => {
if (this.isArrayEqual(item.skuName, selected)) {
this.emitInfo = item
}
})
},
handleConfirmSelectSku() {
const { selected } = this;
if (selected.length !== this.pathFinder.light.length && this.goodsDetail.specType == '1') {
util.showToast('请选择完整的规格', 'none', 1000)
return
}
if ((this.goodsDetail.specType == '0' && (this.goodsDetail.skus[0].stock <=0)) || (this.goodsDetail.specType == '1' && this.emitInfo.stock <= 0)) {
util.showToast('抱歉,该商品已暂无库存', 'none', 1000)
return
}if ((this.goodsDetail.specType == '0' && (this.goodsDetail.skus[0].stock < this.quantity)) || (this.goodsDetail.specType == '1' && this.emitInfo.stock < this.quantity)) {
util.showToast('库存不足以支撑小主加购的数量哦', 'none', 1000)
return
}
this.$nextTick(() => {
this.emitInfo.quantity = this.quantity;
this.$emit('submitSpec', this.emitInfo);
})
let submitInfo = {};
if (this.goodsDetail.specType == '1') {
submitInfo = {
skuId: this.emitInfo.id,
addPrice: this.emitInfo.salesPrice,
salesPrice: this.emitInfo.salesPrice,
picUrl: this.emitInfo.picUrl,
quantity: this.quantity,
specInfo: this.emitInfo.skuName? this.emitInfo.skuName.join(';') : '',
spuId: this.emitInfo.spuId,
spuName: this.goodsDetail.name
}
} else {
submitInfo = {
skuId: this.goodsDetail.skus[0].id,
// 会员价
addPrice: app.globalData.mallUserInfo.userGrade == -1 ? this.goodsDetail.skus[0].memberPrice : this.goodsDetail.skus[0].salesPrice,
salesPrice: app.globalData.mallUserInfo.userGrade == -1 ? this.goodsDetail.skus[0].memberPrice : this.goodsDetail.skus[0].salesPrice,
picUrl: this.goodsDetail.skus[0].picUrl ? this.goodsDetail.skus[0].picUrl : this.goodsDetail.picUrls[0],
quantity: this.quantity,
specInfo: '',
spuId: this.goodsDetail.skus[0].spuId,
spuName: this.goodsDetail.name
}
}
if (this.origin == '2') {
uni.removeStorageSync('orderInfo');
uni.setStorageSync('orderInfo', Array.from([submitInfo]));
this.$emit('close')
uni.navigateTo({
url: '/pages/shoppingCart/submitOrder/index'
})
} else {
this.$emit('close')
api.addCart(submitInfo).then(res => {
if (res.code == 0) {
util.showToast('加购成功!', 'none', 1000)
this.$emit('close')
} else {
util.showToast(res.msg)
}
}).catch(err => {})
}
},
isArrayEqual(arr1, arr2) {
return arr1.length == arr2.length && arr1.every((ele) => arr2.includes(ele));
},
dealSpecList(list) { //specList 扁平化
let arr = [];
list.forEach(item => {
item.leaf.forEach(el => {
arr.push(el.value)
})
})
return arr
},
uniTree(tree) {
const result = [];
tree.forEach((item, index)=> {
result.push([])
item.leaf.forEach(el => {
result[index].push(el.value)
})
})
return result
}
},
mounted() {
this.isVip = app.globalData.mallUserInfo.userGrade == -1 ? true : false
this.isShow = true;
this.types = this.dealSpecList(this.specList); //扁平化数组,取出所有规格值
this.type = this.uniTree(this.specList)
this.prime = getPrime(this.types.length); // 对应质数
this.types.forEach((item, index) => {
this.valueInLabel[item] = this.prime[index]; // 质数和规格值对应的数组,枚举处理
});
this.way = this.specList.map((i) => { // 根据规格坐标,排序质数坐标
return i.leaf.map(ii => this.valueInLabel[ii.value])
})
this.sku = this.goodsDetail.skus.map((item) => {
let arr1 = []
item.specs.forEach(el => {
arr1.push(el.specValueName)
})
return {
skuPrime: arr1.map(ii => this.valueInLabel[ii]),
skuName: arr1,...item
}
})
this.canUseSku = this.sku.filter(item => item.stock); //有库存的规格
// 初始化规格展示内容
this.pathFinder = new PathFinder(this.way, this.canUseSku.map(item =>item.skuPrime));
// 获取不可选的规格,未回显时 默认都可选
this.unDisabled = this.pathFinder.getWay().flat()
// 已选规格后 回显处理
if (this.selectedInfo && this.selectedInfo.length) {
console.log(this.selectedInfo)
this.selectedInfo.forEach(el => {
this.selected.push(el);
this.pathFinder.add(this.valueInLabel[el]);
})
this.unDisabled = this.pathFinder.getWay().flat();
this.sku.forEach((item, index) => {
if (this.isArrayEqual(item.skuName, this.selected)) {
this.emitInfo = item
}
})
}
}
}
</script>

<style>
@import url("index.css");
</style>