分享一下,最近使用 React 封装的一个 Skus 组件,主要用于处理商品的sku在受到库存的影响下,sku项的选中和禁用问题;
需求分析
需要展示商品各规格下的sku信息,以及根据该sku的库存是否为空,判断是否禁用该sku的选择。
以下讲解将按照我的 Skus组件 来,我这里放上我组件库中的线上 demo 和码上掘金的一个 demo 供大家体验;由于码上掘金导入不了组件库,我就上传了一份开发组件前的一份类似的代码,功能和代码思路是差不多的,大家也可以自己尝试写一下,可能你的思路会更优;
线上 Demo 地址
码上掘金
传入的sku数据结构
需要传入的商品的sku数据类型大致如下:
type SkusProps = {
data: SkusItem[]
}
type SkusItem = {
stock?: number;
params: SkusItemParam[];
};
type SkusItemParam = {
name: string;
value: string;
}
转化成需要的数据类型:
type SkuStateItem = {
value: string;
disabledSkus: string[][];
}[];
生成数据
定义 sku 分类
首先假装请求接口,造一些假数据出来,我这里自定义了最多 6^6 = 46656
种 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 () => {
const [checkValArr, setCheckValArr] = useState<number[]>([4, 5, 2, 3, 0, 0]);
const [skusList, setSkusList] = useState<SkusItem[]>([]);
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
中的 [颜色前四项,大小前五项,款式前两项,面料前三项] 即下图的展示。
遍历 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]
;
function getSkusData(skuCategorys: number[], noStockSkus?: string[][]) {
const skusList: SkusItem[] = []
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 = {
stock: Math.floor(Math.random() * 10) >= 4 ? Math.floor(Math.random() * 50) : 0,
params: [],
}
let skuI = 0;
skuNames.forEach((name, j) => {
if(skuCategorys[j]) {
const value = skuData[name][indexArr[skuI]]
sku.params.push({
name,
value,
})
skuI++;
}
})
skusList.push(sku)
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;
disabledSkus: string[][];
}[];
export default function Skus() {
const [skuState, setSkuState] = useState<Record<string, SkuStateItem>>({});
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
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) => {
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选择时,勾选前三个后,第四个 纯棉
的勾选会被禁用。
export default function Skus() {
useEffect(() => {
data.forEach(sku => {
const stock = sku.stock!
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 = {
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)
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;
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 列表
时使用的。
- 开始还未有选中值时,需要校验 disabledSkus 的数组长度,是否等于该sku参数可以组合的sku总数,如果相等则表示禁用。
- 判断当前选中的 sku 还能组成多少种组合。例:当前选中
红,S
,而 isSkuDisable
方法当前判断的 sku 为 款式 中的 圆领
,则还有三种组合 红\S\圆领\纯棉
,红\S\圆领\涤纶
和 红\S\圆领\丝绸
。
- 如果当前判断的 sku 的
disabledSkus
数组中存在这三项,则表示该 sku 选项会被禁用,无法点击。
const isCheckValue = !!Object.keys(checkSkus).length
const isSkuDisable = (skuName: string, sku: SkuStateItem[number]) => {
if(!sku.disabledSkus.length) return false
if(!isCheckValue) {
let checkTotal = 1;
for(const name in skuState) {
if(name !== skuName) {
checkTotal *= skuState[name].length
}
}
return sku.disabledSkus.length === checkTotal
}
const newCheckSkus: Record<string, string> = {...checkSkus}
delete newCheckSkus[skuName]
let total = 1;
for(const name in newCheckSkus) {
if(!newCheckSkus[name]) {
total *= skuState[name].length
}
}
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的选中和禁用的处理的核心代码就完毕了。还有更多的细节问题可以直接查看 源码 会更清晰。