淘宝 sku 前端算法

在工作中遇到的计算sku的需求, 记录下来思路和核心代码以防不时之需。
使用的技术有 react框架lodash库

sku.gif

确认后端返回的数据

"itemSkus": {
    "listData": [
      {
        "id": 598,
        "pictureUrl": "", //
        "sellPrice": 11.00, // 售价
        "totalInventory": 12, // 库存
        "attributesIdArr": [200, 445] // 属性组合
      },
      {
        // ...
        "sellPrice": 13, // 售价
        "attributesIdArr": [200, 447] // 属性组合
      },
      {
        // ...
        "sellPrice": 31, // 售价
        "attributesIdArr": [302, 445] // 属性组合
      },
      {
        // ...
        "sellPrice": 33, // 售价
        "attributesIdArr": [302, 447] // 属性组合
      },
      {
        // ...
        "sellPrice": 20, // 售价
        "attributesIdArr": [201, 445] // 属性组合
      },
      {
        // ...
        "sellPrice": 22, // 售价
        "attributesIdArr": [201, 446] // 属性组合
      },
    ]
  },
  "attrs": {
    "listData": [
      {
        "id": 23,
        "name": "套餐",
        "attrValues": {
          "listData": [
            {
              "id": 200,
              "name": "一年签证"
            },
            {
              "id": 201,
              "name": "两年签证"
            },
            {
              "id": 302,
              "name": "三年签证"
            }
          ]
        }
      },
      {
        "id": 67,
        "name": "时效",
        "attrValues": {
          "listData": [
            {
              "id": 445,
              "name": "一年"
            },
            {
              "id": 446,
              "name": "两年"
            },
            {
              "id": 447,
              "name": "三年"
            },
            {
              "id": 448,
              "name": "四年"
            }
          ]
        }
      }
     // ...
    ]
  },

现在我们有三组属性和六种组合

<--  商品规格 -->
套餐 :  一年签(200)    两年签(201)    三年签(302)
时效 :  一年 (445)      两年(446)      三年(447)     四年(448) 
服务 :    普通(500)      专享(501)

<-- sku -->
[200, 445]  -  一年签 / 一年 
[200, 447]  -  一年签 / 三年 
[302, 445]  -  三年签 / 一年 
[302, 447]  -  三年签 / 三年 
[201, 445]  -  两年签 / 一年 
[201, 446]  -  两年签 / 两年 

接下来我们将从以下三步进行分析, 实现上图的效果

1. 转换商品规格的数据

这一步我们做两件事情

  1. 过滤没用的属性
    从sku的组合中可知, "时效"的第四个属性(四年) 和 "服务"整条属性并未被用到, 所以最开始需要先过滤一遍商品规格
    (如果你的业务中每个属性都被用到的话, 那么这一步不是必须的)
    【实现思路】
    • 循环 sku 的组合, 将每个组合合并到 allAttributesIdArr 数组中, 即使数据重复也么关系
      image.png
    • 循环商品属性, 判断每一个规格的 id 是否存在 allAttributesIdArr 的数组中, 如果没在的话则删除这个规格的子属性, 若每个子属性都没被用到则删除整条规格

  2. 给每个属性初始状态
    我们需要有一个status字段控制标签的 选中, 未选中 和 无效状态
    初始化的时候每个标签都是未选中状态

实现中用的
cloneDeep 和 pullAt 方法来自于 lodash;
isInArray 则是自己封装的工具类, 用于判断某个值是否存在数组中

// 显示状态 status 的常量
const STATUS_UNSELECTED = 0; // 未选中状态
const STATUS_SELECTED = 1; // 选中状态
const STATUS_DISABLED = 2; // 无效状态

// 转换商品规格的数据
convertAttrs = (record) => {
  const { attrs, itemSkus, itemCategoryId } = record;

  // itemSkus 中用到的所有属性的 id 数组
  let allAttributesIdArr = [];
  for (let elem of Array.values(itemSkus.listData)) {
    allAttributesIdArr = attributesIdArr.concat(attributesIdArr);
  }

  // 遍历 attrs 添加默认显示状态(未选中状态)同时删除没用的属性或整条属性
  let _attrs = cloneDeep(attrs);
  let deleteAttrIndex = [];
  for (let [attrIndex, elem] of Array.entries(attrs.listData)) {
    let deleteIndex = [];
    const _attrsElem = _attrs.listData[attrIndex].attrValues.listData;
    const attrsElemLen = _attrsElem.length;
    for (let [index, attrElem] of Array.entries(elem.attrValues.listData)) {
      // 添加初始状态
      _attrsElem[index].status = STATUS_UNSELECTED;
      if (!isInArray(allAttributesIdArr, attrElem.id)) deleteIndex.push(index);
    }
    // 如果删除数据的长度等于这条属性的长度 那么删除整条属性 反之则删除某些属性
    attrsElemLen === deleteIndex.length
      ? deleteAttrIndex.push(attrIndex)
      : pullAt(_attrsElem, deleteIndex);
  }
  pullAt(_attrs.listData, deleteAttrIndex);
  return { attrs: _attrs, itemSkus };
};

2. 初始化数据

这一步我们同样需要做两件事情

  1. 计算出用户可能选择的所有组合(在数学上也称为: 幂集)
    【实现思路】
  • 获取单个组合可能产生的所有组合
    例如 [200, 445] 这个组合可能产生 [ [200], [445], [200, 445] ] 这三种组合
    具体算法参考这段代码


    image.png

    上面代码的计算过程是这样

attributesIdArrItem    powerSet
                       [[]]   // 循环前的值
