這段時間寫了一堆源碼解析,這篇文章想換換口味,跟你們分享一個我工做中遇到的案例。畢竟做爲一個打工人,上班除了摸魚看源碼外,磚仍是要搬的。本文會分享一個使用恰當的數據結構來進行性能優化,從而大幅提升響應速度的故事,提升有幾百倍那麼多。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
我也搞了個公衆號[進擊的大前端],不打廣告,不寫水文,只發高質量原創,歡迎關注~