sku算法详解及Demo~接上篇

前言

做过电商项目前端售卖的应该都遇见过不同规格产品库存的计算问题,业界名词叫做 sku(stock Keeping Unit) ,库存量单元对应我们售卖的具体规格,比如一部手机具体型号规格,其中 iphone6s 4G 红色 就是一个 sku 。这里我们区别 spu(Standard Product Unit) ,标准化产品单元,比如一部手机型号 iphone6s 就是一个 spu

sku 算法

在前端展示商品时,根据用户选择的不同 sku ,我们需要计算出不同的库存量动态展示给用户,这里就衍生出了 sku 算法。

数据结构

我们先看看在后端服务器保存库存的数据结构一般是长怎么样的:

// 库存列表
const skuList = [
  {
    skuId: "0",
    skuGroup: ["红色", "大"],
    remainStock: 7,
    price: 2,
    picUrl: "https://dummyimage.com/100x100/ff00b4/ffffff&text=大",
  },
  {
    skuId: "1",
    skuGroup: ["红色", "小"],
    remainStock: 3,
    price: 4,
    picUrl: "https://dummyimage.com/100x100/ff00b4/ffffff&text=小",
  },
  {
    skuId: "2",
    skuGroup: ["蓝色", "大"],
    remainStock: 0,
    price: 0.01,
    picUrl: "https://dummyimage.com/100x100/0084ff/ffffff&text=大",
  },
  {
    skuId: "3",
    skuGroup: ["蓝色", "小"],
    remainStock: 1,
    price: 1,
    picUrl: "https://dummyimage.com/100x100/0084ff/ffffff&text=小",
  },
];

// 规格列表
const skuNameList = [
  {
    skuName: "颜色",
    skuValues: ["红色", "蓝色"],
  },
  {
    skuName: "尺寸",
    skuValues: ["大", "小"],
  },
];

算法演示

在前端用户选择单个规格或多个规格后,我们需要动态计算出此时其他按钮是否还能点击(组合有库存),以及当前状态对应的总库存量,封面图和价格区间。

以上面的数据举个 :chestnut:

开始时什么都没有选择,展示默认图片,规格列表中的第一项组合(['红色-大'])对应的图片,库存为商品总库存,价格为商品的价格区间。然后在用户选择某个属性或几个属性的时候实时计算对应的图片,库存,价格区间。

同时根据当前已选属性,置灰不可选择的属性。在本例中, 蓝色 大 的产品对应的库存为 0,所以当我们选择其中一项 蓝色 或者 大 的时候,需要置灰另一个属性选项。

实现思路-第二种算法

思路

为了大家能看清下面的分析,在此定义下相关名词,库存列表:skuList,规格列表:skuNameList,属性:skuNameList-skuValues数组下的单个元素,规格:skuNameList下的单个元素

  • 首先定义变量 skuStock (库存对象), skuPartNameStock (用于缓存非全名库存,如{'小': 4})

  • 将规格列表下的已选属性集合作为入参 selected ,如果在当前规格未选择相关属性则传入空字符串,即最开始时 selected === ['', '']

  • 判断当前已选属性 selected 是否已有缓存库存,有则直接返回缓存库存

  • 判断当前是否已全选,如果全选则返回从 skuStock 读取的库存,并在此之前及时缓存库存

  • 定义库存变量 remainStock,将选属性数组 willSelected

  • 遍历库存规格,判断当前规格属性是否已选,已选则将当前属性推入 willSelected

  • 未选则遍历属性数组,将属性数组和已选数组 selected 组合,递归取得当前组合库存,并将库存进行累加

  • 最后返回累加的库存作为已选属性为 selected 时对应的库存,并及时缓存于 skuPartNameStock 对象中

// sku库存列表转对象
const skuStock = skuList.forEach(sku => {
  this.skuStock[sku.skuGroup && sku.skuGroup.join("-")] = sku.remainStock;
});
// 用于缓存库存信息
const skuPartNameStock = {};

/**
 * 获取库存
 * @param {Array} selected 已选属性数组
 * @return {Object} skuInfo
 *
 */
