前端商品多规格选择问题 SKU 算法实现优化2.0

在阅读本文之前,请先阅读笔者上一篇文章:前端商品多规格选择问题 SKU 算法实现

文章目录

    • 一、找bug
    • 二、修复过程详解
      • 1.初始化顶点集和空邻接矩阵
      • 2.邻接矩阵赋值
      • 3.判断 attribute 是否可选
    • 三、Vue源码
    • 四、实现效果
    • 五、其他

一、找bug

上一篇文章最后提到实现的算法存在bug,是哪个地方出现问题了呢?

当数据源如下所示时:

export const properties = [
  {
    id: "1",
    name: "容量",
    attributes: [
      { value: "1L", isActive: false, isDisabled: false },
      { value: "4L", isActive: false, isDisabled: false },
    ],
  },
  {
    id: "2",
    name: "颜色",
    attributes: [
      { value: "红色", isActive: false, isDisabled: false },
      { value: "黑色", isActive: false, isDisabled: false },
    ],
  },
  {
    id: "3",
    name: "优惠套餐",
    attributes: [
      { value: "套餐一", isActive: false, isDisabled: false },
      { value: "套餐二", isActive: false, isDisabled: false },
    ],
  },
];

export const skuList = [
  { id: "10", attributes: ["1L", "红色", "套餐一"] },
  { id: "20", attributes: ["1L", "黑色", "套餐二"] },
  { id: "30", attributes: ["4L", "红色", "套餐一"] },
  { id: "40", attributes: ["4L", "红色", "套餐二"] },
];

实现效果如下所示:
前端商品多规格选择问题 SKU 算法实现优化2.0_第1张图片
你发现了吗,明明 skuList 中没有 ["1L", "红色", "套餐二"] 的组合,但选择了 1L红色 之后却可以选择 套餐二 呢?这就是上一篇文章中实现存在的bug。
为什么会这样?
仔细看看 skuList

  { id: "10", attributes: ["1L", "红色", "套餐一"] },
  { id: "20", attributes: ["1L", "黑色", "套餐二"] },
  { id: "30", attributes: ["4L", "红色", "套餐一"] },
  { id: "40", attributes: ["4L", "红色", "套餐二"] },

可以看到 1L套餐二 有路径,红色套餐二 有路径,导致 1L + 红色套餐二 有路径。

二、修复过程详解

由上所示,在两个顶点有路径时在邻接矩阵中赋值为1不够明确,那么可以赋值成多少呢?还记得每个 sku 有唯一的 id 标识,因此我们将邻接矩阵中赋值成当前 attribute 所支持的 sku 的集合。
JavaScript 中,用 Set 表示集合,主要用法如下:

// 创建 Set
const letters = new Set();

// 向 Set 添加一些值
letters.add("a");
letters.add("b");
letters.add("c");

集合元素互斥,如果添加相等的元素,则只会保存第一个元素。

Set 对象的方法和属性

new Set() 创建新的 Set 对象。
add() 向 Set 添加新元素。
clear() 从 Set 中删除所有元素。
delete() 删除由其值指定的元素。
entries() 返回 Set 对象中值的数组。
has() 如果值存在则返回 true。
forEach() 为每个元素调用回调。
keys() 返回 Set 对象中值的数组。
values() 与 keys() 相同。
size 返回元素计数。

可点击了解更多
JavaScript Set 对象

以下详细说明上一篇文章中提出的3个求解步骤中需要修改的部分。

1.初始化顶点集和空邻接矩阵

这部分并没有修改,与原相同,代码实现如下所示:

    // 构造初始空邻接矩阵存储无向图
    initEmptyAdjMatrix() {
      this.properties.forEach((prop) => {
        prop.attributes.forEach((attr) => {
          this.vertexList.push(attr.value);
        });
      });
      for (let i = 0; i < this.vertexList.length; i++) {
        this.matrix[i] = new Array(this.vertexList.length).fill(0);
      }
    },

2.邻接矩阵赋值

