商品 sku 在库存影响下的选中与禁用

分享一下,最近使用 React 封装的一个 Skus 组件,主要用于处理商品的sku在受到库存的影响下,sku项的选中和禁用问题;

需求分析

需要展示商品各规格下的sku信息,以及根据该sku的库存是否为空,判断是否禁用该sku的选择。

商品 sku 在库存影响下的选中与禁用

以下讲解将按照我的 Skus组件 来,我这里放上我组件库中的线上 demo 和码上掘金的一个 demo 供大家体验;由于码上掘金导入不了组件库,我就上传了一份开发组件前的一份类似的代码,功能和代码思路是差不多的,大家也可以自己尝试写一下,可能你的思路会更优;

线上 Demo 地址

码上掘金

传入的sku数据结构

需要传入的商品的sku数据类型大致如下:

type SkusProps = { 
  /** 传入的skus数据列表 */
  data: SkusItem[]
  // ... 其他的props
}

type SkusItem = {
  /** 库存 */
  stock?: number;
  /** 该sku下的所有参数 */
  params: SkusItemParam[];
};

type SkusItemParam = {
  name: string;
  value: string;
}

转化成需要的数据类型:

type SkuStateItem = {
  value: string;
  /** 与该sku搭配时,该禁用的sku组合 */
  disabledSkus: string[][];
}[];

生成数据

定义 sku 分类

首先假装请求接口,造一些假数据出来,我这里自定义了最多 6^6 = 46656 种 sku。

商品 sku 在库存影响下的选中与禁用

下面的是自定义的一些数据:

const skuData: Record<string, string[]> = {
  '颜色': ['红','绿','蓝','黑','白','黄'],
  '大小': ['S','M','L','XL','XXL','MAX'],
  '款式': ['圆领','V领','条纹','渐变','轻薄','休闲'],
  '面料': ['纯棉','涤纶','丝绸','蚕丝','麻','鹅绒'],
  '群体': ['男','女','中性','童装','老年','青少年'],
  '价位': ['<30','<50','<100','<300','<800','<1500'],
}
const skuNames = Object.keys(skuData)

页面初始化

  • checkValArr: 需要展示的sku分类是哪些;
  • skusList: 接口获取的skus数据;
  • noStockSkus: 库存为零对应的skus(方便查看)。
export default () => {
  // 这个是选中项对应的sku类型分别是哪几个。
  const [checkValArr, setCheckValArr] = useState<number[]>([4, 5, 2, 3, 0, 0]);
  // 接口请求到的skus数据
  const [skusList, setSkusList] = useState<SkusItem[]>([]);
  // 库存为零对应的sku数组
  const [noStockSkus, setNoStockSkus] = useState<string[][]>([])

  useEffect(() => {
    const checkValTrueArr = checkValArr.filter(Boolean)
    const _noStockSkus: string[][] = [[]]
    const list = getSkusData(checkValTrueArr, _noStockSkus)
    setSkusList(list)
    setNoStockSkus([..._noStockSkus])
  }, [checkValArr])
  
  // ....

  return <>...</>
}

根据上方的初始化sku数据,生成一一对应的sku,并随机生成对应sku的库存。

getSkusData 函数讲解

先看总数(total)为当前需要的各sku分类的乘积;比如这里就是上面传入的 checkValArr 数组 [4,5,2,3]120种sku选择。对应的就是 skuData 中的 [颜色前四项,大小前五项,款式前两项,面料前三项] 即下图的展示。

商品 sku 在库存影响下的选中与禁用

遍历 120 次,每次生成一个sku,并随机生成库存数量,40%的概率库存为0;然后遍历 skuNames 然后找到当前对应的sku分类即 [颜色,大小,款式,面料] 4项;

接下来就是较为关键的如何根据 sku的分类顺序 生成对应的 120个相应的sku。

请看下面代码中注释为 LHH-1 的地方,该 value 的获取是通过 indexArr 数组取出来的。可以看到上面 indexArr 数组的初始值为 [0,0,0,0] 4个零的索引,分别对应 4 个sku的分类;

  • 第一次遍历:

indexArr: [0,0,0,0] -> skuName.forEach -> 红,S,圆领,纯棉

