前端SKU算法的实现

16,190 阅读6分钟

本文将提供一种前端SKU算法的实现思路,这可能并不是最佳的实现方式,但可以为没有思路的小伙伴提供一种解决方案。

对于SPU与SKU概念还不了解的小伙伴,请先移步认识SKU与SPU了解一下大致概念,本文便不再详述。

实现效果预览

实现思路

对于前端来说,如果没真的做过SKU,可能并不会了解到它的复杂之处,仔细观察,小小的规格选择,却包含着很多实现细节难点,这里提供一种实现思路供大家参考。

1. 获取后端数据

这里以7七月老师提供的商品数据为例,由于数据过长,这里只贴出sku_list中的一条数据,这条数据格式也是整个实现过程中最重要的数据格式。

 {
    ...
    sku_list: [
        {
          "id":2,
          "price":77.76,
          "discount_price":null,
          "online":true,
          "img":"",
          "title":"金属灰·七龙珠",
          "spu_id":2,
          "category_id":17,
          "root_category_id":3,
          "specs":[
              {
                  "key_id":1,
                  "key":"颜色",
                  "value_id":45,
                  "value":"金属灰"
              },
              {
                  "key_id":3,
                  "key":"图案",
                  "value_id":9,
                  "value":"七龙珠"
              },
              {
                  "key_id":4,
                  "key":"尺码",
                  "value_id":14,
                  "value":"小号 S"
              }
          ],
          "code":"2$1-45#3-9#4-14",
          "stock":5
        },
        ...
    ],
    ...
 }

新建一个类Spu模拟从后端获取数据

class Spu {
  data = []

  constructor() {
    this.data = this.getData();
  }

  getData() {
    const data = sku_list;  //这里的sku_list就是七月老师提供的数据中对应字段的数据,我们目前只需要这些就可以了
    return data
  }
}

2.提取规格数据

后端给我们的数据中只有单品---不同规格组合好的商品列表,我们需要从单品列表中获取到所有的规格。这里我们可以从数据中看到每个单品的specs属性就是这个单品所包含的不同规格,我们需要将其提取出来。

currentSkuList = []

_getCurrentSkuList() {
  this.currentSkuList = this.data.map(item => item.specs);
}

提取结果如下

3.矩阵转置

我们提取到的数据格式为

颜色     图案     尺码
金属灰   七龙珠   小号 S
青芒色   灌篮高手 中号 M
青芒色   圣斗士   大号 L
橘黄色   七龙珠   小号 S

但根据效果图,我们需要如下这种格式的数据,因此我们就需要将上面这个矩阵转置,它可能不是一个标准的矩阵,因为每种规格的数目不一定相同,这里相同可以更好的理解。

enter description here

_transMatrix() {
    this._getCurrentSkuList();

    let transResult = {};

    this.currentSkuList.forEach(specs => {
      specs.forEach(item => {
        if(!transResult[item['key_id']]) {
          transResult[item['key_id']] = {
            key_id: item['key_id'],
            key: item['key'],
            value_list: {
              [item['value_id']]: {
                value_id: item['value_id'],
                value: item['value'],
                selected: false,
                disabled: false
              }
            }
          }
        } else if (!transResult[item['key_id']].value_list[item['value_id']]) {
          transResult[item['key_id']].value_list[item['value_id']] = {
            value_id: item['value_id'],
            value: item['value'],
            selected: false,
            disabled: false
          }
        }
      })
    })
    return transResult;
  }

转换结果

这里利用了JavaScript对象的灵活性,设置了一种数据结构

{
    key_id: { //规格id
        key: string, // 规格名称 
        key_id: number, // 规格id
        [value_list: object]: {  // 规格值列表 object
            value_id: { // 规格值id
                value_id: number, // 规格值id
                value: string, // 规格值
                selected: boolean, // 是否选中
                disabled: boolean, // 是否不可选
            }
        }
    },
    key_id: {...},
    ...
    
}

通过对currentSkuList当前单品列表的遍历和筛选,将其提取为上述数据格式的数据,这里使用对象而不是数组的方式是为了更方便的进行提取

将提取结果变为数组格式

  getAllSpecsList() {
    const transResult = this._transMatrix();
    this.allSpecsList = Object.keys(transResult).map(key => {
      let obj = JSON.parse(JSON.stringify(transResult[key]));
      obj.value_list = Object.keys(obj.value_list).map(vk => obj.value_list[vk]);
      return obj;
    })
    return this.allSpecsList;
  }

将转置的矩阵变为数组,方便我们在页面中使用wx:for循环,如果使用的reactvue来做循环,使用数组也是很方便的,如果直接使用上面的对象格式,还要再做一次转换数组,所以为了方便直接使用,我们再做一遍转换。

