❝郑瑜栋:在团队拥有一个奇怪的外号“=”哥,hh , 咱也是有标点(签)的人了
❞
商品 SKU 的创建与查询,是「电商业务」最经典的开发场景之一,也是整个电商系统「最基础」的功能。因为假如缺少了它,那么也许连准确描述定位一件商品,这样最基本的需求,都将变得困难重重,商品的「库存管理」也就无处谈起。
如图所示,整体流程可分为两部分:
「Demo 项目简介:」
npm start
启动项目后,会默认进入一个模拟「运营端」的商品配置页。如下图:
tip: 点击页面左上角的入口按钮可以进行运营端与用户端的切换。
整体操作:就是运营通过「操作左侧各种规格选项,联动生成右侧的 SKU 列表」。
运营对规格选项的操作大致可分为两类:
颜色,尺寸,款式
,现在增加一组套餐
属性, 那之前的所有 SKU 组合都需要和套餐
再次组合,并且之前配置的库存数据也都失效了。
为了降低设计的复杂度,采用了如下的方案:「不区分操作,无论哪种操作类型,统一都走重新组合生成的逻辑」。(当然如果十分在乎性能,也可以通过区分操作进行优化,类似于 vue 中针对不同 dom 操作类型所进行的 domDiff 效率方面的优化)。那么问题就抽象为:「求商品规格列表的全排列组合」。这个过程是一个典型的树形结构,需要遍历到这颗树的每一个叶子,最终将叶子收集起来。
感兴趣的可以看下 leetcode 上组合[2]这道题,解题思路很类似。
对于层数不固定的 Tree 结构数据,首先可以想到用「递归」的思路求解:
代码位置:src/views/CreateSKU.tsx
/**
* @description 递归版本的SKU全排列组合
* @param attrList 属性列表
* @param prevSkuList 上一次的sku列表数据
* @returns 新的sku列表数据
*/
export function createSkuList(attrList:AttrList,prevSkuList:SkuList = []):SkuList{
const skuList:SkuList = [];//收集结果
let id = 0;//生成skuId
// 旧的SkuList转map,方便下方的复用判断
const prevSkuMap = skuList2Map(prevSkuList);
const loop = (rowIndex:number,prevOptions:AttrSetItem[])=>{
const attrItem = attrList[rowIndex];
if(attrItem.options.length === 0){
loop(rowIndex +1,prevOptions)
return
}
for(const option of attrItem.options){
const curOptions = prevOptions.concat({
label:attrItem.attrLabel,
value:option.value
});
//判断如果是最后一层,那就是组合完整了,将结果收集到全局的容器里
if(rowIndex === attrList.length - 1){
id ++;
// 将sku的选项值用'_'连接起来组成一个key
const key = curOptions.map(({value})=>value).join('_');
// 如果改变前后的sku key相同,复用sku数据,避免数据覆盖
if(prevSkuMap[key]){
skuList.push({
...prevSkuMap[key],
id:`${id}`
})
}else{
skuList.push({
id:`${id}`,
key,
attrSet:curOptions,
stock:0
})
}
}else{
loop(rowIndex +1,curOptions)
}
}
}
loop(0,[])
return skuList
}
/**
* @description sku列表数据转map,方便映射查找,判断sku数据对比复用
* @param skuList sku列表
* @returns skuKey做键,sku数据做值的sku查找映射
*/
function skuList2Map(skuList:SkuList){
return skuList.reduce<{[skuKey:string]:SkuInfo}>((map,sku)=>{
map[sku.key] = sku;
return map
},{})
}
除了递归,也可以用「循环」来求解。 但他们本质其实是一样的,都是 n^m 的时间复杂度,循环只是靠迭代中的临时变量 tempList,模拟了递归里栈的概念,所以与递归版本的区别就是使用原生的函数调用栈还是自己代码里的栈。
/**
* @description 循环版本的SKU全排列组合
* @param attrs 属性列表
* @param prevSkuList 上一次的sku列表数据
* @returns 新的sku列表数据
*/
export function createSkuList1(attrList:AttrList,prevSkuList:SkuList = []):SkuList{
let skuList:SkuList = [];
let id = 0;//用来生成skuId
// 旧的skuList转下map,方便下方的复用判断
const prevSkuMap = skuList2Map(prevSkuList)
//1. 遍历规格大类
attrList.forEach((row)=>{
if(row.options.length === 0){
return
}
// 初始化第一层
if(skuList.length === 0){
skuList = row.options.map((option)=>{
id ++
const attrSet = [{label:row.attrLabel,value:option.value}]
return {
id:`${id}`,
key:attrSet.map(({value})=>value).join('_'),
attrSet,
stock:0
}
});
}else{
const tempList:SkuList = [];
id = 0;
//2. 遍历当前已累积的规格组合
skuList.forEach((skuItem)=>{
//3. 遍历当前规格值,并将值与所有已累积的规格进行拼接
row.options.forEach((option)=>{
id ++;
const attrSet = skuItem.attrSet.concat({label:row.attrLabel,value:option.value});
const key = attrSet.map(({value})=>value).join('_');
// 如果改变前后的sku key相同,复用sku数据,避免覆盖
if(prevSkuMap[key]){
tempList.push({
...prevSkuMap[key],
id:`${id}`
})
}else{
tempList.push({
id:`${id}`,
key,
attrSet,
stock:0
})
}
})
});
if(row.options.length > 0){
skuList = tempList
}
}
})
return skuList;
}
/**
* @description sku列表数据转map,方便映射查找,判断sku数据对比复用
* @param skuList sku列表
* @returns skuKey做键,sku数据做值的sku查找映射
*/
function skuList2Map(skuList:SkuList){
return skuList.reduce<{[skuKey:string]:SkuInfo}>((map,sku)=>{
map[sku.key] = sku;
return map
},{})
}
具体的代码不展开讲解了。但是有一点是需要格外注意的:
SKU 列表里的数据,虽然「每次都重新生成一遍」,但针对之前已经配置过的内容数据(比如库存数量)在「重新生成前后,SKU 组合并无改变的情况下,是需要保留的」,不然这些数据就全丢失了,所以在创建时就给每条 sku 数据定义一个「key」(包含的选项值拼接而成的字符串)。eg:对于选项组合为[M,红,宽松]
的 sku,M_红_宽松
就作为它的一个唯一 key 标示,在 SKU 重新创建时,会拿着新生成的 key 在旧的 sku 数据里的查找一样的,如果可以找到,就「复用原来的数据」,这样就避免了重新生成后导致的原数据覆盖丢失。
这样运营端 SKU 的生成部分工作就完成了,下边讲用户端 SKU 的查询。
这是 SKU 查询功能的截图,这里产品经理一般都会提一个交互需求:「希望根据用户当前已选的规格值,能够预判出哪些 SKU 组合是缺货状态,提前将对应按钮置灰。」
比如上方截图里,[红色,M,男款]
缺货,在选择红色
、M
后,就需要提前将男款
置灰。
反着选,先选后两行的M
、男款
,置灰第一行的红色
。
先选前后两行红色
、男款
,需要置灰中间的M
。
这还是只一个 SKU 缺货,如果两个 SKU 缺货呢?
比如[红色,M,男款]
、[红色,M,女款]
缺货,也就是红色
的M
号都缺货。
在这种情况下,只选择一个红色
,就需要判断出M
不可选。
1-2 个 sku 缺货已经需要有上边那么多置灰的情况需要判断,那么更多的 sku 缺货呢,想想就头大,需要判断的情况太多了,或者根本不知道从何判断。
不过通过以上的场景推演,也能总结出一些导致问题变得复杂的原因:
动手实现之前,先了解下已知条件:
const attrList = [
{
"attrLabel": "颜色",
"options": [
{
"value": "红色",
"disabled": true
},
{
"value": "绿色",
"disabled": true
},
{
"value": "蓝色",
"disabled": true
}
]
},
...
]
const skuList = [
{
"id": "1",
"key": "红色_M_男款",
"attrSet": [
{
"label": "颜色",
"value": "红色"
},
{
"label": "尺寸",
"value": "M"
},
{
"label": "款式",
"value": "男款"
}
],
"stock": 0
},
...
]
根据上边整理的思路,写出以下代码
/**
* @description 属性的选项状态
* @param attrList 属性列表
* @param skuList sku列表数据
*/
function setAttrOptionStatus(attrList:AttrList, skuList:SkuList) {
// 1.获取已选规格集合{A}
const selectedSet = attrList.reduce<{[props:string]:string}>((arr, item) => {
item.currentValue && (arr[item.attrLabel] = item.currentValue);
return arr
}, {})
// 2.遍历所有待选规格
attrList.forEach((attr) => {
attr.options.forEach((option) => {
if (option.isChecked) {
return
}
// 3.待选项{x}与已选项{A}组成新的集合B = {A,x}
const nextSelectSet = {...selectedSet,[attr.attrLabel]:option.value}
const keys = Object.keys(nextSelectSet);
/*
4.遍历sku列表,
看能否找到符合以下两种条件的sku
(1)选项匹配:找到sku对应的规格集合C,判断B与C是否具有包含关系 B <= C ?
(2)有货的:判断库存
查找结果为否,则此按钮需要置灰,反之亦然。
*/
option.disabled = skuList.findIndex((sku) => {
return keys.every((attrKey)=> sku.stock > 0 && sku.attrSet.findIndex((option)=>{
return option.value === nextSelectSet[attrKey]
}) > -1)
}) === -1;
})
})
return attrList
}
「以空间换时间:」 既然用户选择的路径、顺序、个数都不固定,那么组成的选项集合是难以预测的,那么是不是可以「提前把用户所有可能选到的 sku 组合都穷举出来」。这样在匹配查询时效率就会快很多了,因为这本质上是因为将遍历查询匹配的工作前置了。
「具体思路」:就是根据穷举法的思想,提前计算好一个包含任意规格组合(表示用户任意的选择路径)的 Map 映射。
首先就是将每个 sku 组合的进行拆分,比如[红色,M,男款]
就可以拆成(2^n)也就是 8 组子选项。这可以抽象为一个求幂集的操作。
/**
* @description js求幂集
* @param arr
* @returns
*/
function powerset(arr:string[]) {
const ps:string[][] = [[]];
for (let i = 0; i < arr.length; i++) {
for (let j = 0, len = ps.length; j < len; j++) {
ps.push(ps[j].concat(arr[i]));
}
}
return ps;
}
有了这个求幂集方法就可以提前计算出这个任意规格组合的 Map 了
interface AnyOptionSkuMap{
[key:string]:SkuList
}
/**
* @description 计算一个包含任意规格组合的sku映射
* @param skuList sku列表数据
* @returns
*/
function computedSkuResultMap(skuList:SkuList) {
const map:AnyOptionSkuMap = {}
skuList.forEach((sku) => {
if (sku.stock <= 0) {//没货的,不往结果里塞
return
}
const ids = sku.attrSet.map((item) => item.value)
const keysArr = powerset(ids);
keysArr.forEach((keyArr) => {
const key = keyArr.join('_')
const v = map[key];
map[key] = v ? [...v, sku] : [sku]
})
})
return map
}
计算结果如下:
接下来实现查询主函数 src/views/SearchSKU_Map.tsx
/**
* @description 根据当前所选属性值,更新属性按钮的禁用状态 => map版本
* @param saleAttrs
* @param skuList
* @returns
*/
function setAttrOptionStatus(attrList:AttrList, skuMap:AnyOptionSkuMap) {
// 1.获取已选规格集合{A}
const selectedSet = attrList.reduce<{[props:string]:string}>((arr, item) => {
item.currentValue && (arr[item.attrLabel] = item.currentValue);
return arr
}, {})
// 2.遍历所有待选规格
attrList.forEach((attr) => {
attr.options.forEach((option) => {
if (option.isChecked) {
return
}
// 3.待选项{x}与已选项{A}组成新的集合B = {A,x}
const nextSelectSet = {...selectedSet,[attr.attrLabel]:option.value}
/*
4.将集合B的元素值拼一个字符串的key,去提前计算好的skuMap字典里查找
若无查找结果,则此按钮需要置灰,反之亦然。
*/
const valueArr = attrList.map(({ attrLabel }) => nextSelectSet[attrLabel]).filter(Boolean)
const sku = skuMap[valueArr.join('_')]
option.disabled = sku === undefined;
})
})
}
这里的 1、2、3 步骤与上边的第一种暴力循环法,是一致的,区别是第 4 步的。不再是去 skuList 里复杂的遍历匹配,而是「简单的通过判断 key(由集合 B 的选项值按固定的顺序拼成的一个字符串)是否在结果 Map 中存在」即能知道对应的按钮是否需要缺货置灰。
「优点:」 「查询的时间复杂度降为了 O1」,因为复杂的遍历匹配过程简化为了一次 map 查找,非常快。
「缺点:」 一次幂集拆分就有 2^n 个子集。全部 sku 就有 n^m 乘以 2^n 个键值对,最终如上图所示,拆出的键值对非常多,非常陇余。初始化较慢,「计算出的 map 键值对非常多,造成空间浪费」。
两种方案的优缺点都很明显,但其实还有第三种无向图方案,可以取一个均衡。大意是用邻接矩阵的形式,可以在查询中多一个维度的信息,减少遍历范围,在大量属性数据下是有明显的性能意义的(但鸽了许久没有完成)。以上介绍的两种方案应付大多数的业务场景应该都是没有问题的,也都是使用率很高的两种解法,读者碰到类似场景可以按需选择。
sku-demo: https://github.com/FEyudong/sku-demo
[2]组合: https://leetcode-cn.com/problems/combinations/
LBG开源项目推广:
还在手写 HTML 和 CSS 吗?
还在写布局吗?
快用 Picasso(毕加索) 吧!
一键生成高可用的前端代码,让你有更多的时间去沉淀和成长
开源项目地址:https://github.com/wuba/Picasso (欢迎Star)
官网地址:https://picassoui.58.com