看LHH-2标记处: 索引+1 -> indexArr: [0,0,0,1];

  • 第二次遍历:

indexArr: [0,0,0,1] -> skuName.forEach -> 红,S,圆领,涤纶

看LHH-2标记处: 索引+1 -> indexArr: [0,0,0,2];

  • 第三次遍历:

indexArr: [0,0,0,2] -> skuName.forEach -> 红,S,圆领,丝绸

看LHH-2标记处: 由于已经到达该分类下的最后一个,所以前一个索引加一,后一个重新置为0 -> indexArr: [0,0,1,0];

  • 第四次遍历:

indexArr: [0,0,1,0] -> skuName.forEach -> 红,S,V领,纯棉

看LHH-2标记处: 索引+1 -> indexArr: [0,0,1,1];

  • 接下来的一百多次遍历跟上面的遍历同理

商品 sku 在库存影响下的选中与禁用

function getSkusData(skuCategorys: number[], noStockSkus?: string[][]) {
  // 最终生成的skus数据;
  const skusList: SkusItem[] = []
  // 对应 skuState 中各 sku ,主要用于下面遍历时,对 product 中 skus 的索引操作
  const indexArr = Array.from({length: skuCategorys.length}, () => 0);
  // 需要遍历的总次数
  const total = skuCategorys.reduce((pre, cur) => pre * (cur || 1), 1)
  for(let i = 1; i <= total; i++) {
    const sku: SkusItem = {
      // 库存:60%的几率为0-50,40%几率为0
      stock: Math.floor(Math.random() * 10) >= 4 ? Math.floor(Math.random() * 50) : 0,
      params: [],
    }
    // 生成每个 sku 对应的 params 
    let skuI = 0;
    skuNames.forEach((name, j) => {
      if(skuCategorys[j]) {
        // 注意:LHH-1
        const value = skuData[name][indexArr[skuI]]
        sku.params.push({
          name,
          value,
        })
        skuI++;
      }
    })
    skusList.push(sku)

    // 注意: LHH-2
    indexArr[indexArr.length - 1]++;
    for(let j = indexArr.length - 1; j >= 0; j--) {
      if(indexArr[j] >= skuCategorys[j] && j !== 0) {
        indexArr[j - 1]++
        indexArr[j] = 0
      }
    }

    if(noStockSkus) {
      if(!sku.stock) {
        noStockSkus.at(-1)?.push(sku.params.map(p => p.value).join(' / '))
      }
      if(indexArr[0] === noStockSkus.length && noStockSkus.length < skuCategorys[0]) {
        noStockSkus.push([])
      }
    }
  }
  return skusList
}

Skus 组件的核心部分的实现

初始化数据

需要将上面生成的数据转化为以下结构:

type SkuStateItem = {
  value: string;
  /** 与该sku搭配时,该禁用的sku组合 */
  disabledSkus: string[][];
}[];

export default function Skus() {
  // 转化成遍历判断用的数据类型
  const [skuState, setSkuState] = useState<Record<string, SkuStateItem>>({});
  // 当前选中的sku值
  const [checkSkus, setCheckSkus] = useState<Record<string, string>>({});
  
  // ...
}

将初始sku数据生成目标结构

根据 data (即上面的假数据)生成该数据结构。

第一次遍历是对skus第一项进行的,会生成如下结构:

const _skuState = {
  '颜色': [{value: '红', disabledSkus: []}],
  '大小': [{value: 'S', disabledSkus: []}],
  '款式': [{value: '圆领', disabledSkus: []}],
  '面料': [{value: '纯棉', disabledSkus: []}],
}

第二次遍历则会完整遍历剩下的skus数据,并往该对象中填充完整。

export default function Skus() {
  // ...
  useEffect(() => {
    if(!data?.length) return
    // 第一次对skus第一项的遍历
    const _checkSkus: Record<string, string> = {}
    const _skuState = data[0].params.reduce((pre, cur) => {
      pre[cur.name] = [{value: cur.value, disabledSkus: []}]
      _checkSkus[cur.name] = ''
      return pre
    }, {} as Record<string, SkuStateItem>)
    setCheckSkus(_checkSkus)

    // 第二次遍历
    data.slice(1).forEach(item => {
      const skuParams = item.params
      skuParams.forEach((p, i) => {
        // 当前 params 不在 _skuState 中
        if(!_skuState[p.name]?.find(params => params.value === p.value)) {
          _skuState[p.name].push({value: p.value, disabledSkus: []})
        }
      })
    })
    
    // ...接下面
  }, [data])
}