setAdjMatrixValue 调用子函数 associateAttributes 时需要增加传参 skuId ,对于 properties 赋值的情况没有 skuId ,统一传一个能够确认与 skuList 中的每一个 skuId 不相同的值即可,这里传1

    // 根据 skuList 和 properties 设置邻接矩阵的值
    setAdjMatrixValue() {
      this.skuList.forEach((sku) => {
        this.associateAttributes(sku.attributes, sku.id);
      });
      this.properties.forEach((prop) => {
        this.associateAttributes(prop.attributes, '1');
      });
    },

在子函数 associateAttributes 中,赋值时需要修改逻辑,判断 this.matrix[index1][index2] 是否有值,若有值,则使用 add 方法在集合中增加当前传入的 skuId,否则赋值为新创建的 Set 对象,并在集合中增加当前传入的 skuId

    // 将 attributes 属性组中的属性在无向图中联系起来
    associateAttributes(attributes, skuId) {
      attributes.forEach((attr1) => {
        attributes.forEach((attr2) => {
          // 因 properties 与 skuList 数据结构不一致,需作处理
          if (attr1 !== attr2 || attr1.value !== attr2.value) {
            if (attr1.value && attr2.value) {
              attr1 = attr1.value;
              attr2 = attr2.value;
            }
            const index1 = this.vertexList.indexOf(attr1);
            const index2 = this.vertexList.indexOf(attr2);
            if (index1 > -1 && index2 > -1) {
              if(this.matrix[index1][index2]) {
                this.matrix[index1][index2].add(skuId);
              }
              else {
                this.matrix[index1][index2] = new Set([skuId]);
              }
            }
          }
        });
      });
    },

赋值后,邻接矩阵如下所示:

1L 4L 红色 黑色 套餐一 套餐二
1L 0 {1} {10} {20} {10} {20}
4L {1} 0 {30, 40} 0 {30} {40}
红色 {10} {30, 40} 0 {1} {10, 30} {40}
黑色 {20} 0 {1} 0 0 {20}
套餐一 {10} {30} {10, 30} 0 0 {1}
套餐二 {20} {40} {40} {20} {1} 0

3.判断 attribute 是否可选

原逻辑是判断得到的 res 数组中是否每个值均为1,若符合则可选;否则若其中一个值为0,则不可选置灰。
修改后的逻辑更为复杂,感觉这块有可能出bug。如果有小伙伴发现了评论区告诉我~
我的思路是:此时 res 数组中存储的元素可能有两种类型,Number 类型的 0 或者 Object 类型的 Set 对象, Set 对象中存储的可能为 1 或者 skuList 中存在的 skuId1skuIdString 类型。我将结果区分为三种情况,注意三种情况有先后关系,后一种排除前一种存在的可能,即使用 if - else if - else 的控制流:

  • res 数组中存在值为 0 的元素,则返回 false ,表示需要置灰,这种情况与原逻辑相同
  • 排除上一种情况之后,此时 res 数组中存储的是 Set 对象, Set 对象存储的是 1 或者 skuId。若 res 数组中存在包含值为 1Set 对象,则返回 true ,表示可选。
  • 排除以上两种情况之后,此时 res 数组中存储的是 Set 对象, Set 对象中存储的是 skuId。当且仅当每个 Set 对象中包含相同的一个 skuId 时,可选返回 true,否则不可选返回 false

一、三情况比较容易理解,对于第二种情况举例说明:选择了 1L红色 之后,4L 能不能选呢?此时对于 4L 这个 attributeres 数组应为 [{'1'}, {'30', '40'}] ,数组中不包含 0 且包含 1,此时应该是可选的。

代码实现如下所示:

    // 判断当前 attribute 是否可选,返回 true 表示可选,返回 false 表示不可选,选项置灰
    canAttributeSelect(attribute) {
      if (!this.selected || !this.selected.length || attribute.isActive) {
        return true;
      }
      let res = [];
      this.selected.forEach((value) => {
        const index1 = this.vertexList.indexOf(value);
        const index2 = this.vertexList.indexOf(attribute.value);
        res.push(this.matrix[index1][index2]);
      });
      // console.log(attribute.value, '->', res);
      if(res.some((item)=> (item === 0))) {
        return false;
      }
      else if(res.some((item) => (item.has('1')))) {
        return true;
      }
      else {
        const first = res[0];
        const others = res.slice(1);
        return Array.from(first).some((skuId) => (others.every((item) => (item.has(skuId)))));
      }
    },

