前端商品多规格 SKU 选择问题

1,215 阅读3分钟

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

业务场景

需要实现的业务场景是,要根据用户每一次选择的规格,确定剩下可选和不可选的规格,表现在前端页面上,即将不可选的规格置灰。这是一个十分常见的业务场景,在淘宝、京东、拼多多等电商平台上均需要用户选择规格属性。

9338d8cea1de48e582d897a49cc74c95.gif

实现思路

  1. 创建顶点集和空邻接矩阵

    此时的邻接矩阵:

    1L4L红色黑色
    1L0000
    4L0000
    红色0000
    黑色0000

image-20220830173021828.png

  1. 根据后端配置数据,更新邻接矩阵 image-20220830173230572.png

但这步工作还没有完成,若最终的无向图如上图所示,会出现什么情况呢?在选择了 1L 之后,4L 就置灰不可选了,这显然不合常理。所以需要根据每列规格属性将顶点连接起来。(如:将 1L4L 连接起来)

初始化后的邻接矩阵:

1L4L红色黑色
1L0111
4L1010
红色1101
黑色1010

image-20220830173505558.png

  1. 每次选中状态时,根据邻接矩阵处理禁用项

    根据选中值查询矩阵值,来判断是否禁用。

    假设选中4L,则颜色属性就可以根据交点值判断哪些选项可选,哪些不可选。

image-20220830174513964.png

具体操作

  1. 创建顶点集和空邻接矩阵

     // 异步获取到某商品的相关数据 
     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 },
             ],
           },
         ];
     ​
     skuList = [ // 库存单元Array,即每一个单规格选项Array
           { id: "10", attributes: ["1L", "红色"] },
           { id: "20", attributes: ["1L", "黑色"] },
           { id: "30", attributes: ["4L", "红色"] },
         ];
    

    根据该数据结构,创建顶点集

         // 构造初始空邻接矩阵存储无向图
         initMatrix() {
           let matrix = [] // 矩阵
           let vertexList = [] // 顶点集
           
           this.properties.forEach((prop) => {
             prop.attributes.forEach((attr) => {
               vertexList.push(attr.value);
             });
           });
             
           const len = this.vertexList.length
           for (let i = 0; i < len; i++) {
             matrix[i] = new Array(len).fill(0);
           }
         },
     ​
    
  2. 根据后端配置数据,更新邻接矩阵

         // 根据 skuList 和 properties 设置邻接矩阵的值
         setAdjMatrixValue() {
             // 处理后端配置数据
           this.skuList.forEach((sku) => {
             this.associateAttributes(sku.attributes);
           });
             // 处理同级节点的情况 (【红,黑】)
           this.properties.forEach((prop) => {
             this.associateAttributes(prop.attributes);
           });
         },
     ​
         // 将 attributes 属性组中的属性在无向图中联系起来
         associateAttributes(attributes) {
           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) {
                     // 更新邻接矩阵
                   this.matrix[index1][index2] = 1;
                 }
               }
             });
           });
         },
    
  3. 每次选中状态时,根据邻接矩阵处理禁用项

    根据选中项的不同,如选中4L,遍历顶点集与4L 的交点值

           // 重置每个 attribute 的 isDisabled 状态
        this.properties.forEach((prop) => {
             prop.attributes.forEach((attr) => {
               attr.isDisabled = !this.canAttributeSelect(attr);
             });
           });
         },
     ​
     // 判断当前 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]);
           });
           return res.every((item) => item === 1);
         },
     ​
    

image-20220830180710498.png

vue源码

 <template>
   <div class="root">
     <p>商品多规格选择示例</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>
 export default {
   name: "SkuSelector",
   components: {},
   computed: {},
   data() {
     return {
       properties: [], // property 列表
       skuList: [], // sku 列表
       matrix: [], // 邻接矩阵存储无向图
       vertexList: [], // 顶点数组
       selected: [], // 当前已选的 attribute 列表
     };
   },
   mounted() {
     this.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 },
         ],
       },
     ];
     this.skuList = [
       { id: "10", attributes: ["1L", "红色"] },
       { id: "20", attributes: ["1L", "黑色"] },
       { id: "30", attributes: ["4L", "红色"] },
       // { id: "40", attributes: ["4L", "黑色"] },
     ];
 ​
     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);
       });
       this.properties.forEach((prop) => {
         this.associateAttributes(prop.attributes);
       });
     },
 ​
     // 将 attributes 属性组中的属性在无向图中联系起来
     associateAttributes(attributes) {
       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) {
               this.matrix[index1][index2] = 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]);
       });
       return res.every((item) => item === 1);
     },
   },
 };
 </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>


最后一句

学习心得!若有不正,还望斧正。希望掘友们不要吝啬对我的建议。