第三次遍历主要用于为每个 sku的可点击项 生成一个对应的禁用sku数组 disabledSkus ,只要当前选择的sku项,满足该数组中的任一项,该sku选项就会被禁用。之所以保存这样的一个二维数组,是为了方便后面点击时的条件判断(有点空间换时间的概念)。

遍历 data 当库存小于等于0时,将当前的sku的所有参数传入 disabledSkus 中。

例:第一项 sku(红,S,圆领,纯棉)库存假设为0,则该选项会被添加到 disabledSkus 数组中,那么该sku选择时,勾选前三个后,第四个 纯棉 的勾选会被禁用。

商品 sku 在库存影响下的选中与禁用

export default function Skus() {
  // ...
  useEffect(() => {
    // ... 接上面
    // 第三次遍历
    data.forEach(sku => {
      // 遍历获取库存需要禁用的sku
      const stock = sku.stock!
      // stockLimitValue 是一个传参 代表库存的限制值,默认为0
      // isStockGreaterThan 是一个传参,用来判断限制值是大于还是小于,默认为false
      if(
        typeof stock === 'number' && 
        isStockGreaterThan ? stock >= stockLimitValue : stock <= stockLimitValue
      ) {
        const curSkuArr = sku.params.map(p => p.value)
        for(const name in _skuState) {
          const curSkuItem = _skuState[name].find(v => curSkuArr.includes(v.value))
          curSkuItem?.disabledSkus?.push(
            sku.params.reduce((pre, p) => {
              if(p.name !== name) {
                pre.push(p.value)
              }
              return pre
            }, [] as string[])
          )
        }
      }
    })

    setSkuState(_skuState)
  }, [data])
}

遍历渲染 skus 列表

根据上面的 skuState,生成用于渲染的列表,渲染列表的类型如下:

type RenderSkuItem = {
  name: string;
  values: RenderSkuItemValue[];
}
type RenderSkuItemValue = {
  /** sku的值 */
  value: string;
  /** 选中状态 */
  isChecked: boolean
  /** 禁用状态 */
  disabled: boolean;
}

export default function Skus() {
  // ...
  /** 用于渲染的列表 */
  const list: RenderSkuItem[] = []
  for(const name in skuState) {
    list.push({
      name,
      values: skuState[name].map(sku => {
        const isChecked = sku.value === checkSkus[name]
        const disabled = isChecked ? false : isSkuDisable(name, sku)
        return { value: sku.value, disabled, isChecked }
      })
    })
  }
  // ...
}

html css 大家都会,以下就简单展示了。最外层遍历sku的分类,第二次遍历遍历每个sku分类下的名称,第二次遍历的 item(类型为:RenderSkuItemValue),里面会有sku的值,选中状态和禁用状态的属性。

export default function Skus() {
  // ...
  return list?.map((p) => (
    <div key={p.name}>
      {/* 例:颜色、大小、款式、面料 */}
      <div>{p.name}</div>
      <div>
        {p.values.map((sku) => (
          <div 
            key={p.name + sku.value} 
            onClick={() => selectSkus(p.name, sku)}
          >
            {/* classBem 是用来判断当前状态,增加类名的一个方法而已 */}
            <span className={classBem(`sku`, {active: sku.isChecked, disabled: sku.disabled})}>
              {/* 例:红、绿、蓝、黑 */}
              {sku.value} 
            </span>
          </div>
        ))}
      </div>
    </div>
  ))
}

selectSkus 点击选择 sku

通过 checkSkus 设置 sku 对应分类下的 sku 选中项,同时触发 onChange 给父组件传递一些信息出去。

