淘宝 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();
};
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 219,753评论 6 508
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 93,668评论 3 396
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 166,090评论 0 356
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 59,010评论 1 295
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 68,054评论 6 395
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 51,806评论 1 308
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 40,484评论 3 420
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 39,380评论 0 276
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 45,873评论 1 319
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 38,021评论 3 338
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 40,158评论 1 352
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 35,838评论 5 346
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 41,499评论 3 331
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 32,044评论 0 22
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 33,159评论 1 272
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 48,449评论 3 374
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 45,136评论 2 356

推荐阅读更多精彩内容