function getRemainByKey(selected) {
  const selectedJoin = selected.join("-");

  // 如果已有缓存则返回
  if (typeof skuPartNameStock[selectedJoin] !== "undefined") {
    return skuPartNameStock[selectedJoin];
  }

  // 返回skuStock的库存,并及时缓存
  if (selected.length === skuNameList.length) {
    skuPartNameStock[selectedJoin] = skuStock[selectedJoin]
      ? skuStock[selectedJoin]
      : 0;
    return skuPartNameStock[selectedJoin];
  }

  let remainStock = 0;
  const willSelected = [];

  for (let i = 0; i < skuNameList.length; i += 1) {
    // 对应规格的sku是否已选择
    const exist = skuNameList[i].skuValues.find(
      name => name === selected[0]
    );
    if (exist && selected.length > 0) {
      willSelected.push(selected.shift());
    } else {
      // 对应sku未选择,则遍历该规格所有sku
      for (let j = 0; j < skuNameList[i].skuValues.length; j += 1) {
        remainStock += this.getRemainByKey(
          willSelected.concat(skuNameList[i].skuValues[j], selected)
        );
      }
      break;
    }
  }
  // 返回前缓存
  skuPartNameStock[selectedJoin] = remainStock;
  return skuPartNameStock[selectedJoin];
}

demo演示

利用此算法写了个 skuModal 的 vue demo,在此贴下代码,大家可以作为组件引用看看效果方便理解

<template>
  <div v-if="visible" class="modal">
    <div class="content">
      <div class="title">
        {{ skuInfo.specName }}
        <span class="close" @click="close">
          <svg
            t="1590840102842"
            class="icon"
            viewBox="0 0 1024 1024"
            version="1.1"
            xmlns="http://www.w3.org/2000/svg"
            p-id="1264"
            width="32"
            height="32"
          >
            <path
              d="M810.666667 273.493333L750.506667 213.333333 512 451.84 273.493333 213.333333 213.333333 273.493333 451.84 512 213.333333 750.506667 273.493333 810.666667 512 572.16 750.506667 810.666667 810.666667 750.506667 572.16 512z"
              p-id="1265"
              fill="#666666"
            ></path>
          </svg>
        </span>
      </div>
      <div class="info">
        <img :src="skuInfo.pic" class="pic" />
        <div class="sku-info">
          <span class="price">
            ¥{{
              skuInfo.minPrice === skuInfo.maxPrice
                ? skuInfo.minPrice
                : skuInfo.minPrice + "-" + skuInfo.maxPrice
            }}
          </span>
          <span class="selected">{{ skuInfo.selectedTip }}</span>
          <span class="stock">剩余{{ skuInfo.remainStock }}件</span>
        </div>
      </div>

      <div v-for="(sku, index) in skuStatusGroup" :key="index" class="spec">
        <span class="name">{{ sku.name }}</span>
        <div class="group">
          <span
            v-for="(keyInfo, idx) in sku.list"
            :key="idx"
            class="spec-name"
            :class="{
              active: keyInfo.status === 1,
              disabled: keyInfo.status === -1
            }"
            @click="selectSku(index, idx)"
            >{{ keyInfo.key }}</span
          >
        </div>
      </div>
      <div class="footer">
        <button
          class="btn"
          :class="skuInfo.isSelectedAll ? 'active' : ''"
          type="button"
          @click="confirm"
        >
          确认
        </button>
      </div>
    </div>
  </div>
</template>

