这段时间写了一堆源码解析,这篇文章想换换口味,跟你们分享一个我工做中遇到的案例。毕竟做为一个打工人,上班除了摸鱼看源码外,砖仍是要搬的。本文会分享一个使用恰当的数据结构来进行性能优化,从而大幅提升响应速度的故事,提升有几百倍那么多。javascript
事情是这样的,我如今供职一家外企,咱们有一个给外国人用的线下卖货的APP,卖的商品有衣服,鞋子,可乐什么的。某天,产品经理找到我,提了一个需求:须要支持三层的产品选项。听到这个需求,我第一反应是我好像没有见到过三层的产品选项,毕竟我也是一个十来年的资深剁手党,通常的产品选项好像最多两层,好比下面是某电商APP一个典型的鞋子的选项:前端
这个鞋子就是两层产品选项,一个是颜色,一个是尺码,颜色总共有11种,尺码总共也是11种。为了验证个人直觉,我把我手机上全部的购物APP,啥淘宝,京东,拼多多,苏宁易购所有打开看了一遍。在我看过的商品中,没有发现一个商品有三层选项的,最多也就两层。java
本文可运行的示例代码已经上传GitHub,你们能够拿下来玩玩:https://github.com/dennis-jiang/Front-End-Knowledges/tree/master/Examples/DataStructureAndAlgorithm/OptimizeVariationsnode
一两家不作这个,多是各家的需求不同,可是你们都不作,感受事情不对头。通过仔细分析后,我以为不作三层选项可能有如下两个缘由:git
上面这个鞋子有11种颜色,11种尺码,意味着这些选项后面对应的是11 * 11
,总共121
个商品。若是再来个第三层选项,假设第三层也有11
个选项,那对应的商品总共就是11 * 11 * 11
,也就是1331
个商品,好多店铺总共可能都没有1331
个商品。也就是说,第三层选项多是个伪需求,用户并无那么多选项放在第三层,仍是以上面的鞋子为例,除了颜色,尺码外,非要再添一个层级,那只能是性别了,也就是男鞋和女鞋。对于男鞋和女鞋来讲,版型,尺码这些很不同,通常都不会放到一个商品下面,更经常使用的作法是分红两个商品,各自有本身的颜色和尺码。github
仅仅是加上第三层选项这个功能并无什么难的,也就是多展现几个能够点击的按钮而已,点击逻辑跟两层选项并无太大区别。可是细想下去,我发现了他有潜在的性能问题。以上面这双鞋子为例,我从后端API拿到的数据是这样的:算法
const merchandise = { // variations存放的是全部选项 variations: [ { name: '颜色', values: [ { name: '限量版574海军蓝' }, { name: '限量版574白粉' }, // 下面还有9个 ] }, { name: '尺码', values: [ { name: '38' }, { name: '39' }, // 下面还有9个 ] }, ], // products数组存放的是全部商品 products: [ { id: 1, price: 208, // 与上面variations的对应关系在每一个商品的variationMappings里面 variationMappings: [ { name: '颜色', value: '限量版574白粉' }, { name: '尺码', value: '38'}, ] }, // 下面还有一百多个产品 ] }
上面这个结构自己仍是挺清晰的,merchandise.variations
是一个数组,有几层选项,这个数组就有几个对象,每一个对象的name
就是当前层级的名字,values
就是当前层级包含的选项,因此merchandise.variations
能够直接拿来显示在UI上,将他们按照层级渲染成按钮就行。后端
上面图片中,用户选择了第一层的限量版574白粉
,第二层的40
,41
等不存在的商品就自动灰掉了。用上面的数据结构能够作到这个功能,当用户选择限量版574白粉
的时候,咱们就去遍历merchandise.products
这个数组,这个数组的一个项就是一个商品,这个商品上的variationMappings
会有当前商品的颜色
和尺码
信息。对于我当前的项目来讲,若是这个商品能够卖,他就会在merchandise.products
这个数组里面,若是不能够卖,这个数组里面压根就不会有这个商品。好比上图的限量版574白粉
,40
码的组合就不会出如今merchandise.products
里面,查找的时候找不到这个组合,那就会将它变为灰色,不能够点。api
因此对于限量版574白粉
,40
这个鞋子来讲,为了知道它需不须要灰掉,我须要整个遍历merchandise.products
这个数组。按照前面说的11
个颜色,11
个尺码来讲,最多会有121
个商品,也就是最多查找121
次。一样的要知道限量版574白粉
,41
这个商品能够不能够卖,又要整个遍历商品数组,11个尺码就须要将商品数组整个遍历11次。对于两层选项来讲,11 * 11
已经算比较多的了,每一个尺码百来次运算可能还不会有严重的性能问题。可是若是再加一层选项,新加这层假如也有11
个可选项,这复杂度瞬间就增长了一个指数,从$O(n^2)$变成$O(n^3)$!如今咱们的商品总数是11 * 11 * 11
,也就是1331
个商品,假如第三层是性别
,如今为了知道限量版574白粉
,40
,男性
这个商品可不能够卖,我须要遍历1331
个商品,若是遍历121
个商品须要20ms
,还比较流畅,那遍历1331
个商品就须要220ms
,这明显能够感受到卡顿了,在某些硬件较差的设备上,这种卡顿会更严重,变得不可接受了。并且咱们APP使用的技术是React Native,自己性能就比原生差,这样一来,用户可能就怒而卸载了!数组
我拿着上述对需求的疑问,和对性能的担忧找到了产品经理,发生了以下对话:
我:大佬,我发现市面上好像没有APP支持三层选项的,这个需求是否是有问题哦,并且三层选项相较于两层选项来讲,复杂度是指数增加,可能会有性能问题,用户用起来会卡的。产品经理:兄弟,你看的都是国内的APP,可是咱们这个是给外国人用的,人家外国人就是习惯这么用,咱要想卖的出去就得知足他们的需求。太卡了确定不行,性能问题,想办法解决嘛,这就是在UI上再加几个按钮,设计图都跟之前是同样的,给你两天时间够了吧~
我:啊!?额。。。哦。。。
咱也不认识几个外国人,咱也不敢再问,都说了是用户需求,咱必须知足了产品才卖的出去,产品卖出去了咱才有饭吃,想办法解决吧!
看来这个需求是必需要作了,这个功能并不复杂,由于三层选项能够沿用两层选项的方案,继续去遍历商品数组,可是这个复杂度增加是指数级的,即从$O(n^2)$变成$O(n^3)$,用户用起来会卡。如今,我须要思考一下,有没有其余方案能够提升性能。通过仔细思考,我发现,这种指数级的复杂度增加是来自于咱们整个数组的遍历,若是我可以找到一个方法不去遍历这个数组,当即就能找到限量版574白粉
,40
,男性
对应的商品存不存在就行了。
这个具体的问题转换一下,其实就是:在一个数组中,经过特定的过滤条件,查找符合条件的一个项。嗯,查找,听起来蛮耳熟的,如今我之因此须要去遍历这个数组,是由于这些查找条件跟商品间没有一个直接的对应关系,若是我能创建一个直接的对应关系,不就能够一下就找到了吗?我想到了:查找树。假如我重组这些层级关系,将它们组织为一颗树,每一个商品都对应树上的一个叶子节点,我能够将三层选项的查找复杂度从$O(n^3)$降到$O(1)$。
为了说明白这个算法,我先简化这个问题,假设咱们如今有两层选项,颜色
和尺码
,每层选项有两个可选项:
咱们如今对应有4个商品:
若是按照最简单的作法,为了查找红色
的39码
鞋子存不存在,咱们须要遍历全部的这四个商品,这时候的时间复杂度为$O(n^2)$。可是若是咱们构建像下面这样一颗树,能够将时间复杂度降到$O(1)$:
上面这颗树,咱们忽略root
节点,在本例中他并无什么用,仅仅是一个树的入口,这棵树的第一层淡黄色节点是咱们第一层选项颜色
,第二层淡蓝色节点是咱们的第二层选项尺码
,只是每一个颜色
节点都会对应全部的尺码
,这样咱们最后第二层的叶子节点其实就对应了具体的商品。如今咱们要查找红色
的39码
鞋子,只须要看图中红色箭头指向的节点上有没有商品就好了。
那这种数据结构在JS中该怎么表示呢?其实很简单,一个对象就好了,像这样:
const tree = { "颜色:白色": { "尺码:39": { productId: 1 }, "尺码:40": { productId: 2 } }, "颜色:红色": { "尺码:39": { productId: 3 }, "尺码:40": { productId: 4 } } }
有了上面这个数据结构,咱们要查找红色
的39码
直接取值tree["颜色:红色"]["尺码:39"]
就好了,这个复杂度瞬间就变为$O(1)$了。
理解了上面的两层查找树,要将它扩展到三层就简单了,直接再加一层就好了。假如咱们如今第三层选项是性别,有两个可选项男
和女
,那咱们的查找树就是这样子的:
对应的JS对象:
const tree = { "颜色:白色": { "尺码:39": { "性别:男": { productId: 1 }, "性别:女": { productId: 2 }, }, "尺码:40": { "性别:男": { productId: 3 }, "性别:女": { productId: 4 }, } }, "颜色:红色": { "尺码:39": { "性别:男": { productId: 5 }, "性别:女": { productId: 6 }, }, "尺码:40": { "性别:男": { productId: 7 }, "性别:女": { productId: 8 }, } } }
一样的,假如咱们要查找一个白色
的,39码
,男
的鞋子,直接tree["颜色:白色"]["尺码:39"]["性别:男"]
就好了,这个时间复杂度也是$O(1)$。
上面算法都弄明白了,剩下的就是写代码了,咱们主要须要写的代码就是用API返回的数据构建一个上面的tree
这种结构就好了,一次遍历就能够作到。好比上面这个三层查找树对应的API返回的结构是这样的:
const merchandise = { variations: [ { name: '颜色', values: [ { name: '白色' }, { name: '红色' }, ] }, { name: '尺码', values: [ { name: '39' }, { name: '40' }, ] }, { name: '性别', values: [ { name: '男' }, { name: '女' }, ] }, ], products: [ { id: 1, variationMappings: [ { name: '颜色', value: '白色' }, { name: '尺码', value: '39' }, { name: '性别', value: '男' } ] } // 下面还有7个商品,我就不重复了 ] }
为了将API返回的数据转换为咱们的树形结构数据咱们写一个方法:
function buildTree(apiData) { const tree = {}; const { variations, products } = apiData; // 先用variations将树形结构构建出来,叶子节点默认值为null addNode(tree, 0); function addNode(root, deep) { const variationName = variations[deep].name; const variationValues = variations[deep].values; for (let i = 0; i < variationValues.length; i++) { const nodeName = `${variationName}:${variationValues[i].name}`; if (deep === 2) { root[nodeName] = null } else { root[nodeName] = {}; addNode(root[nodeName], deep + 1); } } } // 而后遍历一次products给树的叶子节点填上值 for (let i = 0; i < products.length; i++) { const product = products[i]; const { variationMappings } = product; const level1Name = `${variationMappings[0].name}:${variationMappings[0].value}`; const level2Name = `${variationMappings[1].name}:${variationMappings[1].value}`; const level3Name = `${variationMappings[2].name}:${variationMappings[2].value}`; tree[level1Name][level2Name][level3Name] = product; } // 最后返回构建好的树 return tree; }
而后用上面的API测试数据运行下看下效果,发现构建出来的树彻底符合咱们的预期:
如今咱们有了一颗查找树,当用户选择红色
,40
码后,为了知道对应的男
可不能够点,咱们不须要去遍历全部的商品了,而是能够直接从这个结构上取值。可是这就大功告成了吗?并无!再仔细看下咱们构建出来的数据结构,层级关系是固定的,第一层是颜色,第二层是尺码,第三层是性别,而对应的商品是放在第三层性别上的。也就是说使用这个结构,用户必须严格按照,先选颜色,再选尺码,而后咱们看看性别这里哪一个该灰掉。若是他不按照这个顺序,好比他先选了性别男
,而后选尺码40
,这时候咱们应该计算最后一个层级颜色
哪些该灰掉。可是使用上面这个结构咱们是算不出来的,由于咱们并无tree["性别:男"]["尺码:40"]
这个对象。
这怎么办呢?咱们没有性别-尺码-颜色
这种顺序的树,那咱们就建一颗呗!这固然是个方法,可是用户还可能有其余的操做顺序呀,若是咱们要覆盖用户全部可能的操做顺序,总共须要多少树呢?这实际上是性别
,尺码
,颜色
这三个变量的一个全排列,也就是$A_3^3$,总共6
颗树。像我这样的懒人,让我建6棵树,我实在懒得干。若是不建这么多树,需求又覆盖不了,怎么办呢,有没有偷懒的办法呢?若是我能在需求上动点手脚,是否是能够规避这个问题?带着这个思路,我想到了两点:
用户打开商品详情页的时候,默认选中第一个可售商品。这样就至关于咱们一开始就帮用户按照颜色-尺码-性别
这个顺序选中了一个值,给了他一个默认的操做顺序。
若是提供取消功能,他将咱们提供的颜色-尺码-性别
默认选项取消掉,又能够选成性别-尺码-颜色
了。不提供取消功能,只能经过选择其余选项来切换,只能从红色
换成白色
,而不能取消红色
,其余的同样。这样咱们就能永远保证颜色-尺码-性别
这个顺序,用户操做只是只是每一个层级选中的值不同,层级顺序并不会变化,咱们的查找树就一直有效了。并且我发现某些购物网站也不能取消选项,不知道他们是否是也遇到了相似的问题。
对需求作这两点修改并不会对用户体验形成多大影响,跟产品经理商量后,她也赞成了。这样我就从需求上干掉了另外5棵树,偷懒成功!
下面是三层选项跑起来的样子:
前面的方案咱们解决了查找的性能问题,可是引入了一个新问题,那就是须要建立这颗查找树。建立这颗查找树仍是须要对商品列表进行一次遍历,这是不可避免的,为了更顺滑的用户体验,咱们应该尽可能将这个建立过程隐藏在用户感知不到的地方。我这里是将它整合到了商品详情页的加载状态中,用户点击进入商品详情页,咱们要去API取数据,不可避免的会有一个加载状态,会转个圈什么的。我将这个遍历过程也作到了这个转圈中,当API数据返回,而且查找树建立完成后,转圈才会结束。这在理论上会延长转圈的时间,可是本地的遍历再慢也会比网络请求快点,因此用户感知并不明显。当转圈结束后,全部数据都准备就绪了,用户操做都是$O(1)$的复杂度,作到了真正的丝般顺滑~
上面的方案都是在前端建立这颗树,那有没有可能后端一开始返回的数据就是这样的,我直接拿来用就行,这样我又能够偷懒了~我还真去找事后端,可他给我说:“我也想偷懒!”开个玩笑,真是状况是,这个商品API是另外一个团队维护的微服务,他们提供的数据不只仅给我这一个终端APP使用,也给公司其余产品使用,因此要改返回结构涉及面太大,根本改不动。
其实咱们这个方案实现自己是比较独立的,其余人要是用的话,他也不关心你里面是棵树仍是颗草,只要传入选择条件,可以返回正确的商品就行,因此咱们能够将它封装成一个类。
class VariationSearchMap { constructor(apiData) { this.tree = this.buildTree(apiData); } // 这就是前面那个构造树的方法 buildTree(apiData) { const tree = {}; const { variations, products } = apiData; // 先用variations将树形结构构建出来,叶子节点默认值为null addNode(tree, 0); function addNode(root, deep) { const variationName = variations[deep].name; const variationValues = variations[deep].values; for (let i = 0; i < variationValues.length; i++) { const nodeName = `${variationName}:${variationValues[i].name}`; if (deep === variations.length - 1) { root[nodeName] = null; } else { root[nodeName] = {}; addNode(root[nodeName], deep + 1); } } } // 而后遍历一次products给树的叶子节点填上值 for (let i = 0; i < products.length; i++) { const product = products[i]; const { variationMappings } = product; const level1Name = `${variationMappings[0].name}:${variationMappings[0].value}`; const level2Name = `${variationMappings[1].name}:${variationMappings[1].value}`; const level3Name = `${variationMappings[2].name}:${variationMappings[2].value}`; tree[level1Name][level2Name][level3Name] = product; } // 最后返回构建好的树 return tree; } // 添加一个方法来搜索商品,参数结构和API数据的variationMappings同样 findProductByVariationMappings(variationMappings) { const level1Name = `${variationMappings[0].name}:${variationMappings[0].value}`; const level2Name = `${variationMappings[1].name}:${variationMappings[1].value}`; const level3Name = `${variationMappings[2].name}:${variationMappings[2].value}`; const product = this.tree[level1Name][level2Name][level3Name]; return product; } }
而后使用的时候直接new
一下就行:
const variationSearchMap = new VariationSearchMap(apiData); // new一个实例出来 // 而后就能够用这个实例进行搜索了 const searchCriteria = [ { name: '颜色', value: '红色' }, { name: '尺码', value: '40' }, { name: '性别', value: '女' } ]; const matchedProduct = variationSearchMap.findProductByVariationMappings(searchCriteria); console.log('matchedProduct', matchedProduct); // { productId: 8 }
本文讲述了一个我工做中实际遇到的需求,分享了个人实现和优化思路,供你们参考。个人实现方案不必定完美,若是你们有更好的方案,欢迎在评论区讨论~
本文可运行的示例代码已经上传GitHub,你们能够拿下来玩玩:https://github.com/dennis-jiang/Front-End-Knowledges/tree/master/Examples/DataStructureAndAlgorithm/OptimizeVariations
下面再来回顾下本文的要点:
文章的最后,感谢你花费宝贵的时间阅读本文,若是本文给了你一点点帮助或者启发,请不要吝啬你的赞和GitHub小星星,你的支持是做者持续创做的动力。
做者博文GitHub项目地址: https://github.com/dennis-jiang/Front-End-Knowledges
我也搞了个公众号[进击的大前端],不打广告,不写水文,只发高质量原创,欢迎关注~