const selectSkus = (skuName: string, {value, disabled, isChecked}: RenderSkuItemValue) => {
  const _checkSkus = {...checkSkus}
  _checkSkus[skuName] = isChecked ? '' : value;
  const curSkuItem = getCurSkuItem(_checkSkus)
  // 该方法主要是 sku 组件点击后触发的回调,用于给父组件获取到一些信息。
  onChange?.(_checkSkus, {
    skuName,
    value,
    disabled,
    isChecked: disabled ? false : !isChecked,
    dataItem: curSkuItem,
    stock: curSkuItem?.stock
  })
  if(!disabled) {
    setCheckSkus(_checkSkus)
  }
}

getCurSkuItem 获取当前选中的是哪个sku

  • isInOrder.current 是用来判断当前的 skus 数据是否是整齐排列的,这里当成 true 就好,判断该值的过程就不放到本文了,感兴趣可以看 源码

由于sku是按顺序排列的,所以只需按顺序遍历上面生成的 skuState,找出当前sku选中项对应的索引位置,然后通过 就可以直接得出对应的索引位置。这样的好处是能减少很多次遍历。

如果直接遍历原来那份填充所有 sku 的 data 数据,则需要很多次的遍历,当sku是 6^6 时, 则每次变换选中的sku时最多需要 46656 * 6 (data总长度 * 里面 sku 的 params) 次。

const getCurSkuItem = (_checkSkus: Record<string, string>) => {
  const length = Object.keys(skuState).length
  if(!length || Object.values(_checkSkus).filter(Boolean).length < length) return void 0
  if(isInOrder.current) {
    let skuI = 0;
    // 由于sku是按顺序排列的,所以索引可以通过计算得出
    Object.keys(_checkSkus).forEach((name, i) => {
      const index = skuState[name].findIndex(v => v.value === _checkSkus[name])
      const othTotal = Object.values(skuState).slice(i + 1).reduce((pre, cur) => (pre *= cur.length), 1)
      skuI += index * othTotal;
    })
    return data?.[skuI]
  }
  // 这样需要遍历太多次
  return data.find(s => (
    s.params.every(p => _checkSkus[p.name] === getSkuParamValue(p))
  ))
}

isSkuDisable 判断该 sku 是否是禁用的

该方法是在上面 遍历渲染 skus 列表 时使用的。

  1. 开始还未有选中值时,需要校验 disabledSkus 的数组长度,是否等于该sku参数可以组合的sku总数,如果相等则表示禁用。

  2. 判断当前选中的 sku 还能组成多少种组合。例:当前选中 红,S ,而 isSkuDisable 方法当前判断的 sku 为 款式 中的 圆领,则还有三种组合 红\S\圆领\纯棉红\S\圆领\涤纶红\S\圆领\丝绸

  3. 如果当前判断的 sku 的 disabledSkus 数组中存在这三项,则表示该 sku 选项会被禁用,无法点击。

const isCheckValue = !!Object.keys(checkSkus).length

const isSkuDisable = (skuName: string, sku: SkuStateItem[number]) => {
  if(!sku.disabledSkus.length) return false
  // 1.当一开始没有选中值时,判断某个sku是否为禁用
  if(!isCheckValue) {
    let checkTotal = 1;
    for(const name in skuState) {
      if(name !== skuName) {
        checkTotal *= skuState[name].length
      }
    }
    return sku.disabledSkus.length === checkTotal
  }

  // 排除当前的传入的 sku 那一行
  const newCheckSkus: Record<string, string> = {...checkSkus}
  delete newCheckSkus[skuName]

  // 2.当前选中的 sku 一共能有多少种组合
  let total = 1;
  for(const name in newCheckSkus) {
    if(!newCheckSkus[name]) {
      total *= skuState[name].length
    }
  }

  // 3.选中的 sku 在禁用数组中有多少组
  let num = 0;
  for(const strArr of sku.disabledSkus) {
    if(Object.values(newCheckSkus).every(str => !str ? true : strArr.includes(str))) {
      num++;
    }
  }

  return num === total
}

至此整个商品sku从生成假数据到sku的选中和禁用的处理的核心代码就完毕了。还有更多的细节问题可以直接查看 源码 会更清晰。

原文链接:https://juejin.cn/post/7313979106890842139 作者:滑动变滚动的蜗牛

(0)
上一篇 2023年12月19日 上午10:16
下一篇 2023年12月19日 上午10:26

相关推荐

发表回复

登录后才能评论