<script>
export default {
  props: {
    visible: Boolean
  },
  data() {
    return {
      skuInfo: {
        // 当前选择的sku信息
        minPrice: 0,
        maxPrice: 0,
        pic: "",
        selected: [], // 已选sku 未选择用 '' 占位
        realSelectd: [],
        selectedTip: "",
        specName: "",
        stock: 0,
        isSelectedAll: false
      },
      skuStatusGroup: [], // 当前sku状态数组
      skuStock: {}, // sku对应库存 红-大
      skuPartNameStock: {}, // sku对应库存(不完全名) 红
      skuList: [], // 接口返回的sku列表
      skuInfoCache: {} // 缓存不同sku的skuInfo
    };
  },
  methods: {
    initSku(data) {
      const { skuList, skuNameList } = data;

      // 清空旧的sku数据
      this.clearOldSku();

      skuNameList.forEach(({ skuName, skuValues }) => {
        this.skuStatusGroup.push({
          name: skuName,
          list: skuValues.map(value => ({
            key: value,
            status: 0 // 0 可选 -1 不可选 1 已选
          }))
        });
      });

      this.skuNameList = skuNameList;

      // 规格文案
      this.skuInfo.specName = skuNameList.map(item => item.skuName).join(" | ");

      // sku 初始库存
      skuList.forEach(sku => {
        this.skuStock[sku.skuGroup && sku.skuGroup.join("-")] = sku.remainStock;
      });

      // sku原始列表
      this.skuList = skuList || [];

      // 首次过滤sku库存
      this.filterSkuKey();
    },

    // 清空旧sku数据
    clearOldSku() {
      this.skuStatusGroup = [];
      this.skuStock = {};
      this.skuPartNameStock = {};
      this.skuList = [];
      this.skuInfoCache = {};
    },

    close() {
      this.$emit("update:visible", false);
    },

    // 更新skuInfo
    updateSkuInfo(selected) {
      const { skuStatusGroup } = this;
      const realSelectd = selected.filter(item => item);

      const priceInfo = this.getskuInfoByKey(selected);
      const stock = this.getRemainByKey(realSelectd);
      const isSelectedAll = realSelectd.length === selected.length;
      const selectedTip = isSelectedAll
        ? `已选择 ${realSelectd.join("、")}`
        : `请选择 ${selected
            .map((item, idx) => {
              if (!item) {
                return skuStatusGroup[idx].name;
              }
              return null;
            })
            .filter(item => item)
            .join("、")}`;

      this.skuInfo = Object.assign({}, this.skuInfo, priceInfo, {
        selected,
        stock,
        realSelectd,
        isSelectedAll,
        selectedTip
      });
    },

    // 根据已选sku及库存更新sku列表状态
    filterSkuKey() {
      const { skuStatusGroup } = this;
      const selected = [];

      // 通过sku状态获取已选数组
      skuStatusGroup.forEach(sku => {
        let pos = 0;
        const isInSelected = sku.list.some((skuInfo, idx) => {
          pos = idx;
          return skuInfo.status === 1;
        });

        selected.push(isInSelected ? sku.list[pos].key : "");
      });

      // 更新skuInfo
      this.updateSkuInfo(selected);

      // 根据已选择的sku来筛选库存
      skuStatusGroup.forEach((sku, skuIdx) => {
        const curSelected = selected.slice();

        // 已选的不用更新
        sku.list.forEach(skuInfo => {
          if (skuInfo.status === 1) {
            return;
          }

          // 将不同sku代入计算库存
          const cacheKey = curSelected[skuIdx];
          curSelected[skuIdx] = skuInfo.key;
          const stock = this.getRemainByKey(curSelected.filter(item => item));
          curSelected[skuIdx] = cacheKey;

          // 更新sku状态
          if (stock <= 0) {
            // eslint-disable-next-line no-param-reassign
            skuInfo.status = -1;
          } else {
            // eslint-disable-next-line no-param-reassign
            skuInfo.status = 0;
          }
        });
      });
    },

    // sku按钮点击 选择sku
    selectSku(listIdx, keyIdx) {
      const { list } = this.skuStatusGroup[listIdx];
      const { status } = list[keyIdx];

      // status -1 无库存 0 未选择 1 已选择
      if (status === -1) {
        return;
      }

      // 更新该规格下sku选择状态
      list.forEach((keyInfo, idx) => {
        if (keyInfo.status !== -1) {
          if (idx === keyIdx) {
            // eslint-disable-next-line no-param-reassign
            keyInfo.status = 1 - status;
          } else {
            // eslint-disable-next-line no-param-reassign
            keyInfo.status = 0;
          }
        }
      });

      // 根据库存更新可选sku
      this.filterSkuKey();
    },

    /**
     * 获取已选择的sku匹配的商品信息
     * @param {Array} selected 已选sku数组
     */
    getskuInfoByKey(selected = []) {
      const { skuList } = this;
      const cacheInfo = this.skuInfoCache[
        selected.filter(item => item).join("-")
      ];

      // 如果已有缓存信息则直接返回
      if (cacheInfo) {
        return cacheInfo;
      }

      const info = {
        minPrice: -1,
        maxPrice: -1,
        pic: ""
      };

      skuList.forEach(sku => {
        const group = sku.skuGroup;

        // 通过已选的 key => key 来确定是否匹配
        const isInclude = selected.every(
          (name, index) => name === "" || name === group[index]
        );

        if (isInclude) {
          const { minPrice, maxPrice } = info;
          // 排除首次 -1
          info.minPrice =
            minPrice === -1 ? sku.price : Math.min(minPrice, sku.price);
          info.maxPrice =
            maxPrice === -1 ? sku.price : Math.max(maxPrice, sku.price);
          info.pic = sku.picUrl;
        }
      });

      // 如果主sku未选择,则默认使用第一张图
      if (selected[0] === "") info.pic = skuList[0].picUrl;

      this.skuInfoCache[selected.filter(item => item).join("-")] = info;

      return info;
    },

    /**
     * sku算法 获取已选择sku的库存数
     * @param {Array} selected 已选择的sku数组
     */
    getRemainByKey(selected = []) {
      const { skuStock, skuPartNameStock, skuNameList } = this;
      const selectedJoin = selected.join("-");

      // 如果已有缓存则返回
      if (typeof skuPartNameStock[selectedJoin] !== "undefined") {
        return skuPartNameStock[selectedJoin];
      }

      // 所有sku已选择 及时缓存
      if (selected.length === skuNameList.length) {
        skuPartNameStock[selectedJoin] = skuStock[selectedJoin]
          ? skuStock[selectedJoin]
          : 0;
        return skuPartNameStock[selectedJoin];
      }

      let remainStock = 0;
      const willSelected = [];

      for (let i = 0; i < skuNameList.length; i += 1) {
        // 对应规格的sku是否已选择
        const exist = skuNameList[i].skuValues.find(
          _item => _item === selected[0]
        );
        if (exist && selected.length > 0) {
          willSelected.push(selected.shift());
        } else {
          // 对应sku未选择,则遍历该规格所有sku
          for (let j = 0; j < skuNameList[i].skuValues.length; j += 1) {
            remainStock += this.getRemainByKey(
              willSelected.concat(skuNameList[i].skuValues[j], selected)
            );
          }
          break;
        }
      }
      // 返回前缓存
      skuPartNameStock[selectedJoin] = remainStock;
      return skuPartNameStock[selectedJoin];
    },

    // 确认订单
    confirm() {
      const { skuList } = this;

      if (skuList.length > 1 && !this.skuInfo.isSelectedAll) {
        return;
      }

      const { skuId } = this.skuList.filter(item => {
        if (item.skuGroup.join("-") === this.skuInfo.realSelectd.join("-")) {
          return true;
        }
        return false;
      })[0];

      this.$emit("confirm", skuId);
    }
  }
};
</script>