200                    [[], [200]]   // 将 200 和 powerSet 的元素合并, 并将得到的结果push到powerSet中得到新的powerSet = [[], [200]]
445                    [[], [200], [445], [200, 445]]   //将 400 和上一次的 powerSet 的每个元素合并, 将得到的结果push到powerSet中得到新的powerSet =  [[], [200], [445], [200, 445]]

  • 获得这个组合的产生的所有可能组合后将他转换成json格式, 并且将这个组合对应的数据放入完整的选择中, 方便后续的取值
    image.png
  • 循环 sku 计算出全部的可能
    image.png
  1. 未选中时页面显示的默认数据, 如价格区间、库存、图片等 (本文只计算价格区间)
    循环 sku 获得所有价格的数组, 从中取出最大最小值就是我们想要的价格区间了

实现中用的
min 和 max 方法来自于 lodash;

// 获取组合的幂集 && 计算价格区间
getSkuInit = (record) => {
  const { attrs, itemSkus } = this.convertAttrs(record);
  let skuAvailableSet = {}; // 所有可能的属性组合
  let sellPriceArr = []; // 保存所有的价格
  let attrsLen = attrs.listData.length;

  // 获取组合的幂集
  for (let skusItem of Array.values(itemSkus.listData)) {
    sellPriceArr.push(skusItem.sellPrice);

    // 储存幂集
    let powerSet = [[]];
    for (let attributesIdArrItem of Array.values(skusItem.attributesIdArr)) {
      let len = powerSet.length;
      for (let i = 0; i < len; i++) {
        powerSet.push(powerSet[i].concat(attributesIdArrItem));
      }
    }

    for (let powerSetItem of Array.values(powerSet)) {
      const tmpSet = powerSetItem.join(',');
      // skuAvailableSet存成 [可能组合的id字符串]: [商品属性], 后续取值的时候方便
      if (attrsLen === powerSetItem.length) {
        skuAvailableSet[tmpSet] = skusItem;
      } else if (tmpSet) {
        skuAvailableSet[tmpSet] = {};
      }
    }
  }
  // 计算价格区间
  const minsellPrice = min(sellPriceArr);
  const maxsellPrice = max(sellPriceArr);
  this.sellPriceRange =
    minsellPrice === maxsellPrice ? minsellPrice : `${minsellPrice}-${maxsellPrice}`;

  return { skuAvailableSet, sellPrice: this.sellPriceRange, attrs, itemSkus };
};

3. 点击属性

这一步我们需要判断用户点击时, 其他属性的显示状态(不可选还是未选中) 以及价格(或者图片, 库存等) 的变化
【实现思路】
!!!! 不行了 写不动了, 明年回来再写吧 !!!!

/*
    attrValue: 点击的属性
    attrIndex: 被点击的属性组的坐标(纵坐标)
    attrValuesIndex: 被点击的属性组下value的坐标(横坐标)
*/
doSkuSet = (attrValue, attrIndex, attrValuesIndex) => {
  let { record, selectedValIdArr, skuAvailableSet, sellPrice } = this.state;
  let { attrs = { listData: [] }, ...other } = record;

  // 设置当前属性的显示状态
  const status = attrValue.status;
  switch (status) {
    case STATUS_UNSELECTED:
      selectedValIdArr[attrIndex] = attrValue.id;
      attrs.listData[attrIndex].attrValues.listData[attrValuesIndex].status = STATUS_SELECTED;
      break;
    case STATUS_SELECTED:
      selectedValIdArr[attrIndex] = '';
      attrs.listData[attrIndex].attrValues.listData[attrValuesIndex].status = STATUS_UNSELECTED;
      break;
    case STATUS_DISABLED:
      return;
    default:
      break;
  }

  // 设置标签的状态 & 商品的价格
  const selectedValIdTrueArr = compact(selectedValIdArr);
  if (selectedValIdTrueArr.length) {
    // 设置标签显示状态
    for (let [attrsIndex, attrsItem] of Array.entries(attrs.listData)) {
      for (let attrValueElem of Array.values(attrsItem.attrValues.listData)) {
        const attrValueId = attrValueElem.id;
        //  当前属性是否在已选中的 selectedValIdArr 中, 如果是的话不用重新设置状态
        if (attrValueId === selectedValIdArr[attrsIndex]) continue;

        //  构造当前属性与其他attr已选中的属性的组合
        let tmpSet = cloneDeep(selectedValIdArr);
        tmpSet[attrsIndex] = attrValueId;

        // 判断tmpSet的组合是否可选
        if (skuAvailableSet[compact(tmpSet).join(',')]) {
          attrValueElem.status = STATUS_UNSELECTED;
        } else {
          attrValueElem.status = STATUS_DISABLED;
        }
      }
    }

    // 设置显示价格
    const selecteSellPrice = skuAvailableSet[selectedValIdTrueArr.join(',')].sellPrice;
    sellPrice = selecteSellPrice ? selecteSellPrice : this.sellPriceRange;
  } else {
    // 全不选的时候将所有属性设置为初始值
    for (let attrsItem of Array.values(attrs.listData)) {
      for (let attrValueElem of Array.values(attrsItem.attrValues.listData)) {
        attrValueElem.status = STATUS_UNSELECTED;
      }
    }

    sellPrice = this.sellPriceRange;
  }

  this.setState({ selectedValIdArr, sellPrice, record: { attrs, ...other } });
  this.forceUpdate();
};
©著作权归作者所有,转载或内容合作请联系作者
平台声明:文章内容(如有图片或视频亦包括在内)由作者上传并发布,文章内容仅代表作者本人观点,简书系信息发布平台,仅提供信息存储服务。

推荐阅读更多精彩内容