上面使用了JSON.parse(JSON.stringify(obj))的方式对转置后的矩阵对象做了一个深拷贝,通过Object.keys()Array.mapAPI很方便就可以将之前的对象转换为数组

转换结果

enter description here

Tips: 注意这里不是二维数组,而是之前我们设计的数据结构的对象数组,使用这种数据结构可以是我们后续轻易获取我们想要的数据。

4.最重要的一步:遍历

这一步的标题实在不知道该叫什么,但这是我们整个SKU算法实现的最重要的一步。

选择逻辑的设计 对于选择一个规格,其他规格中只有对应可选的部分能选择,其余部分不可选,直接的视觉效果就是置灰。但是如何进行联动,我们选择一个规格后,后面可能还有很多中规格,我们案例中选择一种之后还有两种,但事实上可能并不止如此,剩余的可能有七八中规格,每种规格甚至可能会有10多种值,每次选取的可能是这些规格中的任意一个,如果暴力循环所有的可能性,效果可能会很差。

这里提供一种思路,就是根据后端给的SKU列表来确定一种规格对应其他规格中可选的部分,描述不是很清楚,下面举个例子来说明: 知道了颜色规格为青芒色,那么其他规格中图案只有两种可以选:灌篮高手圣斗士,尺码的规格只有中号 M大号 L可选。

那么我们就可以确定这样一张表:

那么是不是可以这样来想,我们每次点击选中一种规格之后,就去遍历其他规格,在遍历其他规格的时候,如果这种规格中的规格值不在我们的列表中,那么就给他置灰不可选(disabled),这样我们需要关心的事情就很简单了,再来捋一捋

我们每次点击一个规格值之后,我们不用关心这种规格了,比如选了青芒色,就不用关心其他颜色了,只用关心其他种类的规格,其他种类规格里面也不需要全部都关心,我们只需要关心这种规格中有哪个可以跟我当前点的这个匹配。

举例: 如上表中,确定了颜色是灰色,就去遍历其他种类的规格(图案尺码),遍历图案的时候,只用知道七龙珠跟我匹配,其余不匹配的全部置灰,遍历尺码的时候,只要知道小号 S跟我匹配,其他一律置灰。

再选图案,就只能选七龙珠,选了七龙珠之后,再去遍历其他种类的规格,颜色中只有青芒色金属灰可以跟我匹配,其他全部置灰。

再选尺码,只能选S了,根据上述规则,再去置灰其他两种规格中不匹配的。

这样剩下的就是可选的了。

将上述思路转换为代码:

  getSelectable() {
    if(this.allSpecsList.length === 0) {
      this.allSpecsList()
    }

    if(this.currentSkuList.length === 0) {
      this.currentSkuList = this.data.map(item => item.specs);
    }
    
    const rowLength = this.allSpecsList.length;

    for(let row = 0; row < rowLength; row++) {
      let { key_id, key } = this.allSpecsList[row];
      let columnList = this.allSpecsList[row].value_list;
      this.selectable[key_id] = {
        key_id,
        key,
        selectableList: {}
      }
      for (let column = 0; column < columnList.length; column++) {
        let { value_id, value } = columnList[column];
        this.selectable[key_id].selectableList[value_id] = {
          value_id,
          value,
          matchItems: null
        }
      }

      this.currentSkuList.forEach(specificSpecs => {
        let matchItems = {};
        let currentVlaueId = '';
        specificSpecs.forEach(specsItem => {
          if(specsItem.key_id !== key_id) {
            matchItems[specsItem.key_id] = [specsItem]
          } else {
            currentVlaueId = specsItem.value_id;
          }
        })

        if (!this.selectable[key_id].selectableList[currentVlaueId].matchItems) {
          this.selectable[key_id].selectableList[currentVlaueId].matchItems = matchItems;
        } else {
          Object.keys(this.selectable[key_id].selectableList[currentVlaueId].matchItems).forEach(k => {
            this.selectable[key_id].selectableList[currentVlaueId].matchItems[k].push(...matchItems[k])
          })
        }

      })
    }

    return this.selectable;
  }

转换结果

这里又是借助JavaScript对象的灵活性设计了一种数据结构来表达我们上述的思路

{
    key_id: { // key_id 确定当前选了哪种类型的规格
        key: string, // 规格名
        key_id: number, // 规格id
        selectableList: { // 选择列表,用来描述我们上面画的表
            value_id: { // 当前选择了哪个规格,上面key_id确定了规格种类,这里value_id确定了这种规格中的具体规格,
                matchItems: {  // 当前规格对应其他规格的匹配项
                    key_id: [ // 当前规格匹配的种类规格的种类id,
                        {
                            key_id: numebr, // 种类id,这里为了方便数组中的直接取了后端返回的数据
                            key: string, // 规格种类名称
                            value_id: number, // 匹配项中的规格id
                            value: string, //匹配项中的规格名称
                        },
                        ...
                    
                    ]
                
                }
                
            }
            
        }
    },
    key_id: {...},
    ...
}

