找回密码
 会员注册
查看: 25|回复: 0

商品SKU功能设计与优化(前端)

[复制链接]

2万

主题

0

回帖

6万

积分

超级版主

积分
64454
发表于 2024-9-19 21:19:26 | 显示全部楼层 |阅读模式
作者简介❝郑瑜栋:在团队拥有一个奇怪的外号“=”哥,hh,咱也是有标点(签)的人了❞前言商品SKU的创建与查询,是「电商业务」最经典的开发场景之一,也是整个电商系统「最基础」的功能。因为假如缺少了它,那么也许连准确描述定位一件商品,这样最基本的需求,都将变得困难重重,商品的「库存管理」也就无处谈起。概览SKU功能设计流程如图所示,整体流程可分为两部分:「运营端」。负责创建与配置SKU,运营通过操作SKU选项,生成一个SKU列表。「用户端」。负责SKU的库存状态查询。在商品页,需要根据用户已选规格,去SKU列表里进行匹配查找。假如用户选择完毕,能够命中一份有库存的SKU数据,就提交一个SKU-ID给到下单流程。总结:这是一种「前端为主」的实现方案,SKU的「创建与查询部分」都由前端来完成,后端只负责SKU的「存储」。这也是各大电商平台上的主流实现。当然也有「后端为主」的方案,「SKU的创建、查询、存储都交给后端来完成」。「每次操作都可以看作是一次向后端的表单提交」,并且只需要提交必要的操作信息,后端处理完成,「返回前端需要的最终结果就可以了」。这种方案的优势是「库存数据的实时性好」,但无论是运营端动态创建SKU列表,还是用户在页点击选项查询库存,都是足够「高频的交互」,每次的异步请求查询,出loading,都会打断使用者的当前操作,「用户体验差」。并且这种实现方式前端工作量很少,所以本文不会涉及。「Demo项目简介:」为了更清晰的内容讲解,笔者整理了一个sku-demo[1],包含了「(1)怎样在运营端进行SKU的创建;」「(2)用户端怎样在商品页进行SKU的查询」。麻雀虽小,五脏俱全,基本涵盖了前端SKU方面的绝大部分处理场景,读者可以下载并把项目跑起来,对照着来阅读,会理解的更快一些。用useContext+useReducer模拟了一个小型的「全局Store」,当做用户端与运营端的共享数据存储,已此来「模拟后台到前台」的一个完整交互。运营配置页(src/views/CreateSKU.tsx)负责配置两份数据:一份是「属性选项列表」(attrList),另一份是「SKU列表」(skuList),配置完成后输出到用户端商品页(src/views/SearchSKU_xxx.tsx)分别用来渲染选项UI及查询SKU信息,所以将它们都存入Store中,方便共享通信,模拟「SKU从运营端生成=>发送服务端存储=>用户端读取查询的全过程」。一、SKU创建npmstart启动项目后,会默认进入一个模拟「运营端」的商品配置页。如下图:demo-hometip:点击页面左上角的入口按钮可以进行运营端与用户端的切换。整体操作:就是运营通过「操作左侧各种规格选项,联动生成右侧的SKU列表」。需求关键点分析:运营对规格选项的操作大致可分为两类:对属性的「具体选项」进行增删改会影响属性选项的个数,需要在SKU列表中适当位置进行插入或者删除操作。对「属性」进行增删会影响数据的层数,意味当前所有SKU的选项组合都不是最新的,需要清空,重新组合。比如原来属性类型只有颜色,尺寸,款式,现在增加一组套餐属性,那之前的所有SKU组合都需要和套餐再次组合,并且之前配置的库存数据也都失效了。为了降低设计的复杂度,采用了如下的方案:「不区分操作,无论哪种操作类型,统一都走重新组合生成的逻辑」。(当然如果十分在乎性能,也可以通过区分操作进行优化,类似于vue中针对不同dom操作类型所进行的domDiff效率方面的优化)。如下图:将左侧不同类型的规格值,通过「排列组合」的方式,生成右侧这样一个SKU列表。SKU生成具体实现:那么问题就抽象为:「求商品规格列表的全排列组合」。这个过程是一个典型的树形结构,需要遍历到这颗树的每一个叶子,最终将叶子收集起来。tree感兴趣的可以看下leetcode上组合[2]这道题,解题思路很类似。对于层数不固定的Tree结构数据,首先可以想到用「递归」的思路求解:递归版本代码位置:src/views/CreateSKU.tsx/***@description递归版本的SKU全排列组合*@paramattrList属性列表*@paramprevSkuList上一次的sku列表数据*@returns新的sku列表数据*/exportfunctioncreateSkuList(attrList:AttrList,prevSkuList:SkuList=[]):SkuList{constskuList:SkuList=[];//收集结果letid=0;//生成skuId//旧的SkuList转map,方便下方的复用判断constprevSkuMap=skuList2Map(prevSkuList);constloop=(rowIndex:number,prevOptions:AttrSetItem[])=>{constattrItem=attrList[rowIndex];if(attrItem.options.length===0){loop(rowIndex+1,prevOptions)return}for(constoptionofattrItem.options){constcurOptions=prevOptions.concat({label:attrItem.attrLabel,valueption.value});//判断如果是最后一层,那就是组合完整了,将结果收集到全局的容器里if(rowIndex===attrList.length-1){id++;//将sku的选项值用'_'连接起来组成一个keyconstkey=curOptions.map(({value})=>value).join('_');//如果改变前后的skukey相同,复用sku数据,避免数据覆盖if(prevSkuMap[key]){skuList.push({...prevSkuMap[key],id:`${id}`})}else{skuList.push({id:`${id}`,key,attrSet:curOptions,stock:0})}}else{loop(rowIndex+1,curOptions)}}}loop(0,[])returnskuList}/***@descriptionsku列表数据转map,方便映射查找,判断sku数据对比复用*@paramskuListsku列表*@returnsskuKey做键,sku数据做值的sku查找映射*/functionskuList2Map(skuList:SkuList){returnskuList.reduce((map,sku)=>{map[sku.key]=sku;returnmap},{})}循环版本除了递归,也可以用「循环」来求解。但他们本质其实是一样的,都是n^m的时间复杂度,循环只是靠迭代中的临时变量tempList,模拟了递归里栈的概念,所以与递归版本的区别就是使用原生的函数调用栈还是自己代码里的栈。/***@description循环版本的SKU全排列组合*@paramattrs属性列表*@paramprevSkuList上一次的sku列表数据*@returns新的sku列表数据*/exportfunctioncreateSkuList1(attrList:AttrList,prevSkuList:SkuList=[]):SkuList{letskuList:SkuList=[];letid=0;//用来生成skuId//旧的skuList转下map,方便下方的复用判断constprevSkuMap=skuList2Map(prevSkuList)//1.遍历规格大类attrList.forEach((row)=>{if(row.options.length===0){return}//初始化第一层if(skuList.length===0){skuList=row.options.map((option)=>{id++constattrSet=[{label:row.attrLabel,valueption.value}]return{id:`${id}`,key:attrSet.map(({value})=>value).join('_'),attrSet,stock:0}});}else{consttempList:SkuList=[];id=0;//2.遍历当前已累积的规格组合skuList.forEach((skuItem)=>{//3.遍历当前规格值,并将值与所有已累积的规格进行拼接row.options.forEach((option)=>{id++;constattrSet=skuItem.attrSet.concat({label:row.attrLabel,valueption.value});constkey=attrSet.map(({value})=>value).join('_');//如果改变前后的skukey相同,复用sku数据,避免覆盖if(prevSkuMap[key]){tempList.push({...prevSkuMap[key],id:`${id}`})}else{tempList.push({id:`${id}`,key,attrSet,stock:0})}})});if(row.options.length>0){skuList=tempList}}})returnskuList;}/***@descriptionsku列表数据转map,方便映射查找,判断sku数据对比复用*@paramskuListsku列表*@returnsskuKey做键,sku数据做值的sku查找映射*/functionskuList2Map(skuList:SkuList){returnskuList.reduce((map,sku)=>{map[sku.key]=sku;returnmap},{})}具体的代码不展开讲解了。但是有一点是需要格外注意的:SKU列表里的数据,虽然「每次都重新生成一遍」,但针对之前已经配置过的内容数据(比如库存数量)在「重新生成前后,SKU组合并无改变的情况下,是需要保留的」,不然这些数据就全丢失了,所以在创建时就给每条sku数据定义一个「key」(包含的选项值拼接而成的字符串)。eg:对于选项组合为[M,红,宽松]的sku,M_红_宽松就作为它的一个唯一key标示,在SKU重新创建时,会拿着新生成的key在旧的sku数据里的查找一样的,如果可以找到,就「复用原来的数据」,这样就避免了重新生成后导致的原数据覆盖丢失。这样运营端SKU的生成部分工作就完成了,下边讲用户端SKU的查询。二、SKU查询商品页这是SKU查询功能的截图,这里产品经理一般都会提一个交互需求:「希望根据用户当前已选的规格值,能够预判出哪些SKU组合是缺货状态,提前将对应按钮置灰。」比如上方截图里,[红色,M,男款]缺货,在选择红色、M后,就需要提前将男款置灰。选后俩反着选,先选后两行的M、男款,置灰第一行的红色。选中间先选前后两行红色、男款,需要置灰中间的M。这还是只一个SKU缺货,如果两个SKU缺货呢?比如[红色,M,男款]、[红色,M,女款]缺货,也就是红色的M号都缺货。在这种情况下,只选择一个红色,就需要判断出M不可选。1-2个sku缺货已经需要有上边那么多置灰的情况需要判断,那么更多的sku缺货呢,想想就头大,需要判断的情况太多了,或者根本不知道从何判断。不过通过以上的场景推演,也能总结出一些导致问题变得复杂的原因:用户「选择路径不一定完整」,在用户全选完整之前,就需要做判断。用户「选择的先后顺序并不确定」,用户可以跳着选、反着选。「点击任意选项,都有可能触发其他任意位置的选项置灰与否」那么就可以想到一个统一的思路:「无论当前选择了什么,都遍历全部选项,站在每个选项的角度,判断需不需要将自身置灰」。动手实现之前,先了解下已知条件:「attrList:属性列表」,用来展示选项UI,并且给每个选项自定义了一个disabled字段,用来表示按钮置灰状态。constattrList=[{"attrLabel":"颜色","options":[{"value":"红色","disabled":true},{"value":"绿色","disabled":true},{"value":"蓝色","disabled":true}]},...]「skuList:运营端生成的SKU列表」。判断缺货与否的数据源,stock=0即为缺货constskuList=[{"id":"1","key":"红色_M_男款","attrSet":[{"label":"颜色","value":"红色"},{"label":"尺寸","value":"M"},{"label":"款式","value":"男款"}],"stock":0},...]根据上边整理的思路,写出以下代码常规的循环匹配法/***@description属性的选项状态*@paramattrList属性列表*@paramskuListsku列表数据*/functionsetAttrOptionStatus(attrList:AttrList,skuList:SkuList){//1.获取已选规格集合{A}constselectedSet=attrList.reduce((arr,item)=>{item.currentValue&(arr[item.attrLabel]=item.currentValue);returnarr},{})//2.遍历所有待选规格attrList.forEach((attr)=>{attr.options.forEach((option)=>{if(option.isChecked){return}//3.待选项{x}与已选项{A}组成新的集合B={A,x}constnextSelectSet={...selectedSet,[attr.attrLabel]ption.value}constkeys=Object.keys(nextSelectSet);/*4.遍历sku列表,看能否找到符合以下两种条件的sku(1)选项匹配:找到sku对应的规格集合C,判断B与C是否具有包含关系B{returnkeys.every((attrKey)=>sku.stock>0&sku.attrSet.findIndex((option)=>{returnoption.value===nextSelectSet[attrKey]})>-1)})===-1;})})returnattrList}获取已选规格集合{A}。遍历所有待选规格选项。将每一个待选项{x}与已选项{A}组成新的集合B={A,x}遍历sku列表,看能否找到「同时符合以下两种条件」的sku(1)「有货的」:判断库存stock,是否大于0(2)「规格选项匹配」:找到每个sku对应的规格集合C,判断B与C是否具有包含关系B{if(sku.stockitem.value)constkeysArr=powerset(ids);keysArr.forEach((keyArr)=>{constkey=keyArr.join('_')constv=map[key];map[key]=v?[...v,sku]:[sku]})})returnmap}计算结果如下:skuMap接下来实现查询主函数src/views/SearchSKU_Map.tsx/***@description根据当前所选属性值,更新属性按钮的禁用状态=>map版本*@paramsaleAttrs*@paramskuList*@returns*/functionsetAttrOptionStatus(attrList:AttrList,skuMap:AnyOptionSkuMap){//1.获取已选规格集合{A}constselectedSet=attrList.reduce((arr,item)=>{item.currentValue&(arr[item.attrLabel]=item.currentValue);returnarr},{})//2.遍历所有待选规格attrList.forEach((attr)=>{attr.options.forEach((option)=>{if(option.isChecked){return}//3.待选项{x}与已选项{A}组成新的集合B={A,x}constnextSelectSet={...selectedSet,[attr.attrLabel]ption.value}/*4.将集合B的元素值拼一个字符串的key,去提前计算好的skuMap字典里查找若无查找结果,则此按钮需要置灰,反之亦然。*/constvalueArr=attrList.map(({attrLabel})=>nextSelectSet[attrLabel]).filter(Boolean)constsku=skuMap[valueArr.join('_')]option.disabled=sku===undefined;})})}这里的1、2、3步骤与上边的第一种暴力循环法,是一致的,区别是第4步的。不再是去skuList里复杂的遍历匹配,而是「简单的通过判断key(由集合B的选项值按固定的顺序拼成的一个字符串)是否在结果Map中存在」即能知道对应的按钮是否需要缺货置灰。「优点:」「查询的时间复杂度降为了O1」,因为复杂的遍历匹配过程简化为了一次map查找,非常快。「缺点:」一次幂集拆分就有2^n个子集。全部sku就有n^m乘以2^n个键值对,最终如上图所示,拆出的键值对非常多,非常陇余。初始化较慢,「计算出的map键值对非常多,造成空间浪费」。选择两种方案的优缺点都很明显,但其实还有第三种无向图方案,可以取一个均衡。大意是用邻接矩阵的形式,可以在查询中多一个维度的信息,减少遍历范围,在大量属性数据下是有明显的性能意义的(但鸽了许久没有完成)。以上介绍的两种方案应付大多数的业务场景应该都是没有问题的,也都是使用率很高的两种解法,读者碰到类似场景可以按需选择。Reference[1]sku-demo:https://github.com/FEyudong/sku-demo[2]组合:https://leetcode-cn.com/problems/combinations/
回复

使用道具 举报

您需要登录后才可以回帖 登录 | 会员注册

本版积分规则

QQ|手机版|心飞设计-版权所有:微度网络信息技术服务中心 ( 鲁ICP备17032091号-12 )|网站地图

GMT+8, 2024-12-27 00:07 , Processed in 0.950585 second(s), 27 queries .

Powered by Discuz! X3.5

© 2001-2024 Discuz! Team.

快速回复 返回顶部 返回列表