三、Vue源码

data.js
存储数据 propertiesskuList 初始值

export const properties = [
  {
    id: "1",
    name: "容量",
    attributes: [
      { value: "1L", isActive: false, isDisabled: false },
      { value: "4L", isActive: false, isDisabled: false },
    ],
  },
  {
    id: "2",
    name: "颜色",
    attributes: [
      { value: "红色", isActive: false, isDisabled: false },
      { value: "黑色", isActive: false, isDisabled: false },
    ],
  },
  {
    id: "3",
    name: "优惠套餐",
    attributes: [
      { value: "套餐一", isActive: false, isDisabled: false },
      { value: "套餐二", isActive: false, isDisabled: false },
    ],
  },
];

export const skuList = [
  { id: "10", attributes: ["1L", "红色", "套餐一"] },
  { id: "20", attributes: ["1L", "黑色", "套餐二"] },
  { id: "30", attributes: ["4L", "红色", "套餐一"] },
  { id: "40", attributes: ["4L", "红色", "套餐二"] },
];

// 1L -> 套餐二, 红色 -> 套餐二, 1L+红色 -> 套餐二

SkuSelector.vue
引入 data.js 中的 propertiesskuList 赋初始值,运行时需要注意 data.js 文件路径,可能需要修改:

import { properties, skuList } from '../data';

完整代码如下所示:

<template>
  <div class="root">
    <p>商品多规格选择示例2.0</p>
    <div v-for="(property, propertyIndex) in properties" :key="propertyIndex">
      <p>{{ property.name }}</p>
      <div class="sku-box-area">
        <template v-for="(attribute, attributeIndex) in property.attributes">
          <div
            :key="attributeIndex"
            :class="[
              'sku-box',
              'sku-text',
              attribute.isActive ? 'active' : '',
              attribute.isDisabled ? 'disabled' : '',
            ]"
            @click="handleClickAttribute(propertyIndex, attributeIndex)"
          >
            {{ attribute.value }}
          </div>
        </template>
      </div>
    </div>
  </div>
</template>

<script>
import { properties, skuList } from '../data';