设计好数据结构之后,想办法通过后端数据给出的单品列表,确定每一个规格项可以匹配的其他规格,最终将数据转换为我们设计出来的数据结构。

5.书写简单的业务逻辑

上一步可谓是整个解决方案中最核心也是最难的地方了,只要获取了我们想要的数据结构,接下来就可以通过简单的业务逻辑来实现最后的效果。

页面的data配置

data: {
    sku: null,
    allSpecsList: [],
    selectable: null,
    selected: [],
    selectedItem: null,
    selectTips: '请选择:',
  }

初始化数据

initData() {
    const sku = new Sku();
    const allSpecsList = sku.getAllSpecsList();
    const selectable = sku.getSelectable();
    this.setData({
      allSpecsList,
      selectable,
      sku
    });
  },

初始化数据时候,我们创建Sku实例对象,将其保存在data中,再拿到allSpecsList(转置后的矩阵数组),来循环实现页面的规格展示。 拿到selectable方便后续处理点击某个规格时候对其他规格置灰的处理。

实现页面展示

<wxs src="../../wxs/specs.wxs" module="s"></wxs>
<view class="container">
  <view class="spu-info">
    <image class="selected-img" src="{{selectedItem && selectedItem.img ? selectedItem.img : 'http://i1.sleeve.7yue.pro/assets/5605cd6c-f869-46db-afe6-755b61a0122a.png'}}"></image>
    <view class="spu-container">
      <text class="spu-title">双色可选</text>
      <view class="spu-content">
        <view class="price">$1000</view>
        <view class="tips">
          {{selectTips}}
        </view>
      </view>
    </view>
  </view>
  <view class="select-options">
    <block wx:for="{{allSpecsList}}"
           wx:for-item="specs" 
           wx:for-index="x" 
           wx:key="specs.key_id"
    >
      <view class="specs-item">
        <text class="specs-title">{{specs.key}}</text>
        <view class="specs-value-list">
          <block wx:for="{{specs.value_list}}" 
                 wx:for-item="value" 
                 wx:for-index="y" 
                 wx:key="{{value.value_id}}"
          >
            <view class="value-container {{s.getButtonExtraClass(value.selected, value.disabled)}}" 
                  data-key_id="{{specs.key_id}}" 
                  data-value_id="{{value.value_id}}" 
                  data-select="{{s.getButtonStatus(value.selected, value.disabled)}}"
                  data-x="{{x}}"
                  data-y="{{y}}"
                  bindtap="handleClickSpecs"
            >
              <text class="value">{{value.value}}</text>
            </view>
          </block>
        </view>
      </view>
    </block>
  </view>
</view>

页面的实现主要就是我们定义的数据结构的循环遍历等操作,将数据展示过来。其实页面没什么好讲的,这里要说的主要是几个自定义data属性的设计:这里我们将一个按钮成为一个cell

  1. data-key_id: 标识当前cell的规格id,唯一确定一种规格
  2. data-value_id: 标识当前cell的具体规格id,唯一表示一种规格
  3. data-select: 通过wxs实现一个函数,传入selected与disabled,来确定当前cell是可选还是已选还是禁用状态,
  4. data-x: 用更简单的方式确定当前点击的cell是哪种类型的,只能确定相同x的是同一种规格,不能知道具体是哪种 5: data-y: 简单确定当前选中的cell是某种规格的一种,确定位置,x与y的设计可以更方便查找cell位置

再说明一下cell样式的确定也是通过wxs中的一个函数,传入seleted与disabled确定当前cell的样式。

点击某中规格时候的处理

handleClickSpecs(event) {
    const { key_id, value_id, select, x, y } = event.currentTarget.dataset
    if (select === 'disabled') {
      return;
    }
    
    if (select === 'selectable') {
      this.data.selected.forEach((item, index) => {
        if (item.x === x) {
          this.data.selected.splice(index, 1)
        }
      })
      this.data.selected.push({x, y, key_id, value_id});
      this.handleSelectOneOption(x, y, key_id, value_id);
      
    }

    if (select === 'active') {
      this.clearAllSelectedAndDisabled();
      this.data.selected.forEach((item, index) => {
        if (item.x === x && item.y === y) {
          this.data.selected.splice(index, 1);
        }
      })
      
      this.data.selected.forEach(item => {
        this.handleSelectOneOption(item.x, item.y, item.key_id, item.value_id);
      })      
    }

    this.setData({
      allSpecsList: this.data.allSpecsList,
      selected: this.data.selected
    })
  }