<style lang="less" scoped>
.modal {
  position: fixed;
  top: 0;
  left: 0;
  width: 100vw;
  height: 100vh;

  &:before {
    content: "";
    position: absolute;
    top: 0;
    left: 0;
    width: 100vw;
    height: 100vh;
    background: rgba(0, 0, 0, 0.2);
  }

  .content {
    position: absolute;
    top: 50%;
    left: 50%;
    max-height: 900px;
    padding: 0 20px 20px;
    overflow: auto;
    background: #fff;
    border-radius: 12px;
    transform: translate(-50%, -50%);
    z-index: 1;

    .title {
      display: flex;
      justify-content: space-between;
      color: #666;
      font-size: 32px;
      line-height: 60px;
      text-align: left;
      border-bottom: 1px solid #eee;

      .close {
        display: flex;
        align-items: center;
      }
    }

    .info {
      display: flex;
      margin-top: 10px;

      .pic {
        width: 180px;
        height: 180px;
        border-radius: 4px;
      }

      .sku-info {
        display: flex;
        flex-direction: column;
        align-items: flex-start;
        margin-left: 30px;
        color: #999;
        font-size: 26px;

        span {
          margin-bottom: 20px;
        }

        .price {
          color: #333;
        }
      }
    }

    .spec {
      display: flex;
      padding: 20px;

      .name {
        color: #999;
        font-size: 24px;
        line-height: 54px;
      }

      .group {
        margin-left: 20px;

        .spec-name {
          display: inline-block;
          height: 54px;
          margin: 0 30px 10px 0;
          padding: 0 40px;
          line-height: 54px;
          color: #333;
          font-size: 28px;
          background: rgba(245, 245, 245, 1);
          border-radius: 28px;
          border: 1px solid rgba(204, 204, 204, 1);

          &.active {
            color: #ff981a;
            background: #ffeeeb;
            border: 1px solid #ff981a;
          }

          &.disabled {
            color: #cccccc;
            background: #f5f5f5;
            border: 1px solid transparent;
          }
        }
      }
    }

    .btn {
      width: 690px;
      height: 80px;
      color: rgba(255, 255, 255, 1);
      font-size: 32px;
      background: rgba(204, 204, 204, 1);
      border-radius: 44px;
      outline: none;

      &.active {
        color: #fff;
        background: #ff981a;
      }
    }
  }
}
</style>

使用方式

<!-- 引用组件 -->
<skuModal ref="sku" :visible.sync="visible" @confirm="confirm"></skuModal>
// 初始化sku
this.$refs.sku.initSku({
  skuNameList, // 格式参考上文
  skuList // 格式参考上文
});

总结

做过电商项目的应该都处理或者听说过 sku,学习相关概念和真正理解如何计算 sku 可以帮助我们更加熟悉业务,提升自己对于相关业务的处理能力。以后在面试中遇到面试官的提问也能更稳一些。第一种 sku 算法可以参考上一篇博客。

参考

欢迎到前端学习打卡群一起学习~ 516913974

我来评几句
登录后评论

已发表评论数()

相关站点

热门文章