export default {
  name: "SkuSelector2",
  components: {},
  computed: {},
  data() {
    return {
      properties: [], // property 列表
      skuList: [], // sku 列表
      matrix: [], // 邻接矩阵存储无向图
      vertexList: [], // 顶点数组
      selected: [], // 当前已选的 attribute 列表
    };
  },
  mounted() {
    this.properties = properties;
    this.skuList = skuList;

    this.initEmptyAdjMatrix();
    this.setAdjMatrixValue();
  },
  methods: {
    // 当点击某个 attribute 时,如:黑色
    handleClickAttribute(propertyIndex, attributeIndex) {
      const attr = this.properties[propertyIndex].attributes[attributeIndex];
      // 若选项置灰,直接返回,表现为点击无响应
      if (attr.isDisabled) {
        return;
      }

      // 重置每个 attribute 的 isActive 状态
      const isActive = !attr.isActive;
      this.properties[propertyIndex].attributes[attributeIndex].isActive =
        isActive;
      if (isActive) {
        this.properties[propertyIndex].attributes.forEach((attr, index) => {
          if (index !== attributeIndex) {
            attr.isActive = false;
          }
        });
      }

      // 维护当前已选的 attribute 列表
      this.selected = [];
      this.properties.forEach((prop) => {
        prop.attributes.forEach((attr) => {
          if (attr.isActive) {
            this.selected.push(attr.value);
          }
        });
      });

      // 重置每个 attribute 的 isDisabled 状态
      this.properties.forEach((prop) => {
        prop.attributes.forEach((attr) => {
          attr.isDisabled = !this.canAttributeSelect(attr);
        });
      });
    },

    // 构造初始空邻接矩阵存储无向图
    initEmptyAdjMatrix() {
      this.properties.forEach((prop) => {
        prop.attributes.forEach((attr) => {
          this.vertexList.push(attr.value);
        });
      });
      for (let i = 0; i < this.vertexList.length; i++) {
        this.matrix[i] = new Array(this.vertexList.length).fill(0);
      }
    },

    // 根据 skuList 和 properties 设置邻接矩阵的值
    setAdjMatrixValue() {
      this.skuList.forEach((sku) => {
        this.associateAttributes(sku.attributes, sku.id);
      });
      this.properties.forEach((prop) => {
        this.associateAttributes(prop.attributes, '1');
      });
    },

    // 将 attributes 属性组中的属性在无向图中联系起来
    associateAttributes(attributes, skuId) {
      attributes.forEach((attr1) => {
        attributes.forEach((attr2) => {
          // 因 properties 与 skuList 数据结构不一致,需作处理
          if (attr1 !== attr2 || attr1.value !== attr2.value) {
            if (attr1.value && attr2.value) {
              attr1 = attr1.value;
              attr2 = attr2.value;
            }
            const index1 = this.vertexList.indexOf(attr1);
            const index2 = this.vertexList.indexOf(attr2);
            if (index1 > -1 && index2 > -1) {
              if(this.matrix[index1][index2]) {
                this.matrix[index1][index2].add(skuId);
              }
              else {
                this.matrix[index1][index2] = new Set([skuId]);
              }
            }
          }
        });
      });
    },

    // 判断当前 attribute 是否可选,返回 true 表示可选,返回 false 表示不可选,选项置灰
    canAttributeSelect(attribute) {
      if (!this.selected || !this.selected.length || attribute.isActive) {
        return true;
      }
      let res = [];
      this.selected.forEach((value) => {
        const index1 = this.vertexList.indexOf(value);
        const index2 = this.vertexList.indexOf(attribute.value);
        res.push(this.matrix[index1][index2]);
      });
      console.log(attribute.value, '->', res);
      if(res.some((item)=> (item === 0))) {
        return false;
      }
      else if(res.some((item) => (item.has('1')))) {
        return true;
      }
      else {
        const first = res[0];
        const others = res.slice(1);
        return Array.from(first).some((skuId) => (others.every((item) => (item.has(skuId)))));
      }
    },
  },
};
</script>

<style>
.root {
  width: 350px;
  padding: 24px;
}
.sku-box-area {
  display: flex;
  flex: 1;
  flex-direction: row;
  flex-wrap: wrap;
}
.sku-box {
  border: 1px solid #cccccc;
  border-radius: 6px;
  margin-right: 12px;
  padding: 8px 10px;
  margin-bottom: 10px;
}
.sku-text {
  font-size: 16px;
  line-height: 16px;
  color: #666666;
}
.active {
  border-color: #ff6600;
  color: #ff6600;
}
.disabled {
  opacity: 0.5;
  border-color: #e0e0e0;
  color: #999999;
}
</style>

四、实现效果

可以看到已修复了bug,选择了 1L红色 之后,套餐二 不可选,同理,选择了 1L套餐二 之后,红色 不可选。
前端商品多规格选择问题 SKU 算法实现优化2.0_第2张图片

五、其他

考虑更多边界情况,发现存在如下问题:

  1. 问题:代码实现中 handleClickAttribute(propertyIndex, attributeIndex) 使用到 index,因此依赖于数据源中 properties 数组按序存储,若顺序调换则会出错
    解决:可以在 mounted 对数据源 properties 进行处理,使之按序存放
  2. 问题:构建图时将顶点设置为 attributevalue,若不同的属性有相同的值(虽然这种情况很少发生,但仍需考虑),则会出错
    解决:可以改为设置为 id-value 的形式,如 19701-黑色,比较简便的方法是在 mounted 对数据源 properties 进行处理,其他原有的代码不需要变更
  3. 问题:刚开始的想法是动态维护一个数组 filteredSkuList 表示经过当前的选择之后支持的 sku,用数组的长度是否为 1 来判断是否选择完毕,这样若第一个属性有两个值,第二个属性有一个值,则第一个属性选择之后,不管第二个属性有没有选,数组的长度都将是 1,会出错
    解决:可以通过当前已选择的 property 的个数判断是否与 property 的总数相等,即通过判断 this.selected.lengththis.properties.length 是否相等

你可能感兴趣的:(前端开发,前端,算法)