sku演算法詳解及Demo~接上篇

沐曉發表於2020-05-30

前言

做過電商專案前端售賣的應該都遇見過不同規格產品庫存的計算問題,業界名詞叫做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: ["大", "小"],
  },
];

演算法演示

在前端使用者選擇單個規格或多個規格後,我們需要動態計算出此時其他按鈕是否還能點選(組合有庫存),以及當前狀態對應的總庫存量,封面圖和價格區間。

以上面的資料舉個 ?

開始時什麼都沒有選擇,展示預設圖片,規格列表中的第一項組合(['紅色-大'])對應的圖片,庫存為商品總庫存,價格為商品的價格區間。然後在使用者選擇某個屬性或幾個屬性的時候實時計算對應的圖片,庫存,價格區間。

同時根據當前已選屬性,置灰不可選擇的屬性。在本例中,藍色 大的產品對應的庫存為 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

相關文章