sunilwang

V1

2022/07/26阅读:37主题:绿意

商品SKU功能设计与优化(前端)

作者简介

郑瑜栋:在团队拥有一个奇怪的外号“=”哥,hh , 咱也是有标点(签)的人了

前言

商品 SKU 的创建与查询,是电商业务最经典的开发场景之一,也是整个电商系统最基础的功能。因为假如缺少了它,那么也许连准确描述定位一件商品,这样最基本的需求,都将变得困难重重,商品的库存管理也就无处谈起。

概览

SKU功能设计流程
SKU功能设计流程

如图所示,整体流程可分为两部分:

  1. 运营端。负责创建与配置 SKU,运营通过操作 SKU 选项,生成一个 SKU 列表。
  2. 用户端。负责 SKU 的库存状态查询。
    在商品详情页,需要根据用户已选规格,去 SKU 列表里进行匹配查找。
    假如用户选择完毕,能够命中一份有库存的 SKU 数据,就提交一个 SKU-ID 给到下单流程。 总结: 这是一种前端为主的实现方案,SKU 的创建与查询部分都由前端来完成,后端只负责 SKU 的存储。这也是各大电商平台上的主流实现。
    当然也有后端为主的方案,SKU 的创建、查询、存储都交给后端来完成每次操作都可以看作是一次向后端的表单提交,并且只需要提交必要的操作信息,后端处理完成,返回前端需要的最终结果就可以了。这种方案的优势是库存数据的实时性好,但无论是运营端动态创建 SKU 列表,还是用户在详情页点击选项查询库存,都是足够高频的交互,每次的异步请求查询,出 loading,都会打断使用者的当前操作,用户体验差。并且这种实现方式前端工作量很少,所以本文不会涉及。

Demo 项目简介:

  1. 为了更清晰的内容讲解,笔者整理了一个sku-demo[1],包含了 (1)怎样在运营端进行 SKU 的创建; (2)用户端怎样在商品详情页进行 SKU 的查询
    麻雀虽小,五脏俱全,基本涵盖了前端 SKU 方面的绝大部分处理场景,读者可以下载并把项目跑起来,对照着来阅读,会理解的更快一些。
  2. 用 useContext+useReducer 模拟了一个小型的全局 Store,当做用户端与运营端的共享数据存储,已此来模拟后台到前台的一个完整交互。
  3. 运营配置页(src/views/CreateSKU.tsx)负责配置两份数据:一份是属性选项列表(attrList), 另一份是SKU 列表(skuList),配置完成后输出到用户端商品详情页(src/views/SearchSKU_xxx.tsx)分别用来渲染选项 UI 及查询 SKU 信息,所以将它们都存入 Store 中,方便共享通信,模拟SKU 从运营端生成=>发送服务端存储=>用户端读取查询的全过程

一、SKU 创建

npm start启动项目后,会默认进入一个模拟运营端的商品配置页。如下图:

demo-home
demo-home

tip: 点击页面左上角的入口按钮可以进行运营端与用户端的切换。
整体操作:就是运营通过操作左侧各种规格选项,联动生成右侧的 SKU 列表

需求关键点分析:

运营对规格选项的操作大致可分为两类:

  1. 对属性的具体选项进行增删改
    会影响属性选项的个数,需要在 SKU 列表中适当位置进行插入或者删除操作。
  2. 属性进行增删
    会影响数据的层数,意味当前所有 SKU 的选项组合都不是最新的,需要清空,重新组合。 比如原来属性类型只有颜色,尺寸,款式,现在增加一组套餐属性, 那之前的所有 SKU 组合都需要和套餐再次组合,并且之前配置的库存数据也都失效了。 为了降低设计的复杂度,采用了如下的方案:不区分操作,无论哪种操作类型,统一都走重新组合生成的逻辑。(当然如果十分在乎性能,也可以通过区分操作进行优化,类似于 vue 中针对不同 dom 操作类型所进行的 domDiff 效率方面的优化)。
    如下图:将左侧不同类型的规格值,通过排列组合的方式,生成右侧这样一个 SKU 列表。
SKU生成
SKU生成

具体实现:

那么问题就抽象为:求商品规格列表的全排列组合。这个过程是一个典型的树形结构,需要遍历到这颗树的每一个叶子,最终将叶子收集起来。

tree
tree

感兴趣的可以看下 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 查询功能的截图,这里产品经理一般都会提一个交互需求:希望根据用户当前已选的规格值,能够预判出哪些 SKU 组合是缺货状态,提前将对应按钮置灰。
比如上方截图里,[红色,M,男款]缺货,在选择红色M后,就需要提前将男款置灰。

