在阅读本文之前,请先阅读笔者上一篇文章:前端商品多规格选择问题 SKU 算法实现
上一篇文章最后提到实现的算法存在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", "红色", "套餐二"] },
];
实现效果如下所示:
你发现了吗,明明 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个求解步骤中需要修改的部分。
这部分并没有修改,与原相同,代码实现如下所示:
// 构造初始空邻接矩阵存储无向图
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);
}
},
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 |
原逻辑是判断得到的 res
数组中是否每个值均为1,若符合则可选;否则若其中一个值为0,则不可选置灰。
修改后的逻辑更为复杂,感觉这块有可能出bug。如果有小伙伴发现了评论区告诉我~
我的思路是:此时 res
数组中存储的元素可能有两种类型,Number
类型的 0
或者 Object
类型的 Set
对象, Set
对象中存储的可能为 1
或者 skuList
中存在的 skuId
, 1
和 skuId
是 String
类型。我将结果区分为三种情况,注意三种情况有先后关系,后一种排除前一种存在的可能,即使用 if - else if - else
的控制流:
res
数组中存在值为 0
的元素,则返回 false
,表示需要置灰,这种情况与原逻辑相同res
数组中存储的是 Set
对象, Set
对象存储的是 1
或者 skuId
。若 res
数组中存在包含值为 1
的 Set
对象,则返回 true
,表示可选。res
数组中存储的是 Set
对象, Set
对象中存储的是 skuId
。当且仅当每个 Set
对象中包含相同的一个 skuId
时,可选返回 true
,否则不可选返回 false
一、三情况比较容易理解,对于第二种情况举例说明:选择了 1L
和 红色
之后,4L
能不能选呢?此时对于 4L
这个 attribute
, res
数组应为 [{'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)))));
}
},
data.js
存储数据 properties
和 skuList
初始值
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
中的 properties
和 skuList
赋初始值,运行时需要注意 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
和 套餐二
之后,红色
不可选。
考虑更多边界情况,发现存在如下问题:
handleClickAttribute(propertyIndex, attributeIndex)
使用到 index
,因此依赖于数据源中 properties
数组按序存储,若顺序调换则会出错mounted
对数据源 properties
进行处理,使之按序存放attribute
的 value
,若不同的属性有相同的值(虽然这种情况很少发生,但仍需考虑),则会出错id-value
的形式,如 19701-黑色
,比较简便的方法是在 mounted
对数据源 properties
进行处理,其他原有的代码不需要变更filteredSkuList
表示经过当前的选择之后支持的 sku
,用数组的长度是否为 1
来判断是否选择完毕,这样若第一个属性有两个值,第二个属性有一个值,则第一个属性选择之后,不管第二个属性有没有选,数组的长度都将是 1
,会出错property
的个数判断是否与 property
的总数相等,即通过判断 this.selected.length
与 this.properties.length
是否相等