在点击具体的规格时候,我们会面临三种状态,默认的状态是可选的状态selectable,所以我们只用额外增加两种状态active已选状态和disabled禁用状态。通过event.currentTarget获取到点击的这个cell,然后通过其dataset属性获取到我们之前设计的一些辅助更简单获取当前cell属性的数据。

  1. 如果是disabled的状态,直接返回,因为不可点。
  2. 如果是selectable状态,当前cell可以点击,点击之后要对其他种类单元格做个遍历,将不匹配的置灰。并将当前选中的数据放入selected中保存起来方便后续使用,这里要强调的一点是在选择的时候要通过之前设置的x变量,遍历当前种类的规格,如果之前有选过,则删除它,因为一种规格只能选一个值。
  3. 如果是active状态,点击则需要将其置为可选状态,这时候如果想要将某些置灰属性恢复可选,是不太好操作的,我们这里选择一种简单的方式,就是将当前点击的元素从selected中删除,然后遍历selected中的元素,将其重新“点击”一遍(当然这里使用代码来点击)。

这样就实现了基本的功能其中handleSelectOneOption的代码如下,它实现了点击一个cell,去置灰其他种类的规格

handleSelectOneOption(x, y, key_id, value_id) {
    
    this.data.allSpecsList[x].value_list[y].selected = true;
    this.data.allSpecsList[x].value_list.forEach((specs, index) => {
      if (index === y) {
        specs.selected = true;
      } else {
        specs.selected = false;
      }
    })
    const selectableMatchItems = this.data.selectable[key_id].selectableList[value_id].matchItems;
    this.data.allSpecsList.forEach((specsRow, index) => {
      if (index === x) {
        return;
      }
      specsRow.value_list.forEach(specs => {
        specs.disabled = false;
        if (specs.selected) {
          return;
        }
        const result = selectableMatchItems[specsRow.key_id].find(item => item.value_id === specs.value_id);
        if (!result) {
          specs.disabled = true;
        }
      })
    })
  }

通过x选项我们可以知道我们不需要遍历当前x这一行(也就是这一种属性),如果不是当前种类的属性,就拉过来遍历,如果不跟当前规格匹配,那就置灰。 有个细节要注意一下,在置灰过程中首先恢复了这种属性的置灰状态,这是为了防止之前置灰的效果影响当前置灰的结果。

在点击active状态的cell时候,我们也有一个细节操作,就是先将所有的置灰与选中全部恢复默认,再重新操作,也是为了防止之前状态影响我们现在的结果。

清空所有置灰与选中的代码

 clearAllSelectedAndDisabled() {
    this.data.allSpecsList.forEach(row => {
      row.value_list.forEach(specs => {
        specs.selected = false;
        specs.disabled = false;
      })
    })
  }

6.完善细节:联动上面的提示文字

我们看到实现的效果,在我们点击的时候,上方提示文字会有请选择xxx,当选完之后,会有已选xxx的文字效果,下面将代码展示出来。

getSelectedInfo() {
    let selectTips = '';

    if(this.data.selected.length === this.data.allSpecsList.length) {
      let selectedText = []
      this.data.allSpecsList.forEach(rowSpecs => {
        rowSpecs.value_list.forEach(specs => {
          if(specs.selected) {
            selectedText.push(specs.value)
          }
        })
      })
      selectTips = '已选:' + selectedText.join(',');
      const selectedItem = this.data.sku.getSelectable(selectedText.join('·'));
      this.setData({
        selectTips,
        selectedItem
      })
      return 
    }
    const selectedRow = this.data.selected.map(item => item.x);
    const unSelected = []
    this.data.allSpecsList.forEach((item, index) => {
      if(!selectedRow.includes(index)) {
        unSelected.push(item.key)
      }
    })
    selectTips = '请选择:' + unSelected.join(',');
    this.setData({
      selectTips
    })
  }

主要思路就是查看selected已选列表中的数量跟规格列表的种类数量是不是相同,如果相同那就全部选中了,然后通过这些选中的规格可以确定一个SKU,我们就能拿到这个SKU的库存,价格,图像等信息,这里我们把这个SKU存起来了,添加库显示等可以直接在页面添加相关数据。

如果已选属性数量跟规格种类数量不匹配,那就找有哪个规格的数据没选,将其提示在上方。

小结

当前SKU算法的实现可能不是最好的,但自己感觉不是很难理解,最好的效果是看思路然后亲自动手实现一次,有可能看着简单,但是动手时候会有很多意想不到的困难。

最后说明一下这篇文章主要是对慕课网学习中7七月老师布置作业的独立完成和思考,所有思路与代码也都是自己一整天思考和动手完成的,如果有小伙伴想一起加入成长,可以慕课来报7七月老师的从java后端到全栈课程的学习。