选后俩
选后俩

反着选,先选后两行的M男款,置灰第一行的红色

选中间
选中间

先选前后两行红色男款,需要置灰中间的M
这还是只一个 SKU 缺货,如果两个 SKU 缺货呢?
比如[红色,M,男款][红色,M,女款]缺货,也就是红色M号都缺货。

在这种情况下,只选择一个红色,就需要判断出M不可选。
1-2 个 sku 缺货已经需要有上边那么多置灰的情况需要判断,那么更多的 sku 缺货呢,想想就头大,需要判断的情况太多了,或者根本不知道从何判断。 不过通过以上的场景推演,也能总结出一些导致问题变得复杂的原因:

  1. 用户选择路径不一定完整,在用户全选完整之前,就需要做判断。
  2. 用户选择的先后顺序并不确定,用户可以跳着选、反着选。
  3. 点击任意选项,都有可能触发其他任意位置的选项置灰与否
    那么就可以想到一个统一的思路:无论当前选择了什么,都遍历全部选项,站在每个选项的角度,判断需不需要将自身置灰

动手实现之前,先了解下已知条件:

  1. attrList: 属性列表,用来展示选项 UI,并且给每个选项自定义了一个 disabled 字段,用来表示按钮置灰状态。
const attrList = [
    {
        "attrLabel""颜色",
        "options": [
            {
                "value""红色",
                "disabled"true
            },
            {
                "value""绿色",
                "disabled"true
            },
            {
                "value""蓝色",
                "disabled"true
            }
        ]
    },
   ...
]
  1. skuList: 运营端生成的 SKU 列表。判断缺货与否的数据源,stock = 0 即为缺货
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
}
  1. 获取已选规格集合{A}。
  2. 遍历所有待选规格选项。
  3. 将每一个待选项{x}与已选项{A}组成新的集合 B = {A,x}
  4. 遍历 sku 列表,看能否找到同时符合以下两种条件的 sku
    (1)有货的:判断库存 stock,是否大于 0
    (2)规格选项匹配:找到每个 sku 对应的规格集合 C,判断 B 与 C 是否具有包含关系 B <= C ?
    查找结果为否,则此按钮需要置灰,反之亦然。
    尝试分析一下复杂度。假设,有 m 种规格,每一种都有 n 个选项, 步骤 2 需遍历 n x m 次,还可接受。 但是步骤 4 需要在完整遍历 SKU 列表(长度 n^m)过程中,再判断 B\C 俩集合是否具有包含关系。 却并不是一个简单的过程,所以是优化的重点。

Map 穷举法

以空间换时间: 既然用户选择的路径、顺序、个数都不固定,那么组成的选项集合是难以预测的,那么是不是可以提前把用户所有可能选到的 sku 组合都穷举出来。这样在匹配查询时效率就会快很多了,因为这本质上是因为将遍历查询匹配的工作前置了。
具体思路:就是根据穷举法的思想,提前计算好一个包含任意规格组合(表示用户任意的选择路径)的 Map 映射。
首先就是将每个 sku 组合的进行拆分,比如[红色,M,男款]就可以拆成(2^n)也就是 8 组子选项。这可以抽象为一个求幂集的操作。

sku拆分
sku拆分
/**
 * @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
}

计算结果如下:

skuMap
skuMap

接下来实现查询主函数 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 键值对非常多,造成空间浪费

选择

两种方案的优缺点都很明显,但其实还有第三种无向图方案,可以取一个均衡。大意是用邻接矩阵的形式,可以在查询中多一个维度的信息,减少遍历范围,在大量属性数据下是有明显的性能意义的(但鸽了许久没有完成)。以上介绍的两种方案应付大多数的业务场景应该都是没有问题的,也都是使用率很高的两种解法,读者碰到类似场景可以按需选择。


LBG开源项目推广:

还在手写 HTML 和 CSS 吗?
还在写布局吗?
快用 Picasso 吧,Picasso 一键生成高可用的前端代码,让你有更多的时间去沉淀和成长,欢迎Star

开源项目地址:https://github.com/wuba/Picasso
官网地址:https://picassoui.58.com

参考资料

[1]

sku-demo: https://github.com/FEyudong/sku-demo

[2]

组合: https://leetcode-cn.com/problems/combinations/

分类:

前端

标签:

React.js

作者介绍

sunilwang
V1